@voltro/plugin-billing 0.24.0 → 0.26.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 +670 -0
  2. package/package.json +6 -6
package/CHANGELOG.md CHANGED
@@ -39,6 +39,676 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.26.0] — 2026-08-04
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/protocol, @voltro/plugin-webhooks, @voltro/cli** — `defineEvent({ webhook: { retry } })` is removed. It never did anything.
47
+
48
+ The field was typed, documented as "default retry policy for new subscriptions", and read by nothing — `grep` for `spec.retry` across the repo returned no hits. Setting it produced no error, no warning and no effect: the value was dropped where an event descriptor is projected into an outgoing webhook descriptor, and a comment there explained why (the plugin's `RetryPolicy` is a richer shape than the two numbers the protocol carried, so forwarding it blind would install a policy nobody wrote). A test pinned that dropping as correct.
49
+
50
+ The reasoning was sound and the result was still wrong, because none of it reached the user: they wrote a typed option and got silence. This is the third instance of that exact shape in this feature — `broadcast({ channel })` was declared, named in its own doc comment as the multi-deployment answer, and forwarded by nothing; an event's `guards` were accepted, serialised into the manifest, reported by doctor and counted in the devtools panel while nothing enforced them. Two were found by consumers. This one was found by walking the option surface and asking, per field, who reads it.
51
+
52
+ Retry belongs on the SUBSCRIPTION, where the full `RetryPolicy` shape is available and typed. If you set it on the event, delete it — nothing changes at runtime, because nothing was reading it.
53
+
54
+ **The guard that exists for this class did not catch it, and that is the more important half.** `declaredOptionsEnforced.test.ts` checks a hand-maintained list of options; it can only re-verify the ones somebody remembered to add, and it covers no nested field at all. It was green throughout. Deriving that list from the type rather than curating it is filed as follow-up — the same lesson as `procedureWireReachability.test.ts`, which was satisfied at every site it knew about while the defect sat at a site it did not consider one.
55
+ - **@voltro/workflow** — The `retry:` field on a workflow `step({...})` is now **ENFORCED**, not dashboard metadata. The framework compiles the declared policy to an Effect `Schedule` and retries `execute` accordingly — so `step({ retry: { maxAttempts: 5 } })` actually retries five times, no hand-written `Effect.retry` needed.
56
+
57
+ It became a real, innovative policy while it was at it — the conditions you actually want, default-correct:
58
+
59
+ - **Error classification** — `retryableErrors: ['ProviderDown', 'RateLimited']` (retry only these typed-error `_tag`s; everything else fails fast) or `retryable: (error) => boolean`. Retry the transient, fail the permanent. - **A time BUDGET, not just a count** — `maxElapsed: '5 minutes'` stops retrying once that much wall-clock has elapsed, even if attempts remain. A deadline. - **Jitter** — `jitter` (ON by default) spreads retries so a fleet doesn't re-hit a recovering dependency in lockstep. - **Capped backoff** — `maxDelay` ceilings exponential growth; `strategy` (`exponential` / `fixed` / `linear`), `baseDelay`, `factor`, `step`. - **Provider-driven backoff** — `respectRetryAfter` honors a `retryAfterMillis` / `retryAfter` hint on the error as a floor (a 429 `Retry-After`).
60
+
61
+ Retries run inside the one step and are transparent to the durable engine; the step's final outcome is recorded, and the serialisable knobs still feed the dashboard. `stepModule.retry(…, Schedule)` remains for full hand-written `Schedule` control.
62
+
63
+ **BREAKING, and check the first half before the second.**
64
+
65
+ **A step that declared `retry:` and nothing else ran ONCE. It now runs up to `maxAttempts` times.** In 0.25.0 the field's own type said so — *"Pure metadata — does NOT change retry behavior on its own"* — so trusting it was correct. If `execute` is not idempotent (a charge, an email, an outbound POST), that is real duplicate work beginning on this upgrade, with nothing in your code changed to cause it. Per step: make the effect idempotent, or set `maxAttempts: 1`, or narrow with `retryableErrors: [...]` so only transient failures retry.
66
+
67
+ The second half is the one you can see in your own source: the old docs told you to ALSO wrap the step in `stepModule.retry` / `Effect.retry`, and a step that did both now retries TWICE. Keep the declarative `retry:` (it also drives the dashboard) and drop the redundant wrapper — or, if your hand-written `Schedule` did something the policy can't express, keep it and drop `retry:` from that step.
68
+
69
+ `codemod: 0.26.0/02_step-retry-enforced` (manual) prints both, and fires for any project declaring `retry:` on a step — not only those with a manual wrapper.
70
+
71
+ ### Added
72
+
73
+ - **@voltro/plugin-audit, @voltro/plugin-versioning** — **The audit trail can name its own actor, and it covers more than mutations.**
74
+
75
+ *B1 — the actor is a snapshot now, not a reference.* The argument that decides this lives inside ONE row: `_voltro_row_history.data` is a full-row snapshot, deliberately, so it survives what happens to its source — while the same row's `changedBy` is a foreign key that does not. One record, two philosophies: the row's state preserved forever, its author only until someone exercises a right to be forgotten.
76
+
77
+ That right is one we grant. `@voltro/plugin-governance`'s `governance.erase` (`delete | anonymize`) is ours and recommended, so a deployment can install auditPlugin + versioningPlugin + governancePlugin and have the third render the first two unreadable for precisely the subjects an investigation is about. Anonymisation is the worse half because it looks like it worked: the join SUCCEEDS and returns "Anonymised" for every entry that actor ever produced, retroactively rewriting history that was correct when written. A rename does the same, silently.
78
+
79
+ Both tables gain `actor json {id,type,displayName,email}`, resolved from the `actors` row at WRITE time — the moment the identity is still true. `email` is read opportunistically, because the framework's own columns are `id`/`kind`/`displayName` and apps commonly extend it; insisting on a fixed shape would make the field useless where it is needed most. Resolution is best-effort and never fails the mutation it records, and absent stays absent — a fabricated placeholder is the thing this column exists to prevent.
80
+
81
+ `_voltro_audit_log` also gains `metadata json` the app writes: the noun a diff cannot contain. "Anna removed Bernd from the Frontend sub-team" is one row-delete plus a membership row, and no column-level detail reconstructs the sentence a compliance reader needs.
82
+
83
+ *B2 — actions and queries are audited too.* The interceptor was mutation-only, measured by the reporter against their own data: all ten rows carried mutation tags, so a successful login, a GDPR export and a third-party write from an action left no trace at all. For a compliance trail that is a LARGER hole than a missing name — the question "who exported this" had no row to be missing one on. `interceptAction` and `interceptQuery` were available slots the plugin simply never filled.
84
+
85
+ Actions record by default (they write). Queries are opt-in via `recordQueries`, because a read-heavy app writes one row per read and a trail that drowns in reads is worse than one missing them — nobody searches it. Turn it on for the surfaces where the READ is the sensitive act, usually with `include`.
86
+
87
+ codemod: none
88
+ - **@voltro/runtime** — The credential bound now covers EVERY realtime primitive, not just events.
89
+
90
+ An event stream got it first; live queries and `*.stream.ts` streams are the same kind of standing grant and did not have it. All three now end when the credential that authorized them expires, and the clients' existing reconnect re-opens them as a NEW request — fresh subject, guards re-run for real.
91
+
92
+ It is ONE function (`boundByCredential`) that all three call rather than the same three lines in three binders. A value derived independently at several sites is the shape this repo has been bitten by repeatedly: every site looks correct and they disagree the moment one is edited.
93
+
94
+ Where the halves sit, because they are easy to conflate: the per-delivery guard re-check catches RESOURCE revocation (its resolver does a live lookup); the credential bound catches the ROLE case, whose scopes were captured when the subscription opened and never change. Neither covers the other.
95
+
96
+ codemod: none
97
+ - **@voltro/runtime** — An event stream now re-authorizes on EVERY delivery, as a live query already did. Events were the weaker of the two for the same kind of grant.
98
+
99
+ `servePipeline` states the reasoning for the identical case and it applies verbatim: a subscription is a LONG-LIVED grant, and the scopes that justified it can be withdrawn while it is still open — a role revoked, a resource un-shared, a membership ended. Without a re-check the socket keeps delivering what the subject may no longer read. Live queries have re-authorized per delivery for some time; event streams were checked once, at subscribe, and never again.
100
+
101
+ **This corrects a conclusion drawn in this repo one change earlier.** That change argued a re-check was unbuildable at this seam because the subject is captured per request, so re-checking it always confirms. True of the scopes ON the subject — and wrong as a general claim, because `checkGuardsEffect` runs the async RESOURCE-SCOPE resolver, which does a live lookup. For a resource-scoped guard (`{ scope: 'arena:read', from: 'arenaId' }`, the shape events use) the re-check catches revocation for real. The two mechanisms cover different halves: this catches resource revocation, and the credential bound added alongside it catches the role case by refusing to outlive the token.
102
+
103
+ A denial ENDS the stream rather than dropping the delivery. A silently skipped delivery is indistinguishable from "nothing happened", which is the one outcome this primitive exists to eliminate; the client is told, and its reconnect gets the refusal as a typed error. An unguarded event pays nothing — the closure short-circuits before any effect is built.
104
+
105
+ Red-verified, and the first version of that verification FAILED to go red: the test asserted only that the stream failed, and `Effect.timeout` also fails, so a stream that never ended satisfied it. It asserts the failure VALUE now — a scope denial, explicitly not a timeout.
106
+
107
+ codemod: none
108
+ - **@voltro/plugin-notifications** — `archive` / `unarchive`, `markUnread` and `markAllRead` — the four procedures that were keeping an app off this plugin.
109
+
110
+ The reporter's comparison was fair and worth repeating: our surface is richer than theirs on the parts we have (quiet hours, channel preferences, delivery logs) and was missing the ones a user touches most. **Archive was not merely a missing procedure — the word appeared nowhere in this plugin's types.** `readAt` covers read; the delivery table's `status` is the delivery outcome (`sent | failed | skipped`). Neither is an archive, and archiving is the action that empties an inbox. An inbox nobody can clear is one they stop opening.
111
+
112
+ `archivedAt` is therefore its own column and its own state: archiving does not mark an item read, and an archived-but-unread item still counts toward `unreadCount`. A UI that conflates them cannot show what a user did.
113
+
114
+ `markUnread` exists because an inbox without a way back is a one-way ratchet, and `markAllRead` because marking two hundred items one at a time is not a feature. It reports how many rows it changed — a caller showing "12 marked read" must not be told 200 because that is how many rows exist.
115
+
116
+ All four are subject-scoped like `markRead`: an inbox action must not reach across subjects because an id happens to be guessable.
117
+
118
+ codemod: none
119
+ - **@voltro/database, @voltro/runtime** — `pluginRef(table, { orphanPolicy })` — point at a plugin-owned row from an app table, with a declared rule.
120
+
121
+ ```ts
122
+ favouriteOf: pluginRef(aiFlowsTable, { orphanPolicy: 'delete' })
123
+ sharedFlow: pluginRef(aiFlowsTable, { orphanPolicy: 'null' }).nullable()
124
+ ```
125
+
126
+ No foreign key is emitted, and that part was already right: the plugin owns its table and may rename it — the `_voltro_` migration did exactly that across ten tables — so a cross-boundary FK would turn every rename into a coordinated migration of every app pointing at it. `ai_flow_runs.flowRef` is a plain string for the same reason, and `plugin-storage` ships `assetRef({ fk: false })`.
127
+
128
+ **What was lost with the FK is not the constraint but the ORPHANING RULE**, and a reporter's census shows the shape of it: 711 app→app references carrying an `orphanPolicy`, against 2 pointers at plugin rows. Not because pointing across the boundary is rare — because there was no pattern, so each one becomes a hand-written subscriber that cleans up on delete. Bespoke referential integrity, re-implemented per app, and nothing notices when someone forgets one.
129
+
130
+ Four decisions, each answering an edge case they raised:
131
+
132
+ - **Tenant — fail closed.** A referencing row whose tenant differs from the deleted row's, or which has none, is left alone. Deleting across a tenant boundary because a scope was missing is the one outcome worse than an orphan. - **Soft delete — opt in per reference** (`onSoftDelete`). A soft delete is a state the target can undo, so cascading on it destroys rows a restore cannot bring back; and plugin tables are inconsistent here by design (`_voltro_ai_flows` has `deletedAt`, `_voltro_ai_flow_runs` does not), so a guess would be wrong for half of them. - **Rename — the target is a table VALUE**, resolved through the handle the plugin exports, so a rename carries the rule with it. Referencing by string would reintroduce the coupling the missing FK exists to avoid. - **`'keep'` is a policy, not the absence of one.** Same behaviour as omitting it, arrived at deliberately and reviewable as such. The default stays `'keep'` — a default that deleted rows would be a footgun.
133
+
134
+ codemod: none
135
+ - **@voltro/protocol, @voltro/runtime** — `defineStream` accepts `guards:`, and they are enforced.
136
+
137
+ A stream was the ONE realtime primitive that could not express authorization at all. Queries, mutations and actions carry `guards:`; `StreamProcedureDescriptor` had no such field. Whatever protection a `*.stream.ts` had was hand-written inside its executor, where nothing could verify it existed — not the boot audit, not `voltro doctor`, not a reviewer reading the descriptor. The absence was invisible in exactly the way that matters: a stream with no authorization and a stream whose authorization lives in its body look identical from outside.
138
+
139
+ Checked at subscribe AND before every element, the same as a query's, for the same reason `servePipeline` already gives: a stream is a long-lived grant and the scopes that justified it can be withdrawn while it is still open. The guard INPUT is the call's decoded input, so a resource-scoped guard (`{ scope: 'feed:read', from: 'id' }`) sees which resource was asked for.
140
+
141
+ A denial ENDS the stream rather than dropping the element. A skipped element is indistinguishable from "nothing to send", and the client must learn it lost access rather than infer it from silence. An unguarded stream pays nothing.
142
+
143
+ Deliberately NOT wired into the manifest, doctor or the devtools panel in this change. The event-`guards` defect was reporting surfaces showing protection that nothing enforced; enforcement without reporting is the safe direction of that same asymmetry — it works and is merely not displayed yet.
144
+
145
+ codemod: none
146
+ - **@voltro/protocol, @voltro/runtime, @voltro/cli** — An event subscription can no longer outlive the credential that authorized it.
147
+
148
+ Guards are checked once, at subscribe. That is not an oversight to patch: the subject comes from THAT request's layer, so re-checking it later inside the stream asks the same captured object and always gets the same answer. A "re-check on subject change" built at that seam would be a control path that always confirms — worse than no check, because it looks like one.
149
+
150
+ The honest bound is a fact the token already carries. `ConnectionInfoValue` gains `credentialExpiresAt` (unix seconds, verified — an unverified decode would let a client forge a far-future expiry and lift the very ceiling this imposes), and `bindEvent` ends the stream there. Absent means no bound, so the failure direction is the behaviour that already existed.
151
+
152
+ **It is seamless, and that costs nothing to build.** `useEvent` already treats a clean end as a reconnect reason — a server never legitimately finishes a stream a client still wants — so it re-opens immediately. A reconnect is a NEW request: the subject is resolved afresh and the guards run again for real. Still entitled, it continues and the app sees nothing; no longer entitled, the reconnect is refused loudly instead of delivering forever on a dead credential. No application-side reconnect handling.
153
+
154
+ `sessionExpiryFromHeaders` is a SHARED helper both boot paths call, and `SESSION_COOKIE_NAME` moved to `@voltro/protocol/session` so it has one definition rather than one per reader — dev and serve deriving one value twice is how the two paths come to disagree silently.
155
+
156
+ This bounds EXPIRY, not revocation. A role revoked mid-session is not observed until the credential runs out, and the docs say so in both languages rather than implying more. Revocation belongs at the session seam — a revoke event that ends the connection is one place instead of one per primitive, and this reconnect machinery would then carry it for free.
157
+
158
+ codemod: none
159
+ - **@voltro/plugin-webhooks** — Three additions that were the whole distance between a consumer and deleting their own webhook tables.
160
+
161
+ **`scope` — an opaque app dimension on `_voltro_webhook_targets`.** Stored and returned verbatim, never interpreted; `listTargets(event, scope)` filters on equality against it. `.with(tenant())` is one level too coarse for real deployments: their endpoints are scoped to a TEAM and a tenant has many teams, so every read filters by it and every write guards on it.
162
+
163
+ The precedent is theirs, and it decided a migration: `_voltro_presence.meta` is json the framework stores and never interprets, and it is the ONLY reason their presence migration was lossless — three denormalised columns went straight in. An earlier review of theirs called that plugin lossy and they withdrew it. The general form they derived is the right one: **a plugin that stores rows in an app's database on the app's behalf needs one place for the app's own dimension.**
164
+
165
+ **`listDeliveries` / `getDelivery`.** There was no service method over `_voltro_webhook_deliveries`, so a management view could only query the table directly — which they declined, correctly: the 0.24.0 `agent_messages` rename taught them what app code coupled to a framework table name costs, and that one was survivable only because it was a rename. `listDeliveries` omits `payload` and `responseBody` so a list view does not pull response bodies for 200 rows; `getDelivery` adds them. Timestamps are normalised to ISO regardless of what the dialect returned, and an unparseable payload comes back verbatim rather than throwing — a management view must render a malformed row, not 500.
166
+
167
+ **`updateTarget` and `testTarget`.** Editing a URL previously meant delete + re-subscribe, which rotates the secret (every receiver reconfigured) and orphans the delivery history. The patch writes only the keys present, so an absent key leaves the column alone while an explicit `null` clears it; `event` and `secret` stay unpatchable (a different event is a different subscription, and the secret has `rotateSecret`). `testTarget` sends ONE delivery, bypassing fan-out and the filter — a filter excluding the probe would make a healthy endpoint look dead — but NOT `active`, so a paused target queues exactly as an emit would and the test tells the truth about production.
168
+
169
+ codemod: none
170
+ - **@voltro/cli, @voltro/runtime, @voltro/workflow** — **Cross-replica workflow WAKE** — a triggered workflow now starts ~immediately across replicas, instead of waiting up to the storage-poll interval. When you trigger a workflow whose cluster shard is owned by ANOTHER replica, that replica used to pick the run up only on its next poll tick (up to 10s), because Voltro's single-runner topology has no runner-to-runner push. Now, on a trigger the framework publishes a tiny "wake" onto the SAME Redis/NATS broadcast bus a multi-replica deployment already runs for cross-replica reactivity; every replica subscribes and, on a wake, re-polls cluster storage right away — so the shard owner reads the new run now.
171
+
172
+ - **Dialect-agnostic** — it rides the broker, not the SQL dialect, so it works identically on postgres / mysql / mariadb / mssql (unlike a pg-only LISTEN/NOTIFY). No effect on sqlite (single-process, already immediate). - **Degrades cleanly** — with no broadcast broker (single replica, or the in-process memory transport), there's nothing to wire and the poll interval (`VOLTRO_WORKFLOW_POLL_INTERVAL`) remains the bound. The wake is a latency optimisation, never a correctness dependency: a dropped wake just falls back to the poll. - Built by ONE shared builder wired into BOTH `voltro dev` and `voltro serve` (boot-path parity), fires on the fire-and-forget `start` / `child` triggers, and skips its own wake (the triggering replica already polled locally).
173
+
174
+ `codemod: none` — additive; new opt-in behaviour that activates only when a cross-replica broker is present.
175
+ - **@voltro/runtime, @voltro/workflow, @voltro/cli** — Workflow dead-letter management — a dead-letter VIEW + `discard`. Because the framework applies no retry of its own, a `failed` run is terminal: it is the dead-letter. `voltro workflows list --dead-letter` shows the queue of unhandled failures (`status = 'failed' AND discardedAt IS NULL`); `voltro workflows discard <id>` acknowledges one so it drops off that view. Discard is an ACK, not a re-classification — the run stays `status: 'failed'` (outcome + audit trail survive) and gains a `discardedAt` timestamp (mirrors how `cancelled` coexists with the status); `--status failed` still lists it, marked `discarded`. Discarding a non-failed run is refused; discarding is idempotent. New `discardedAt`/`discardedBy` columns on `_voltro_workflow_runs` (ride the declarative differ — no codemod), a `discard` inspect action + `--dead-letter` list filter, and `discardedAt` on the `WorkflowRunSummary` / `deadLettered` on `WorkflowRunListFilter`. Note: like the other workflow inspect ACTIONS (retry/cancel/…), discard is wired on the `voltro dev` inspect surface. `codemod: none` — additive schema + a new opt-in CLI/inspect surface; no user-authored code is affected.
176
+ - **@voltro/workflow, @voltro/cli** — Workflow failover across replicas is now **tunable and proven**. When a replica running a durable workflow crashes, a surviving replica takes the run over and continues it from the last completed step (completed steps replay from the journal, not re-run) — on any SQL store (postgres / mysql / mariadb / mssql). That already worked; what's new:
177
+
178
+ - **Two operator knobs** for how fast a survivor reclaims a crashed replica's in-flight work — which is a lease-expiry floor (~35s default), NOT a polling one, so lowering it is the lever, and a push mechanism wouldn't help: `VOLTRO_WORKFLOW_FAILOVER_LEASE` (seconds; default 35) and `VOLTRO_WORKFLOW_FAILOVER_HEARTBEAT` (seconds; default 10, keep ≈ lease/3). Lower the lease for faster failover, at the cost of false-positive reclaims if a healthy replica stalls (GC / DB-latency) longer than the lease. Exposed as `failoverLeaseSeconds` / `failoverHeartbeatSeconds` on the workflow engine layer and read from env by `voltro serve`. - **A live multi-PROCESS failover test** (`@voltro/sql-postgres`) that boots two real cluster-runner processes against one postgres, SIGKILLs the one running a 3-step workflow mid-step, and asserts the survivor resumes it from the journal — the completed step ran exactly ONCE across the crash. This exercises the hard-crash (lease-expiry) path a clean shutdown can't, and is the guarantee behind the docs. - Production-hardening docs (en + de) now cover the failover model, the `POD_IP` requirement, the at-least-once step boundary, and the tuning tradeoff.
179
+
180
+ `codemod: none` — additive config; nothing user-authored changes.
181
+ - **@voltro/workflow, @voltro/cli** — `VOLTRO_WORKFLOW_POLL_INTERVAL` (seconds → `messagePollSeconds` on the workflow engine layer) tunes NEW-message pickup latency across replicas. When you trigger a workflow whose shard is owned by the SAME replica, it starts immediately (a same-process push); when ANOTHER replica owns the shard, that replica picks it up on its next storage poll — up to 10s by default (Voltro's single-runner topology has no cross-runner push). Lower this for latency-sensitive multi-replica workloads, at the cost of more idle poll queries; it has no effect on a single replica. This is distinct from the failover knobs (a crash-reclaim lease, not new-message latency). The lower-idle-load alternative is a pg LISTEN/NOTIFY wake, not yet wired. `codemod: none` — additive.
182
+ - **@voltro/workflow, @voltro/runtime, @voltro/cli, @voltro/devtools-ui, @voltro/voltro** — <!-- apiSurface: compatible — reasoned, not rubber-stamped. Three golden lines churn, all WIDENINGS (the direction the gate's rule is not about), and the actual consumers typecheck green against them: 1. `WorkflowRunEventType` gained `'run-redriven'` (in @voltro/workflow AND the @voltro/voltro re-export). It is a framework-EMITTED union — a reader gets a superset; every value that was one of the old members still is one. 2. `workflowEngineLayer`'s return went from `Layer<WorkflowEngine>` to `Layer<WorkflowEngine | Sharding | MessageStorage>` — it now EXPOSES the two cluster services it already built internally (so the re-drive adapter can reach the same instance). It is a framework-internal engine builder wired only by dev.ts / serveCommand (both cast loosely); every value-level use (`provideMerge`, `ManagedRuntime.make`) still compiles. @voltro/runtime + @voltro/cli, its real consumers, were typechecked after the change — green. Nothing was removed or narrowed. -->
183
+
184
+ `redrive` — re-drive a terminally-`failed` workflow run from the step it died on, reusing its durable journal. The operator counterpart to `retry` (fresh execution, empty journal) and to `resume` (which only re-drives a *suspended* run): a plain `failed` run is a terminal `Complete(Failure)` in the cluster store that `resume` will not touch. Fix the downstream cause, then `voltro workflows redrive <runId>` (or `ctx.workflows.redrive(runId)`, or the inspect `redrive` action) and the engine re-delivers the run — every completed step **replays from the journal** (NOT re-executed) while the failed step(s) re-run. Ideal for a long multi-step pipeline where redoing steps 1…N‑1 is expensive or unsafe and you did NOT pre-declare `suspendOnFailure`.
185
+
186
+ Under the hood a single isolated adapter (`@voltro/workflow/cluster` `redriveFailedRun`) reaches into `@effect/cluster`'s `MessageStorage`/`Sharding` to clear the terminal `run` reply plus each failed step's journaled reply, then re-polls storage — the same primitive the engine's own `resume` uses, minus its suspended-only guard. A live cluster **contract test** boots a real engine, fails a multi-step run, re-drives it, and asserts the completed step did NOT re-run, so an engine upgrade that moves those internals fails loudly instead of silently corrupting a re-drive.
187
+
188
+ Works in `voltro dev` AND `voltro serve` — dead-letter recovery matters where incidents happen. Refuses a run that is not a not-yet-discarded failure (use `retry` for a fresh run, `resume` for a suspended one), and declines cleanly (`redriven: false` + a `reason`) when there is no durable journal (e.g. the memory store). Records a `run-redriven` lifecycle event. `codemod: none` — a new opt-in action + SDK method; no user-authored code is affected.
189
+ - **@voltro/workflow** — `suspendOnFailure` — resume a workflow from where it failed, reusing completed steps. Declare `suspendOnFailure: true` on a workflow and a failure of its top-level body no longer becomes a terminal `failed` run — it **suspends** with the durable journal intact, so `voltro workflows resume <id>` (or `ctx.workflows.resume`) re-drives it from the point of failure: every completed activity replays from the journal (NOT re-executed) and only the failed activity runs again. This is the durable-execution way to make a long multi-step workflow recoverable across a transient downstream outage without re-doing prior work — the opposite of `retry`, which starts a fresh execution with an empty journal. Maps to `@effect/workflow`'s `SuspendOnFailure` annotation. A suspended-on-failure run records `status='suspended'` WITH the failure reason (`errorTag`/`errorMessage` + a `suspend-on-failure` event, and it reaches the error reporter), so it is distinguishable from a plain sleep/signal suspension; it shows under `--status suspended`, NOT in the dead-letter view (it is recoverable, not dead). Default `false` — a failure stays terminal. `codemod: none` — a new opt-in workflow option; no user-authored code is affected.
190
+
191
+ ### Fixed
192
+
193
+ - **@voltro/runtime, @voltro/cli, @voltro/plugin-webhooks** — Three gaps named in the previous change set, closed.
194
+
195
+ **`onSoftDelete` could not fire.** A soft delete is not a `delete` event — it is an UPDATE that sets `deletedAt` — and the rule matcher only looked at `op === 'delete'`, so the option existed and the event it needed never arrived. The matcher detects the null → non-null TRANSITION on `deletedAt` (the value alone would re-fire on every later write to a tombstoned row) and the boot wiring forwards updates as well as deletes.
196
+
197
+ The tests were green throughout, because they passed `softDeleted: true` alongside `op: 'delete'` — a shape the change channel never produces. They proved the flag worked against something that does not exist.
198
+
199
+ **`assertNoTagCollisions` ran only in `voltro dev`.** A plugin/app tag clash aborted boot in development and was checked nowhere in production, so a collision dev refuses could ship and whether it shadowed a route or crashed depended on what codegen happened to emit. It runs in `serve` now, honouring `overridesPlugin` identically from the same descriptors.
200
+
201
+ **`listDeliveries` filtered after the read.** `status` and `since` cannot go into the predicate, so taking exactly `limit` and then filtering silently returned too few — ask for 200 deliveries since Monday and you get however many of the newest 200 rows fall in that window, with no signal the answer was truncated from the wrong end. It over-fetches when a post-read filter is in play, then applies the limit.
202
+
203
+ That last test also passed against the old code at first: the fake store ignored `take` entirely, so nothing about paging was being tested. Modelling `take` made it red-verifiable, and it is — 1 row instead of 5 without the fix.
204
+
205
+ codemod: none
206
+ - **@voltro/cli, @voltro/runtime** — Two defects reported from a MariaDB deployment.
207
+
208
+ **`gc-snapshots` and `restore-snapshot` were postgres-only, silently.** `table_schema = 'public'` was hardcoded at four sites. On MySQL/MariaDB the schema IS the database name, so every one matched nothing — and "matched nothing" prints the same line as "there is nothing": the reporter had a real `presence__dropped_20260803032340` while the tool said "dropped 0" and exited 0.
209
+
210
+ It compounds because `VOLTRO_SOFT_DROP=1` is the right default for an unattended migrate job, so every drop becomes a snapshot and they accumulate forever when the reclaim tool cannot see them — the safety net becomes litter.
211
+
212
+ Two sites were in `gc-snapshots`, which is what was reported. The other two are in **`restore-snapshot`**, which nobody had reached yet: that is the command you run AFTER something went wrong, and it would have answered "no snapshot found" for one that exists. Beneath the predicate sat a second postgres assumption the first one hid — `"double-quoted"` identifiers, which MySQL/MariaDB reject, so even a matching query could not have executed. Both are dialect-resolved now (`quoteIdent` was already imported and unused).
213
+
214
+ **A stale `source:` is now reported at boot.** `source` is matched by NAME against change events, so one naming a table that no longer exists leaves the query not broken but permanently QUIET — it serves its first snapshot and never updates, which is indistinguishable from "nothing has changed". The reporter hit it on the 0.24.0 agent rename: two queries kept the old string and the app booted clean with zero warnings. It is a string, so `tsc` cannot see it, and the codemod's promise that a missed rename "fails loudly with relation does not exist" is true of a SQL reference and false of this.
215
+
216
+ Resolved against the declared table set — which the boot already holds, so it is free — with a did-you-mean for the prefix-rename case that produced it. It WARNS rather than refusing: a table can legitimately live outside the declared schema, and a boot failure for those would be the worse trade. Computed in `loadDiscovered`, so dev / serve / doctor / check all see it, with a parity test that fails if it is wired into only one boot path.
217
+
218
+ Both red-verified against their own reverted fix.
219
+
220
+ codemod: none
221
+ - **@voltro/runtime** — The events docs promised an authorization guarantee the code does not provide.
222
+
223
+ "Guards are re-checked when the subject changes, not per delivery. Revoke a role and the stream ends." There is exactly ONE `checkGuardsEffect` call on the event path — in `bindEvent`, at subscribe — and no subject-change hook, no revocation path that touches a live subscription. A subject whose role is revoked keeps receiving, and this primitive reconnects forever by design, so "until the stream ends" can be a very long time.
224
+
225
+ The documentation now says what happens: checked once, at subscribe, never again; if a permission change must take effect immediately, do not model the authorization boundary with an event subscription. en + de, agent-docs regenerated.
226
+
227
+ Correcting the sentence rather than implementing the re-check is deliberate, and the reasoning is the same one that made this worth finding: a security guarantee that is stated and not kept is worse than one that is absent, because readers build on the sentence. Re-checking on subject change is a real feature with real design questions (what ends the stream, how a subject change is even observed on a long-lived socket) and it should not be improvised inside a doc fix.
228
+
229
+ Same shape as the three defects already fixed in this pass — `webhook.retry`, `presencePlugin({ sweepIntervalMs })`, and the guards themselves, which were accepted, serialised into the manifest, reported by doctor and counted in the devtools panel while nothing enforced them. That one was about whether the check runs at all; this one is about how long its answer stays true.
230
+
231
+ codemod: none
232
+ - **@voltro/runtime, @voltro/protocol, @voltro/cli** — **`ctx.events` is typed as what it actually is.** It was declared as the old string-emitter facade (`emit(name, data)`) long after that facade stopped being installed there, so the documented and taught call — `ctx.events.publish(descriptor, key, payload)` — was a `tsc` error while the runtime carried only `publish`. A consumer could not tell which of the two was lying and measured it with a cron probe:
233
+
234
+ EVENT_PROBE {"eventKeys":["publish"],"publishType":"function","emitType":"undefined"}
235
+
236
+ Exactly the inverse of the declared type. Their workaround was a cast in the one primitive whose entire justification is typing.
237
+
238
+ What let it drift is the part worth recording: the builder installed the publisher with `as never`, so the compiler had the answer the whole time and was told not to give it — beside a comment in the same file stating that `emit` is gone. A context field is the one place this repo already treats such a cast as a defect in its own right; it is removed, so `tsc` is the guard now.
239
+
240
+ **`overridesPlugin: true` on a query / mutation / action.** Correcting the premise first, because it matters for anyone reading the same report: sharing a NAMESPACE with a plugin already composes. `assertNoTagCollisions` compares FULL tags, so `notifications.list` beside the plugin's `notifications.inbox` has always been fine. Only an identical name collides, and that stays an error — two handlers behind one tag is not something a caller can reason about.
241
+
242
+ What was missing is the deliberate replacement. The two escapes available before were to rename your procedure or to `alias` the whole plugin away, and both move the split from a domain boundary to "who built it" — for a frontend developer, the worst possible partition. The flag drops the plugin's route rather than merely permitting the pair (permitting it would leave two handlers bound, the state the check exists to prevent) and logs which routes it replaced.
243
+
244
+ Explicit, never inferred: silently letting the app win would mean a plugin upgrade that adds a route could shadow an app procedure with no diff to read.
245
+
246
+ Three smaller ones from the same report: the `defineSchedule` timezone error now says that an ABSENT field is a `tsc` error and reaching the message means an EMPTY one (usually `process.env.TZ ?? ''`); the empty-relations warning names the `_relations.register.ts` entry that must go with the file; and `db apply` no longer says "nothing to apply" one line above "installing change triggers on 500 table(s)" — it says "no DDL to apply", which is what it meant.
247
+
248
+ codemod: none
249
+ - **@voltro/plugin-presence** — `presencePlugin({ sweepIntervalMs })` is now read. It was declared, documented as "Sweep interval for stale rows. Default 60s.", and the sweep ran on `timeoutMs / 3` regardless — so setting it did nothing, and the stated default was wrong as well: with the 30s window the real interval was 10s, not 60s. The one number a reader could have checked the option against disagreed too.
250
+
251
+ It defaults to a third of `timeoutMs` (a vanished member is gone within roughly 1.3x the online window, which is the right relationship for almost every room) and an explicit value now wins.
252
+
253
+ The interval was also derived at TWO sites — the one the sweep ran on and the one reported to the inspect surface — computed identically and independently. That is how a reported value and a real one drift apart with neither site looking wrong; it is derived once now.
254
+
255
+ Found by walking the plugin option surface and asking, per field, who reads it — the same pass that found `webhook.retry`. Fourth instance of this class in this feature. `declaredOptionsEnforced.test.ts` pins it, red-verified against the reverted fix.
256
+
257
+ codemod: none
258
+ - **@voltro/client** — `useEvent` crashed instead of waiting when its api had not resolved yet. Every other hook survives that window because it reads through `LoadingSubscriptionCache`, whose `subscribe` is a non-fetching no-op; `useEvent` forks its own fiber on the api handle's runtime, and the loading baseline's runtime is a stub with `runPromise` and nothing else — so a mount without a `<VoltroRuntimeProvider>` above it, or during the boot window before the client resolves, died with `runtimeRef.current.runFork is not a function`.
259
+
260
+ It now stays `idle` until the api resolves, then subscribes. The gate is worth more than the crash it removes: this subscription retries a dropped connection forever on purpose, and the loading baseline's client is a proxy that throws on every call — so a fork that had "worked" would have spun rather than failed.
261
+
262
+ The gate asks whether the runtime can fork rather than comparing the handle against the stub by identity, because a host that loads a bundled copy of `@voltro/client` alongside the resolved one — `@voltro/web`'s dist does — has its own stub object, and identity would answer "resolved" for a stub. Capability is true of every real runtime and false of every stub, in any number of copies.
263
+
264
+ codemod: none
265
+ - **@voltro/database, @voltro/runtime, @voltro/cli, @voltro/plugin-versioning, @voltro/plugin-audit** — Two features shipped one commit earlier were declared and inert. Both are now wired, and both are the exact defect class the change set they arrived in was about — declared, and nothing reads it.
266
+
267
+ **`pluginRef` was a library, not a feature.** `applyPluginRefRules` and `pluginRefSpecOf` had no caller anywhere. An app could declare `orphanPolicy: 'delete'` and the rule would never run: the column worked, the engine was correct, and nothing connected them. It is collected at boot from the registered tables and applied on the post-commit change channel, out of band so it can never back-pressure the change stream.
268
+
269
+ `collectPluginRefRules` also implements the edge case that was only a comment before: a `pluginRef` naming a table no installed plugin registers **refuses at boot**, naming both sides. A declared rule against an absent plugin would sit there looking enforced.
270
+
271
+ **`_voltro_row_history.actor` was always null.** The column existed and the row builder read `event.actor` — which nothing ever set. The versioning plugin now resolves the snapshot from the `actors` row it already has store access to.
272
+
273
+ `resolveActorSnapshot` moved to `@voltro/database` for that: two plugins need it, it is the only package both depend on, and `actors` is a core table declared there. Putting it in the runtime was the first attempt and wrong — plugin-audit deliberately does not depend on the runtime.
274
+
275
+ Both are guarded by WIRING tests, not only unit tests of the engines: in both cases the engine was correct and entirely inert, which no unit test could see. Red-verified by removing the wiring.
276
+
277
+ codemod: none
278
+ - **@voltro/workflow** — **Multi-replica workflow runners now get a DISTINCT cluster identity** — a real sharding + failover correctness fix. `workflowEngineLayer` set only the cluster runner's *listen* address (from `POD_IP`), never its *advertised* address, and the advertised address IS the identity `@effect/cluster` keys `cluster_runners` and every owned shard on. So every replica fell back to the library default (`localhost:34431`) and they all registered as the SAME runner: one identity owning all 300 shards, no distribution, and failover that "worked" only because the colliding processes happened to poll the same rows.
279
+
280
+ Now both the advertised (`runnerAddress`) and listen addresses are set from the `POD_IP`-derived identity, so two pods with distinct `POD_IP`s are two distinct runners — shards distribute across them (verified: 3 runners → 100 shards each, was 1 → 300) and a crashed replica's shards are genuinely handed off to a survivor. Surfaced by a new multi-process chaos test that needs three real, distinct runners to hand a run off twice.
281
+
282
+ Requires `POD_IP` (or `VOLTRO_WORKFLOW_RUNNER_HOST`) injected per pod — the same requirement the boot already warns about; it now actually determines identity, not just the (inert, under SingleRunner) listen address. `codemod: none` — no user-authored code changes; `cluster_runners` is ephemeral and re-registers on boot, so stale old-identity rows age out on their own.
283
+
284
+ ---
285
+
286
+ ## [0.25.0] — 2026-08-04
287
+
288
+ ### ⚠ BREAKING
289
+
290
+ - **@voltro/plugin-broadcast, @voltro/plugin-presence, @voltro/cli** — **Two Voltro apps pointed at one Redis or NATS were publishing into each other's channels. The option documented as the fix for that was never read.**
291
+
292
+ Every framework channel was a flat constant with no per-app component — `voltro:changes`, `voltro:events`, `voltro:members`, `voltro:presence` — and the providers pass channel names to the broker verbatim. So a shared broker made one app's change events wake another app's matchers, one app's presence deltas land in another app's roster (adding members that can never leave: there is no owner for membership to time out), and, since events were unified, one app's events arrive at another app's clients.
293
+
294
+ `BroadcastPluginOptions.channel` existed for this. Its own doc comment named it as the answer for several deployments sharing one broker. It was declared, it was documented, and **nothing ever forwarded it out of the options object** — proven by test before it was replaced. Setting it did nothing, silently, while looking like a solution.
295
+
296
+ It is now **one namespace for all four channels**:
297
+
298
+ ```ts
299
+ broadcast({ provider: 'redis', namespace: 'shop-prod' })
300
+ ```
301
+
302
+ A per-channel override would have been the wrong shape even working: escaping cross-talk means changing four names, three of which had no option at all, and fixing one of four is a half-fix that reads as a whole one.
303
+
304
+ **The default derives from your app's name**, so two different apps separate without anyone configuring anything. That ordering is deliberate — a namespace you must remember to set is one two apps forget to set, and the failure is silent in the worst direction.
305
+
306
+ **The one case derivation cannot see**, stated plainly rather than papered over: staging and production of the SAME app share a name, the code and every fingerprint. Nothing derivable tells them apart. If one broker serves several deployments of one app, `namespace` or `VOLTRO_BROADCAST_NAMESPACE` is not optional — it is the only thing that can work.
307
+
308
+ Resolution: `broadcast({ namespace })` → `VOLTRO_BROADCAST_NAMESPACE` → app name. Values are lowercased and reduced to `a-z0-9_-`. The reason, measured against nats:2 rather than assumed — the first version of this note had it wrong:
309
+
310
+ | In a name | What NATS does | | --- | --- | | a `.` beside a `>` (`shop.>`) | matches `shop.other` — wildcards are token-level, tokens are dot-separated | | a name that IS `>` or `*` | matches EVERY subject on the server | | whitespace | rejects the subject outright — the app receives nothing at all |
311
+
312
+ A wildcard inside a token is inert (`shop>:changes` does not match `other:changes`), so the dangerous inputs are narrower — and different in kind: the whitespace case is not a leak but a silent hard failure. A name reducing to nothing falls through to the next candidate rather than becoming an empty prefix. Redis is indifferent to all three; the sanitiser is the strict intersection.
313
+
314
+ The codemod rewrites `channel` → `namespace` and strips a trailing `:changes` (the framework appends the channel kind itself, so carrying the old value verbatim would produce `myapp:prod:changes:changes` — a channel nobody publishes to, and silent). A non-literal value is carried verbatim and flagged for review rather than guessed at. It also tells you the old option never took effect, which is the part a rename would otherwise hide.
315
+
316
+ Namespaces are resolved ONCE per boot and threaded to all four wirings; `dev`, `serve` and the plugin bind context call the same helper, because four independent derivations of one value is four chances to produce a replica that publishes where nobody listens.
317
+ - **@voltro/runtime, @voltro/cli** — **Each declared event now travels on its own cross-instance channel (`voltro:events:<name>`), and a replica subscribes only while it has a local subscriber for that event.**
318
+
319
+ No user-authored code is affected — hence `codemod: none`. The channel name is internal to the transport; `defineEvent`, `ctx.events.publish` and `useEvent` are unchanged.
320
+
321
+ Previously every event shared one channel, so every replica received, JSON-decoded and materialised a route for every event of every peer — including the ones it served no clients for. With five replicas and one high-rate event whose subscribers all sat on one of them, four replicas did that work and threw the result away.
322
+
323
+ **The operational consequence to plan for:** during a rolling deploy, replicas on different framework versions use different channel names, so cross-replica delivery is degraded for the length of the rollout. Local delivery on each replica is unaffected throughout, and the two sets converge when the rollout completes.
324
+
325
+ Interest is tracked per EVENT (not per route) and the transport re-reads the desired state when its async `subscribe` resolves — a subscriber that arrives and leaves inside that window would otherwise leave a live subscription behind, a leak that grows with reconnect churn and never reports itself. Registering the interest listener replays what is already subscribed, so a client that attached between the bus being built and the transport being wired is not left unwired.
326
+ - **@voltro/runtime, @voltro/cli** — **`ctx.events.emit('name', data)` is gone. `ctx.events.publish(descriptor, key, payload)` is the only emitter, and it drives BOTH audiences.**
327
+
328
+ The string emitter and the declared event were two ways to say the same thing, and only one of them can be checked. `emit` matched a workflow trigger BY NAME: rename the event on one side and the trigger silently stops matching, the workflow never runs again, and nothing errors. That is the exact defect a consumer reported having with their own string channels — two spellings of one event, both subscribed, one dead since the day it was written — so shipping the typed event while keeping the untyped emitter would have shipped the fix and the defect together.
329
+
330
+ ```ts
331
+ // before
332
+ await ctx.events.emit('orders.paid', { orderId, total })
333
+ triggerWorkflow({ event: 'orders.paid', workflow: 'fulfil' })
334
+
335
+ // after
336
+ yield* ctx.events.publish(orderPaid, { orderId }, { total })
337
+ triggerWorkflow({ on: orderPaid, workflow: 'fulfil' })
338
+ ```
339
+
340
+ **Nothing was lost with it.** `publish` still writes `_voltro_workflow_events`, still starts every matching trigger, and still records a delivery row per trigger — it does that from ONE call, on the SAME commit boundary as the client fan-out. Two emitters could disagree about whether the thing happened; one cannot. A trigger failure still cannot fail the mutation that published, for the same reason a broker outage cannot.
341
+
342
+ The codemod is `manual`, and the reason is the actual guidance: the rewrite needs a routing `key` and nothing can derive one. The key decides WHO receives the event, so a guessed `{}` compiles and fans every event out to every listener, while a guessed field fans it out to none. Both fail silently, which is what this change is about. The printed steps say how to choose one.
343
+
344
+ **Also: a subscriber can now PUBLISH a declared event** (`ctx.publish` in `*.subscribe.ts`, present only when the app declares any). A row changing and a thing happening are different statements, and usually only the second is what a client cares about — nobody watches `attendance` rows, they watch "attendance changed". Without the bridge, a table-derived event has to be published from every mutation that touches the table, and from the next one somebody adds: fail-open by omission, which is the shape a declaration exists to remove. Best-effort by nature — it fires after the commit, so there is no transaction left to couple to. When the event must not be lost, publish it from the mutation.
345
+ - **@voltro/plugin-webhooks, @voltro/cli** — **`defineOutgoingEvent` is gone. An outbound webhook event is an AUDIENCE of a declared event.**
346
+
347
+ ```ts
348
+ // before — events/order.completed.webhook.tsx
349
+ export default defineOutgoingEvent({ id: 'order.completed', payload: P, version: 1 })
350
+
351
+ // after — events/orders.event.ts
352
+ export const orderCompleted = defineEvent({
353
+ name: 'order.completed',
354
+ key: Schema.Struct({}),
355
+ payload: P,
356
+ webhook: { version: 1 },
357
+ })
358
+ ```
359
+
360
+ This completes the unification. One declaration, and `ctx.events.publish` reaches connected clients, workflow triggers AND subscribed HTTP targets from the same call, on the same commit boundary. Two declarations of one thing drift — the defect the event primitive exists to remove — and keeping both forms would have shipped the fix beside it.
361
+
362
+ **The codemod is a `transform`, and the contrast with its sibling is the useful part.** The string-emitter codemod had to be `manual` because the rewrite needs a routing key and nothing can derive one: only the author knows who should receive an event. This one needs no key. A webhook event is delivered to subscribed TARGETS, not to a key, so `key: Schema.Struct({})` is the correct answer rather than a guess — and everything else maps 1:1.
363
+
364
+ `defaultRetry` and `defaultSigning` are deliberately NOT carried across. The plugin's shapes are richer than a browser-safe descriptor can hold; dropping them silently would remove a policy the author wrote, and inventing the missing fields would install one they did not. The transform leaves them as a compile error and says so — configure them at subscribe time, where the full shape is typed. `globalRateLimit` becomes `rateLimit`: "global" only ever meant "not per-target", and beside three audiences that word would read as "across all of them".
365
+
366
+ **Nothing downstream changed shape.** `OutgoingEventDescriptor` survives as the internal form the delivery workflow, the JSON-Schema export and the dashboard's event list all read; a declared event is PROJECTED onto it. Giving declared events a parallel path would mean each of those consumers handles two shapes, which is how two shapes drift apart.
367
+
368
+ Webhook DISCOVERY now merges declared events into the same `outgoing` bucket it always produced, in both boot paths — so the six consumers of that bucket are untouched.
369
+
370
+ ### Added
371
+
372
+ - **@voltro/plugin-broadcast, @voltro/runtime, @voltro/cli** — **A dropped broadcast message used to leave a client stale forever. It is now detected and repaired.**
373
+
374
+ This was the one correctness gap the change bus had that the event bus did not, and the asymmetry is what gave it away: an event computes exactly what a subscriber missed and tells it, while a ChangeEvent was fire-and-forget with no serial and no accounting.
375
+
376
+ The failure is quiet and permanent. Replica B's broker connection blips and misses a change replica A published. B's clients keep their sockets — so the client-side reconnect never fires — and their live queries never re-run. They show stale rows until something else happens to touch the same table, which on a quiet table can be never. Nothing errors, nothing logs, and the only symptom is a user saying the page "didn't update".
377
+
378
+ **Detection.** Every change now carries a per-origin serial. A receiving replica tracks the highest it has seen per peer, and a jump is an EXACT count of what vanished — not an estimate. A first message from an origin reports nothing however high its serial: a replica that just started missed nothing, and reading that as a gap would make every new pod refresh everything on its first remote change.
379
+
380
+ **Recovery.** There is nothing to replay — pub/sub keeps no log — and that does not matter, because **a live query is idempotent**. `Dispatcher.refreshAll()` re-runs every live subscription through its own descriptor, so every guard, row filter and tenant predicate applies unchanged. A refresh is a re-query, not a push: if the snapshot has not moved the subscriber sees nothing, so one dropped message does not repaint the fleet.
381
+
382
+ Deliberately blunt — it refreshes everything rather than reasoning about which tables the lost changes touched. We do not know, and guessing narrower would reintroduce exactly the silent staleness this repairs.
383
+
384
+ Detection and recovery are separate: a bus used without a dispatcher still DETECTS and logs the loss. Both boot paths wire the recovery, in two steps — the bus must subscribe before anything can be missed, and the dispatcher does not exist yet.
385
+
386
+ Nothing to configure. It follows from having a broker.
387
+ - **@voltro/protocol, @voltro/runtime, @voltro/client, @voltro/cli, @voltro/testing, @voltro/voltro** — **`defineEvent` — the axis the framework did not have.** Voltro modelled "what IS" (a table, watched by a reactive query) extremely well and had exactly ONE server→client fan-out path: a query re-runs because a table changed. Anything that is not row state — a game starting, a door opening, a payment terminal confirming — had to invent a table, and two independent consumers built the same three bugs on top of a reactive list: a `seen` set, an `initialized` flag so page load does not replay the history into a live system, and a `limit` that silently truncates. Our own `plugin-presence` does it too.
388
+
389
+ ```ts
390
+ // events/gameLifecycle.event.ts — browser-safe, may hold several
391
+ export const gameStarted = defineEvent({
392
+ name: 'games.started',
393
+ key: Schema.Struct({ arenaId: Schema.String }),
394
+ payload: Schema.Struct({ gameId: Schema.String, startedAt: Schema.Number }),
395
+ guards: [{ scope: 'display:read' }],
396
+ })
397
+
398
+ // any handler with a ctx — action, mutation, workflow, cron, subscriber
399
+ yield* ctx.events.publish(gameStarted, { arenaId }, { gameId, startedAt })
400
+
401
+ // the client
402
+ const { missed } = useEvent(gameStarted, arenaId ? { arenaId } : null, (payload) => {
403
+ scene.switchTo('running', payload.gameId) // payload is typed from the descriptor
404
+ }, { onMissed: ({ count }) => resync(count) })
405
+ ```
406
+
407
+ **`missed` is computed, never estimated.** Every delivery carries `(origin, n)` and the server keeps the highest serial per origin, so a loss is arithmetic — what you were owed, minus what could be replayed. A dropping buffer discards silently BY DEFINITION, and silence is the one outcome nothing can be built on: a display cannot tell "no game started" from "I missed the start signal".
408
+
409
+ **A first attach and a reconnect are different events.** "Never replay history" and "never lose a message" read as one contradiction and are two questions: a fresh subscription starts empty (opt in with `rewind`), a reconnect resumes from the last serial that subscription saw. `useEvent` does the second for you, including after a deploy or a proxy timeout.
410
+
411
+ **Publishing is server-only.** A client-originated event is an action that publishes, which deletes the entire "who may write to this channel" authorization surface. **Inside a mutation, publish fires on COMMIT and not at all on rollback** — riding the buffer the transactional view already uses for ChangeEvents, so it needs no SQL trigger. Serials are assigned AT commit, so a rollback burns no number and leaves no permanent hole.
412
+
413
+ **Cross-instance delivery is wired**, not just seamed: events ride `@voltro/plugin-broadcast` (Redis / NATS / memory) on their OWN channel — `voltro:events`, not `voltro:changes`, because sharing one would make every replica decode every message of the other kind to discover it does not want it. Additive like the change bus: local fan-out happens first and a broker outage degrades cross-replica delivery without touching the publishing pod's subscribers. A malformed message on that shared channel is dropped with a log line rather than injected — a bad serial would corrupt a route's watermark and make every later `missed` on it wrong, permanently.
414
+
415
+ **`triggerWorkflow({ on: descriptor })`** ships with it, additively. A workflow trigger reads the event's NAME off the descriptor, so a rename moves the trigger with it — where the string form (`event: 'games.started'`, still accepted) leaves the trigger matching nothing and the workflow simply never runs again, with nothing to notice. Shipping it now means a third string namespace never comes into existence even briefly; removing the string form is a separate breaking change with its own codemod, and nothing here has to be undone for it.
416
+
417
+ `apiSurface: compatible`, and the distinction is worth stating because the API report reads it as a REMOVAL: `triggerWorkflow` / `defineEventTrigger` show as changed lines rather than added ones in `@voltro/voltro`'s goldens, since their parameter went from `T` to `T | (descriptor form)`. That is a WIDENING — the direction the gate's rule is not about. Every call that compiled against the old signature still compiles, and a function accepting the wider union is still assignable wherever the narrower one was expected. The umbrella package is listed here for exactly that reason: it re-exports both symbols, so its goldens churn even though nothing it re-exports narrowed.
418
+
419
+ Also: `key` is the routing address and the tenant is derived from the subject (never caller-supplied); payloads are capped at 7,500 bytes on **every** dialect so switching broadcast transport is never a behaviour change; a duplicate event name fails the boot because an event name IS an rpc tag; `voltro doctor` reports declared events with no producer or no consumer — the class a consumer found by hand in their own inventory (four dead channels in twenty); and `testEventBus()` ships in `@voltro/testing` WITH the primitive, driving the real bus so a suite cannot pass on payloads production rejects.
420
+
421
+ Four defects were found and fixed while building it, each pinned by a test: `Queue.unsafeOffer` does not slide on a sliding queue (it keeps the oldest and rejects the new — the wrong end for an event); a delivery dropped before a client's first read was invisible until the gap detector seeded from the attach watermark; a clean stream close was not a reconnect reason, leaving a display at `status: 'live'` receiving nothing after a deploy; and `Rpc.make` with `stream: true` puts the declared error inside the stream schema, not on `errorSchema`, so the guarded-QUERY half of the `ScopeError` union rule had never been asserted.
422
+ - **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/devtools-ui** — **`defineEvent({ delivery: 'latest' })` — for streams where only the current value matters.**
423
+
424
+ The default (`'each'`) is unchanged: every delivery counts, a subscriber that falls behind keeps the newest and is told exactly how many it lost. That is the right reading for a lifecycle event, and it is what you get by not deciding.
425
+
426
+ `'latest'` says the opposite, and it is a **semantic** rather than a performance knob: a newer delivery SUPERSEDES a pending one, the server retains one value instead of a ring, a reconnect hands over the current value, and no gap is reported — because nothing was lost. For a 60Hz stream of positions, frame 1 stopped being interesting the moment frame 2 existed, and reporting it as "missed" trains a consumer to read normal operation as degradation.
427
+
428
+ ```ts
429
+ export default defineEvent({
430
+ name: 'player.moved',
431
+ key: Schema.Struct({ arenaId: Schema.String }),
432
+ payload: Schema.Struct({ playerId: Schema.String, x: Schema.Number, y: Schema.Number }),
433
+ access: 'authenticated',
434
+ delivery: 'latest',
435
+ })
436
+ ```
437
+
438
+ The test for which one you want: **would a consumer be wrong to miss one?**
439
+
440
+ **`delivery: 'latest'` combined with `webhook` is REFUSED at declaration.** The two contradict each other — `latest` says a superseded delivery did not matter, while a webhook delivery is a durable side effect at a third party that cannot be superseded once sent. The combination also multiplies badly: a 60Hz event with an HTTP audience is 60 deliveries per second per subscribed target, and the webhook rate limit DEFERS the excess as pending rows rather than dropping it, so the symptom is a growing table rather than an error anyone would look at. Split them: the high-rate event for clients, a coarser one for the outside world.
441
+
442
+ The declared semantic is read from ONE map, by the bus (for retention) and by the bridge (for queue depth), so the two cannot come to disagree about whether a drop counts as a loss. The devtools events panel badges a `latest-wins` event, because two events with identical numbers otherwise mean opposite things about a missing message.
443
+ - **@voltro/protocol, @voltro/plugin-webhooks, @voltro/cli** — **A declared event can now reach subscribed HTTP targets too — one declaration, three audiences.**
444
+
445
+ ```ts
446
+ export const orderPaid = defineEvent({
447
+ name: 'orders.paid',
448
+ key: Schema.Struct({ orderId: Schema.String }),
449
+ payload: Schema.Struct({ total: Schema.Number }),
450
+ webhook: { description: 'An order was paid', version: 2 },
451
+ })
452
+
453
+ yield* ctx.events.publish(orderPaid, { orderId }, { total })
454
+ // → connected clients (useEvent) + workflow triggers + subscribed HTTP targets
455
+ ```
456
+
457
+ Without it, an app that both fans an event out to its screens and posts it to a partner declares the thing twice, in two shapes — and the two drift. That is the defect a declaration exists to remove, one level up from the string channel it already removed.
458
+
459
+ Three implementation decisions worth knowing:
460
+
461
+ - **`webhook:` is namespaced**, not spread across the descriptor. These settings are meaningless to the other audiences, and a top-level `retry` would read as if it applied to client delivery — which is at-most-once by design and has no retry at all. - **The block is structurally typed in `@voltro/protocol`** (plain numbers and strings), and the plugin maps it onto its own shapes. Protocol is browser-safe and must not reach a plugin; that dependency direction decides where the adapter lives, not preference. - **A declared event is PROJECTED onto the descriptor the plugin already reads** rather than given a parallel path. The delivery workflow, the JSON-Schema export and the dashboard's event list all keep reading one shape — a second path would mean each of them handles two, which is how two shapes drift.
462
+
463
+ `retry` is deliberately not forwarded blind: the plugin's `RetryPolicy` is richer than the two numbers protocol carries, and inventing the missing fields would put a policy in place nobody wrote. Configure it at subscribe time, where the full shape is typed.
464
+
465
+ `defineOutgoingEvent` still works and is unchanged. Removing it is cleanup with its own `transform` codemod, not part of this.
466
+ - **@voltro/runtime, @voltro/cli** — **Instance membership — which replicas are alive, and when one stops being.**
467
+
468
+ Cross-instance FAN-OUT was already solved: a publish goes onto a channel and whoever listens receives it, and nobody needs to know who the other instances are. That is what makes pub/sub cheap.
469
+
470
+ **Membership is the question a channel cannot answer**, because a channel says nothing about who is on it. An instance that dies simply goes quiet, and quiet is indistinguishable from "nothing happened" — a crashing process does not get to send a goodbye.
471
+
472
+ That gap is invisible until state is OWNED per instance. Presence is the motivating case: replica 2 holds the WebSockets of the clients connected to it, so when replica 2 dies its members must disappear from replicas 1 and 3, and nothing on the event channel will ever say so.
473
+
474
+ `InstanceMembership` announces this process on its own broadcast channel (`voltro:members` — separate from events for the same reason events are separate from changes) and reports `joined` / `left` / `restarted` to any consumer. Wired into BOTH boot paths through one helper; visible at `GET /_voltro/inspect/members`.
475
+
476
+ **Liveness is measured on the RECEIVER's clock.** `lastHeardAt` is when *we* received a heartbeat, never a timestamp the sender put in it — trusting the sender reintroduces exactly the problem `.version()` exists to avoid: an instance whose clock runs slow would look permanently overdue, one whose clock runs fast would look alive forever, and neither would report anything wrong.
477
+
478
+ Three decisions that are easy to get backwards, each pinned by a test:
479
+
480
+ - **Three missed beats, not one.** A single missed beat is a GC pause or a broker hiccup, and evicting on it makes a healthy cluster flap — every flap dropping and re-adding that instance's owned state, which a presence roster shows as everyone briefly leaving and coming back. - **A returning instance with a NEW `startedAt` is a RESTART, not a heartbeat.** Whatever state a consumer held for the old process is gone with it; resuming would show a roster of clients connected to nothing. - **A stale self-echo is ignored.** Brokers replay, and a replayed message from a previous incarnation carries an older `startedAt` — without the id guard that reads as "this instance restarted", and every consumer drops the state it is holding for *itself*.
481
+
482
+ **It is a presumption, not a fact**, and the docs say so: a network-partitioned instance is alive and still serving its own clients; it just cannot be heard. Each side of a partition marks the other down and drops its state. That is the correct degradation — you show what you can actually reach — and it is why `/_voltro/inspect/members` reports what THIS replica observes rather than a merged "cluster view". Presenting one would invent a consensus nobody has; the disagreement is the diagnostic.
483
+
484
+ Single-instance deployments get a registry whose only member is themselves, which is the true answer and means no consumer needs a "do we have a cluster" branch — that branch is how a feature comes to work in dev and not in production.
485
+ - **@voltro/cli** — Restore drill — `voltro data restore <dir> --drill [--drill-url <url>]`. "A backup you have never restored is a hypothesis"; the drill turns it into a fact by restoring the artifact into a THROWAWAY database (from `--drill-url` / `DRILL_DB_URL`) and verifying it, WITHOUT ever touching the live DB. It refuses a drill target that resolves to the live connection (a drill that `--clean`s production is the disaster it exists to rehearse against). After the restore it introspects the throwaway DB and compares its schema fingerprint to the backup's stamp: zero tables → FAIL (empty / unreadable dump), fingerprint disagrees with the stamp → FAIL (the restore didn't reproduce what was backed up), tables + matching fingerprint → PASS. Exits non-zero on any FAIL, so a scheduled CI job turns a silently-broken backup into a red build. The verify is schema-level (introspect + fingerprint); a full app boot against the restored DB is a heavier follow-up. Pure decision logic (`resolveDrillTarget` / `assessDrillResult` / `connKey`) covered by 14 unit tests; the native round-trip is integration-tested where a matching `pg_dump` is available. `codemod: none` — a new opt-in flag; no user-authored code is affected.
486
+ - **@voltro/database, @voltro/cli** — Opt-in rolling-deploy refuse gate — `VOLTRO_ROLLING_DEPLOY=1`. The rolling-deploy safety classifier shipped as a `voltro db plan` advisory (a `⚠`, never a block), because the framework can't know the deploy strategy and a maintenance-window / scale-to-zero deploy has no overlap window. Operators who ALWAYS rolling-deploy can now opt into a hard gate: with `VOLTRO_ROLLING_DEPLOY=1` set, `voltro db apply` (both the auto-diff and the reviewed `--plan` path) REFUSES (exit 2) a plan containing a rolling-unsafe operation — a dropped/renamed column, a narrowed type, an added constraint — instead of warning, so an un-split breaking change fails the deploy rather than breaking pods at runtime. Override a specific apply with `--force`. Unset (the default) leaves the advisory behaviour untouched. The decision is a pure `assessRollingDeployGate` in `@voltro/database` (testable without a CLI, reusable by the cloud migration wall). `codemod: none` — a new opt-in env var; no user-authored code is affected.
487
+ - **@voltro/runtime** — Schedule (cron) observability metrics. The framework scheduler now emits three registry series on every firing — `voltro_schedule_runs_total{schedule,status}` (firings by name + `succeeded`/`failed`), `voltro_schedule_duration_seconds{schedule}` (histogram), and `voltro_schedule_last_success_timestamp_seconds{schedule}` (a gauge holding the UNIX time of the last SUCCESS). Emitted from the single scheduler seam, so EVERY app's crons get them with no per-handler wiring, scrapeable via `@voltro/plugin-prometheus` (`GET /metrics`), `GET /_voltro/inspect/metrics`, or the OTLP export — the same registry as the RPC/HTTP/subscription metrics. A cron fires unattended, so its failure mode is silent; the last-success gauge is the series to alert on (`time() - voltro_schedule_last_success_timestamp_seconds > interval × N`), because a failure counter alone can't catch a job that stopped firing at all. A failure moves the counter but deliberately NOT the gauge. `codemod: none` — additive metric emission; no user-authored code is affected.
488
+ - **@voltro/database, @voltro/runtime** — **`.version()` — optimistic locking, and the answer to "which write is newest".**
489
+
490
+ Two clients read the same row and both write it. Until now the second silently won and the first user's change was gone with no trace — the shape of every "my edit disappeared" report. Mark the column and the store owns it:
491
+
492
+ ```ts
493
+ table('documents', { id: id(), title: text(), version: integer().version() })
494
+
495
+ yield* ctx.store.update('documents', id, { title, version }) // the version the client READ
496
+ // → VersionConflict { expected: 3, actual: 7 }
497
+ ```
498
+
499
+ `VersionConflict` is a typed error carrying **both** numbers, because "someone else changed it" is not actionable while "you had 3, it is now 7" is. It reaches the client typed, so a UI can offer reload-and-re-apply rather than showing a crash.
500
+
501
+ **A timestamp cannot do this job**, which is why `.version()` rejects one at declaration: two writes in the same millisecond are indistinguishable and replica clocks disagree, so a comparison that looks right in a test loses rows under load. This repo has already lost rows to exactly that — an analytics sink dropped 7 of 40 events written in the same millisecond as the query bounding them. An integer the database owns is totally ordered and needs no clock.
502
+
503
+ Three decisions worth knowing: the caller's version is an **expectation, never a write** (it is stripped from the patch, so a client cannot pin its own and win every race); an update with no expectation stays last-write-wins but the version **still advances** (one that moved only for careful writers would sit still while a careless write changed the row — worse than none); and a row deleted underneath you conflicts with `actual: null`, which is how you tell "deleted" from "changed".
504
+
505
+ Enforced in the store wrapper every dialect passes through, NOT in the four hand-written `DataStore` implementations. Twice now a correct fix landed in one of those and the other three kept the bug — a per-dialect copy of a subtle decision will drift, so the decision stopped being per-dialect.
506
+
507
+ **`expires()` — a row with an end date.**
508
+
509
+ ```ts
510
+ table('inviteLinks', { id: id(), email: text() }).with(expires())
511
+ ```
512
+
513
+ After `expiresAt` passes the row is not returned by reads. Null means never, so adding the mixin to an existing table does not make its rows vanish; `.includeExpired()` opts out for a deliberate admin read.
514
+
515
+ **Read the split before relying on it.** Visibility and storage are two guarantees and only one holds everywhere: reads filter on **every dialect, immediately**, while the physical delete is a **postgres-only** retention sweep. An expired row is therefore invisible everywhere and still present in the database on four of five dialects. That is deliberate — making visibility depend on the sweep would mean a row that vanished on postgres and kept serving on MariaDB, which is the per-dialect divergence class this repo has three scars from — but it means an expired row is not unreachable. If the value must actually be gone, delete it.
516
+ - **@voltro/runtime, @voltro/workflow, @voltro/cli** — Workflow (durable-execution) observability metrics. The workflow run-recording seam now emits three registry series on every terminal outcome — `voltro_workflow_runs_total{workflow,status}` (`succeeded`/`failed`), `voltro_workflow_duration_seconds{workflow}` (histogram), and `voltro_workflow_last_success_timestamp_seconds{workflow}` (last-success gauge). Because the framework applies no retry of its own, a `failed` run is TERMINAL — it is the dead-letter state — so the failed counter IS the dead-letter rate, and the last-success gauge going stale is the "this workflow stopped completing" alert (`time() - voltro_workflow_last_success_timestamp_seconds > N`), mirroring the schedule metrics. A failure moves the counter but not the gauge. Same registry as the RPC/HTTP/subscription/schedule metrics → scrapeable via `@voltro/plugin-prometheus`, `/_voltro/inspect/metrics`, or OTLP. `@voltro/workflow` stays free of a `@voltro/runtime` dependency: the recorder is injected as an optional `recordRun` hook on the recording options (mirroring `emit`/`wakeups`), supplied by the CLI in BOTH boot paths. `codemod: none` — additive metric emission + a new optional hook; no user-authored code is affected.
517
+
518
+ ### Changed
519
+
520
+ - **@voltro/plugin-presence, @voltro/protocol, @voltro/cli** — **Presence no longer touches the database.** A heartbeat used to rewrite one row per client every 15 seconds, swept by a coordinated background job — a lot of write amplification for a datum that is meaningless 30 seconds later, and it made the most ephemeral thing in the framework the one backed by the most durable store.
521
+
522
+ It is now an owner-partitioned map in memory, announced between replicas over the broadcast channel. **`usePresence` is unchanged** — same signature, same live roster, no client code moves.
523
+
524
+ **Why this needs no CRDT.** Phoenix's tracker uses ORSWOT because it lets any node track any key, so two nodes can genuinely write one key concurrently. We have an invariant they do not: every entry is owned by exactly one instance — the one holding that client's WebSocket — so concurrent writes to one key from different owners are impossible by construction. The merge collapses to partition by owner, union across owners, and a departing owner takes its whole partition.
525
+
526
+ The part Phoenix gets free from BEAM monitors — knowing an owner is gone — is what `InstanceMembership` had to supply, and it is the wire nothing else can provide: a crashing process does not send goodbyes for the thousand clients it was holding, and on the channel it is simply quiet.
527
+
528
+ **The staleness filter is gone, and its absence is the change.** The table version had to compare every row against a timeout because a row outlived the client that wrote it. An entry now leaves when its client does and a whole partition goes when membership says its instance did, so an entry that exists is one an instance is currently vouching for. A timeout could only add a way to be wrong. The coordinated sweep is gone with the rows it swept.
529
+
530
+ **`_voltro_presence` remains DECLARED and is never written.** The name is the reactivity key: `presence.list` declares `source: '_voltro_presence'` and the framework routes change events by table name, so the plugin injects a synthetic change whenever the tracker moves and every subscribed client is pushed a fresh roster through the path it already used. Removing the declaration would make the `source` resolve to nothing — which the boot audit reports correctly, and which would silently stop every roster from updating. One empty table is the accepted cost of not introducing a second push mechanism.
531
+
532
+ `PluginBindContext` gains `instanceId`, `membership` and `broadcast`, so any plugin holding per-replica state can say who owns an entry and learn when that owner is gone. Both boot paths supply all three, asserted by the parity guard — two out of three is silently wrong rather than broken.
533
+
534
+ A defect found while building: the tracker's route key (`tenant + '::' + channel`) is ambiguous once a channel contains the separator, and a round-trip masks it because both readings rebuild the same key. It surfaces only where something reads the PARTS — a client applying a delta — so the parts are stored beside the members and the encoding is now write-only.
535
+ - **@voltro/voltro** — **The umbrella package re-exports the event surface, and two trigger signatures widened.**
536
+
537
+ `@voltro/voltro` is a one-install re-export of runtime / database / protocol / workflow, so everything this release added to those reaches consumers through it too. Almost all of that is a pure addition — `defineEvent`, `EventBus`, `bindEvent`, the delivery semantics, the presence sweep.
538
+
539
+ Two lines are not additions, and they are the reason this entry exists: `defineEventTrigger` and `triggerWorkflow` now accept **either** the original spec **or** the descriptor form (`{ on: gameStarted }`). Their parameter type is a union where it used to be a single shape.
540
+
541
+ `apiSurface: compatible` because widening a PARAMETER cannot break a caller: every call that compiled against the old shape still matches one arm of the union. The check flags it as non-additive because the golden line changed rather than appeared, which is the right default — a narrowed parameter looks identical in a diff and would break every call site.
542
+
543
+ No codemod: nothing a user wrote stops compiling.
544
+
545
+ ### Fixed
546
+
547
+ - **@voltro/runtime** — **A change watched by N identical live subscribers cost N reads and N diffs. It now costs one of each.** Fifty screens open on the same list re-ran the same query fifty times per change and recomputed the same delta fifty times — in memory that is wasted CPU; against SQL it is fifty round trips.
548
+
549
+ `diffRows` costs the WALK, not the delta: ~31µs at 50 rows, ~289µs at 500, ~3.1ms at 5000, and one changed row costs what zero does. So on a large list the diff share is worth as much as the read share.
550
+
551
+ Both are keyed by the resolved read descriptor; the diff share additionally keys on the previous rows' OBJECT IDENTITY. That second half is a safety property rather than an optimisation: a patch computed against another subscriber's base silently corrupts its rows, and it is the one failure on this path that neither a test nor a log would catch. Reference identity cannot be wrong about it — two subscribers share only when they hold the literally same array, which is exactly when the read share already served them together. A late joiner holds a different object and gets its own diff.
552
+
553
+ Recorded because the route was not straight, and the wrong turns are the instructive part. The read memo was removed mid-release as dead code on a measurement that was broken: `handleChange` is dispatched with `void`, so the counter was sampled before the reads had landed, and the "1.00 reads" that condemned it was an artefact of the sampling. The diff share was separately shipped once with a test that could not prove it — counting deliveries, which happen either way, stays green with the share disabled — and was removed for that reason before being rebuilt. The proof needs no mocks: a shared diff is the SAME OBJECT in every delivery, so counting distinct patch identities is exact, and disabling reuse turns one shared patch into fifty.
554
+ - **@voltro/cli** — **The domain-event audit tables are bounded now.** `ctx.events.emit(...)` writes one row to `_voltro_workflow_events` plus **one per matching trigger** to `_voltro_workflow_event_deliveries`, and nothing in the framework ever deleted from either. Both are registered with the boot retention GC on a 30-day default, env-tunable via `VOLTRO_WORKFLOW_EVENTS_TTL_HOURS` and `VOLTRO_WORKFLOW_EVENT_DELIVERIES_TTL_HOURS`.
555
+
556
+ Same family as `_voltro_schedule_claims`, whose sweep landed one release ago after a consumer measured 35,128 rows in 14 days. The comment there already named the pattern — *"the one table of this family with no sweep"* — and two more members of the family were sitting next to it. The delivery log is the faster half: three triggers on one event write four rows per emit.
557
+
558
+ Found while validating a consumer's request for a client-facing event primitive. Their report's core complaint is that they had modelled events as durable rows and the table grew without bound; the primitive we would have pointed them at does exactly that, in framework-owned tables, with no bound at all.
559
+
560
+ **Read the delivery TTL as the deduplication window, not as housekeeping.** The idempotency check looks for an existing delivery row with the same `idempotencyKey`, so once a row is swept its key is no longer deduplicated. With the default key (`<eventId>:<triggerId>`, and `eventId` is fresh per emit) a duplicate cannot occur and the sweep costs nothing; it matters only for an app supplying its own key that can re-emit the same stable value more than 30 days apart. That app raises the env var, which is what it is for.
561
+
562
+ Deliberately **not** status-filtered, unlike `_voltro_outbox`: there a `dead` row is an incident an operator can requeue, while a 30-day-old `starting` delivery has no requeue path and no reader — filtering would preserve evidence nobody can act on and leave the table unbounded for exactly the rows a crash produces.
563
+ - **@voltro/cli** — **A declared event never reached the generated rpcGroup, so `useEvent` could not work in a real app.**
564
+
565
+ `codegen.ts` keeps its own `walk` with its own list of file patterns, and `*.event.ts` was not on it. The machinery below it was complete — `loadExports` has an event branch whose comment says an event descriptor MUST reach the client group, and the emitter has an `eventToRpc` case — but nothing ever handed either of them an event file. A project with two declared events generated a rpcGroup containing neither, the browser's `RpcClient` had no procedure to subscribe with, and the entire client half of the primitive was unreachable.
566
+
567
+ **Nothing reported it, and that is the part worth knowing.** The server builds its own event rpcs in `makeEventWiring` and merges them at runtime, so `voltro dev` logs `events registered count:2` and looks completely healthy from the side anyone would check. It was found by booting a fixture and grepping the generated file, not by any test.
568
+
569
+ This is the third copy of one defect. `fileConventions.ts`, `dev.ts`'s walk and `codegen.ts`'s walk each keep a separate pattern list, and a convention added to one is silently absent from the others — the same shape as the earlier gap where events were discovered by neither boot path. `walkConventionCoverage.test.ts` now asserts the two walks agree on what a client-facing descriptor is, against a real directory tree.
570
+
571
+ Also fixed alongside it: **one descriptor exported under two names generated two of everything.** `export { fireArena }` plus `export default fireArena` is the same object under two keys, and `loadExports` pushed an entry per export name — producing `export const arenaFireRpc =` twice, a redeclaration. The failure was split in the worst way: `voltro dev` booted fine (the generated file is transpiled, not typechecked, and the runtime map overwrote the duplicate key) while the app's own `typecheck` and `voltro build` failed on generated code the user never wrote. Deduped by descriptor IDENTITY, not by name — two DIFFERENT descriptors sharing a name is a real conflict and must still be reported rather than silently collapsed into one endpoint.
572
+ - **@voltro/runtime** — **A freshly-started replica no longer tells every client it missed thousands of messages.**
573
+
574
+ Measured: a pod joining a route where a peer was at serial 5000 reported `missed: 5000` on its first delivery. Nobody had missed anything — that replica simply had not been listening, and a client attaching to it was never owed a peer's history.
575
+
576
+ The cause was one number the subscriber could not see. From a subscriber's seat, two opposite situations look identical: an origin absent from the attach watermark plus a first delivery carrying a high serial. It can mean the serials in between reached this instance and were lost on the way out (a real loss, which the gap detector exists to report), or that this instance never had them at all.
577
+
578
+ A delivery now carries `prior` — the route's watermark for that origin immediately before the envelope was accepted. `prior > 0` proves the earlier serials reached the bus, so a jump is a genuine local drop and is still reported exactly as before; `prior === 0` proves they did not, so there is nothing to report and the first delivery establishes the baseline. A real drop occurring right afterwards is still caught.
579
+
580
+ This also makes it safe for a replica to hold a cross-instance subscription only while it needs one — see the channel-partitioning entry, where "this instance was not listening" stops being a rare startup case and becomes the normal one.
581
+ - **@voltro/runtime, @voltro/cli** — **An event's `guards:` were never checked. Any client that could open the socket could subscribe to any declared event.**
582
+
583
+ `defineEvent` accepted them. Its own doc comment called them *"WHO MAY LISTEN — the same vocabulary as a query's guards"*, with a worked example. `eventToRpc` declared `ScopeError` in the wire contract whenever they were present. `manifestBuild` serialised them, `doctorCommand` and `advisoryGuardAudit` reported on them, `voltro check` counted their scopes, and the devtools events panel showed a guard COUNT per event.
584
+
585
+ Nothing enforced them. `bindEvent` read the resolved subject for the TENANT and for nothing else, so a declaration that read as an access-control rule was decoration.
586
+
587
+ That is the worst shape this class of hole can take: everything *around* the enforcement existed, so it looked enforced from every angle an author or an operator would inspect it from — the manifest, the dashboard, the doctor, and the type of the error the rpc could return. The one thing missing was the check.
588
+
589
+ Guards now run BEFORE the subscribe, from the same descriptor every one of those readers uses, with the routing key as the guard input — so a resource-scoped guard (`{ scope: 'arena:read', from: 'arenaId' }`) can see which arena was asked for. `bindEvent`'s error channel is `ScopeError` rather than `never`, which is what `eventToRpc` had been promising all along.
590
+
591
+ The ORDER is pinned too, not just the check: failing after `bus.subscribe` would leave a refused client holding a live subscription, and the first version of that test read the subscriber count after the stream had already ended — where the scope's finaliser has unsubscribed and the count is 0 either way. It measures while the subscription would be live now, and asserts the admitted case is 1, or the denial assertion proves nothing.
592
+
593
+ Tenant isolation was never affected: it comes from the subject on both sides and is not something a caller can ask for.
594
+ - **@voltro/runtime** — **A `latest` event re-sent its current value to a client that already had it.**
595
+
596
+ Found by building the cross product of delivery semantics against attach kinds — each was individually covered and the combination was not.
597
+
598
+ `each` answers "you are already up to date" with silence. `latest` re-sent the retained value on every resume, on the reasoning that a last-value-wins delivery is idempotent. It is idempotent in a store and not on a screen: a reconnect handing back the value already displayed is a re-render, and on a flaky connection that is a visible flicker with nothing behind it. There is no reason for the two semantics to differ on that question.
599
+
600
+ The comparison is per `(origin, n)`, not by serial alone — under `latest` the retained entry can come from ANY replica, so a bare number would read another pod's serial as our own and skip a value the client has genuinely never seen. Pinned by a test that publishes locally, injects remotely, and resumes current with respect to the local origin only.
601
+ - **@voltro/runtime** — **`await ctx.events.publish(...)` in an async handler published NOTHING, silently.**
602
+
603
+ `ctx.events.publish` returns an `Effect`. An Effect is not thenable, so `await` hands the object back unrun: no delivery to clients, no cross-replica publish, no webhook, no workflow trigger — and the handler returns success. Nothing errors, nothing logs, and `tsc` is satisfied because awaiting a non-Promise is legal.
604
+
605
+ Found the hard way: a two-replica end-to-end fixture published from an async handler, the action returned `{ ok: true }`, and a full broker trace showed no event traffic at all. The first two hypotheses (a subscribe/publish race, then a stale build) were both wrong, and the diagnosis only landed after instrumenting the broker on both sides.
606
+
607
+ It matters because **both handler styles are supported and shipped**: the docs show the `Effect.gen` + `yield*` form, which works, while the mutation TEMPLATE ships an `async (input, ctx) => { … }` handler. An author following the template and reaching for `ctx.events.publish` gets the one spelling that cannot work.
608
+
609
+ It is also inconsistent with the rest of `ctx.*`. `ctx.store.insert`, `ctx.cache` and `ctx.kv` are Promise-based precisely so async handlers can use them — `makeAsyncKv` / `makeAsyncCache` exist for that reason. `ctx.events` is the one member that is not, and the difference is invisible at the call site.
610
+
611
+ `publishEvent`'s Effect is now also awaitable: the returned value carries a `then` that runs it, so `await ctx.events.publish(…)` performs the publish and resolves with the same result `yield*` produces. Both spellings work, neither is silent, and the Effect-first form remains the documented one.
612
+ - **@voltro/runtime** — **Publishing an event is 2.1× faster, and the reason is worth knowing: observability was setting the throughput ceiling.**
613
+
614
+ Measured on the publish path, single core:
615
+
616
+ | | before | after | | --- | --- | --- | | `bus.publish` (1 subscriber) | 5.07µs | **1.45µs** | | `bus.publish` (100 subscribers) | 5.13µs | **1.48µs** | | `ctx.events.publish` (encode + size gate + bus) | 8.60µs | **4.12µs** |
617
+
618
+ The cause was one line. `Effect.tagMetrics('event', name)` is the natural spelling for labelling a metric and it modifies a FiberRef to build a label context on EVERY call: **4.2µs**, against 0.7µs for a metric instance tagged once via `Metric.tagged`. Before the change the metric was roughly **90% of the cost of publishing an event** — the route encoding, the replay ring and the size gate together came to 0.35µs.
619
+
620
+ The tag cache is bounded by construction: its keys are DECLARED event names plus a two-value drop reason, so it cannot grow with traffic. A label carrying user data would make it a leak, and the test pins the boundedness rather than a size.
621
+
622
+ Two things the measurement corrected, both recorded because the guesses were plausible:
623
+
624
+ - **The replay ring was NOT the bottleneck.** `ring = ring.slice(drop)` reallocates a 64-element array on every publish once full, which looked like the obvious cost. Fixing it to an in-place `splice` moved 5.76µs to 5.07µs — real, and nowhere near the metric. It is kept because the allocation is what a garbage collector notices, but it was not the answer. - **Fan-out is nearly free.** 1 subscriber and 100 subscribers cost the same; 1000 costs 3.07µs. The per-publish work dominates, not the delivery loop.
625
+
626
+ Guarded behaviourally rather than by timing — a timing assertion goes flaky on a loaded CI machine and then gets deleted, after which the regression it guarded is invisible again. Reverting to `tagMetrics` leaves the cache empty and the test goes red.
627
+ - **@voltro/cli** — **A source-tree guard failed the whole test FILE when a fixture directory vanished mid-walk.**
628
+
629
+ `netHarnessPackages.test.ts` walked with `readdirSync(dir)` then `statSync(p)` — two syscalls with a gap. The codegen suites create their fixture modules inside `src/` (`mkdtemp(join(here, '.codegen-…'))`) and remove them in `afterEach`, and they have to live there: the codegen imports them through vite's module graph, which is rooted at the package. A directory removed inside that gap makes `statSync` throw `ENOENT`, which fails the file at COLLECTION time — no assertion, a path nobody recognises, and green the moment you re-run it alone.
630
+
631
+ This is the FOURTH file to grow that shape, and the rule was already written up in `packages/cli/CLAUDE.md` for `ledgerReadPortability.test.ts`. It surfaced now because two new codegen suites landed in the same directory, which is the point: the latent version was indistinguishable from machine load.
632
+
633
+ Fixed on the reader, per that rule: `readdirSync(dir, { withFileTypes: true })` gives the name and the kind from ONE syscall, so there is no gap; and dot-directories are skipped, which is right regardless — a scratch directory is never source.
634
+ - **@voltro/plugin-presence** — **A client that vanished stayed in the presence roster forever, and `presencePlugin({ timeoutMs })` did nothing.**
635
+
636
+ One cause, two symptoms. A member left the roster only when its client explicitly CALLED `leave`. A closed laptop, a dropped network or a crashed tab call nothing — and the owning replica is still alive, so `dropOwner` never fires either. Those entries stayed, and every screen kept showing people who had gone home.
637
+
638
+ The tracker's own comment asserted the opposite ("an entry is removed when the client leaves") and argued from it that a staleness filter "would only add a way to be wrong". The premise was false, so the conclusion protected the bug. A stale comment describing a cluster-coordinated sweep that had been deleted in an earlier rewrite made it read as already-solved from a second angle.
639
+
640
+ `timeoutMs` was the second half of the same defect: accepted, shown in the plugin's own usage example, and logged at boot — read by nothing. The same shape as `defineEvent({ guards })` and `broadcast({ channel })`.
641
+
642
+ `sweep()` now removes members whose client stopped heartbeating, and `timeoutMs` drives it. It touches **only this instance's own partition** — another owner's entries carry timestamps from THEIR clock, and judging them against ours is exactly the mistake instance membership exists to avoid: a peer that is gone is dropped whole, on a signal, never on a guess about clock skew.
643
+
644
+ It needs no cluster coordination, and that is a consequence of the design rather than a shortcut: the table version had shared rows, so one replica had to evict them or they would fight. Owner-partitioned presence has no shared state, so every replica sweeps its own and there is nothing to coordinate.
645
+
646
+ Removals are ANNOUNCED — a local removal nobody broadcasts is a member every other replica keeps showing. The sweep runs at a third of the timeout, so a vanished member is gone within roughly 1.3× the window rather than up to 2×.
647
+ - **@voltro/cli** — **`voltro build` could delete output it had just written.** The post-build orphan prune compared each file's mtime against `Date.now()` taken at build start — two different clocks. Linux stamps inode times from a COARSE clock (`ktime_get_coarse_real_ts64`) that advances once per timer tick, so a file written microseconds AFTER the cutoff can carry an mtime a tick BEFORE it, and the strict comparison then removed it.
648
+
649
+ The consequence is the exact failure the prune was designed to avoid: a bundle that is missing pieces mid-run. The wipe-before-build version had the same effect for a different reason, and this reintroduced it in a narrower window.
650
+
651
+ The comparison now carries a one-second tolerance. The two directions are not symmetric — too small deletes a fresh artefact, too large lets an orphan survive until the next prune — so the margin sits on the side of keeping. Real orphans are minutes or builds old.
652
+
653
+ Found by the release gate on Linux, where the suite's own concurrency case failed while asserting a precondition that held: the file it checked was fine, a different one was pruned. It had never failed on macOS, whose timestamp granularity differs. The suite now pins the tolerance directly — a file stamped just before the cutoff must survive, and one past the tolerance must still go, so the margin cannot quietly widen into a no-op.
654
+ - **@voltro/cli** — **A `source:` that names no table is now reported at boot.** It was silent, and the silence is the defect: `source` is matched BY NAME against change events, so one naming a table that does not exist matches nothing — the query returns its first result and never updates again. Not a broken subscription, a permanently silent one, which from the outside is indistinguishable from "nothing has changed".
655
+
656
+ ```text
657
+ 1 query declares a `source` that names no table:
658
+ agent.messages: source 'agent_messages' is not a declared table — did you mean '_voltro_agent_messages'?
659
+ ```
660
+
661
+ Found by a consumer applying the `agent_messages` → `_voltro_agent_messages` rename we shipped. Their two agent queries went quiet, their live typewriter stopped updating (the reply arrived on page reload), and every layer agreed everything was fine: boot clean, zero warnings, `tsc` green — `source` is a string. Their own invariant test missed it too, because it compared DECLARED sources against READ tables and both sides named the old table, so they went stale together and agreed.
662
+
663
+ Our codemod's reassurance — *"a missed one fails loudly with 'relation does not exist'"* — is true of a SQL reference and NOT of a `source:` declaration. That sentence is what sent them past the `grep` hits it had printed.
664
+
665
+ The check is set membership against data the boot already holds, so it closes a class: a rename is one way in, a typo is another, a plugin table whose plugin is not installed is a third. The suggestion is what makes it actionable, and it is why edit distance alone is not enough — `agent_messages` → `_voltro_agent_messages` is eight edits, so a prefix match wins outright. Wired into `voltro dev` AND `voltro serve`. **Warn, not refuse**, deliberately: an app can be carrying one right now and booting happily, so refusing would turn an upgrade into an outage for a defect the framework never mentioned.
666
+
667
+ **And every mutating `inspect`-backed command now sends the write credential.** `voltro schedule run`, `voltro workflows start|resume|signal` and `voltro inspect invoke` all sent the bearer and none sent `x-voltro-inspect-write`, so they were refused by their own server while the identical `curl` with both headers worked — using tokens `voltro dev` had minted into the project's own `.env.local`. Five call sites, one omission, in the shared helper none of them owned; it is attached there now for every mutating method. From the ENVIRONMENT only: the read token is published in the runtime registry so a read works from any directory, and publishing the write token beside it would leave no second factor.
668
+ - **@voltro/cli** — **`voltro serve` built the instance-membership registry TWICE per process.**
669
+
670
+ `serveCommand` builds one before `bindDataStore` — it has to, because a plugin handed a registry that appears later would silently never learn that a peer died — and `serveApi` built a second. So one serve process ran **two heartbeat timers announcing the same `instanceId`**, held two subscriptions to the members channel, and handed the presence plugin and the event layer different objects for one fact. `serveCommand`'s was also never detached at shutdown, so its timer ran until process exit.
671
+
672
+ `voltro dev` builds exactly one, which makes this the dev/serve divergence class again — both paths typecheck alone, and nothing errors either side. It was found by booting `voltro serve` for the first time in this area and reading its log: `membership: announcing` appeared twice with the same id.
673
+
674
+ `serveCommand` now hands its registry to `serveApi`, which builds one only when nothing was passed (a direct `serveApi` call in a test or an embedder).
675
+
676
+ Pinned by a two-replica integration test that boots real `voltro serve` processes against a real Redis and asserts exactly one `announcing` line per process — plus the boot REFUSAL when `VOLTRO_SESSION_SECRET` is unset, which is the first thing a deployment hits and must name both the variable and the command that fixes it.
677
+ - **@voltro/protocol, @voltro/cli** — **Two guards for the two defect shapes this area kept producing.**
678
+
679
+ Every defect found while hardening the event primitive was one of two things, so they are checked now rather than rediscovered:
680
+
681
+ **"Declared but never read."** `defineEvent({ guards })` was accepted, documented as "WHO MAY LISTEN", declared as `ScopeError` in the wire contract, serialised into the manifest, reported by doctor and counted in the dashboard — and enforced nowhere. `declaredOptionsEnforced.test.ts` requires each option to be READ on the event's own path, and it took three attempts to make it able to fail:
682
+
683
+ - v1 asked whether the symbol appeared anywhere outside a reporter. It does — on the query path — so it stayed green through a revert that removed the event enforcement entirely. It proved that SOMETHING checks guards, which was never in doubt. - v2 scoped it to the file and still passed: replacing the CALL left the import behind, and an unused import satisfied it. - v3 matches a CALL or a property read, with comments AND imports stripped. `guards` carried a doc block naming itself the whole time it was dead, so a rule satisfied by prose would have passed on the case it exists for.
684
+
685
+ **"Derived twice."** The broadcast namespace could have been derived by four wirings; the membership registry WAS built twice per serve process; the file-convention pattern list exists in three copies and `*.event.ts` was missing from one. None of them errors — two namespaces that disagree are simply invisible to each other. `derivedOnceGuard.test.ts` pins one construction per boot path and requires the second consumer to take the value as a parameter.
686
+
687
+ Both are red-verified against the actual reverts, not against a hypothetical.
688
+
689
+ Also fixed here: the two-replica boot test hardcoded the expected event count and went red when the fixture grew two more. It derives the number from the fixture now — a count written down beside the thing it counts rots on the next change.
690
+
691
+ And `nats-test` is in the CI stack. It was added to `docker-compose.yml` without being started, so every NATS integration test skipped — and the gate's no-undeclared-skips step is right to call that a coverage claim nobody honours.
692
+
693
+ ### Internal (no consumer-facing effect)
694
+
695
+ - **@voltro/workflow** — **The cluster resume test tore the first runner down at a point the clock picked, and asserted a property only the engine can place.** It waited for `step1`'s SIDE EFFECT, slept one second, then killed runner A and asserted that the resumed runner B did not redo `step1`. Alone that held; inside the full gate it produced `expected ['A','B'] to deeply equal ['A']` — B re-ran the step, correctly.
696
+
697
+ The one second was a guess that `step1`'s journal write had landed. A step's side effect and its durable record cannot be atomic, so a teardown between them re-runs the step on resume — the framework is at-least-once at a step boundary and the docs say so, telling users to make side effects idempotent for exactly this reason. The assertion is therefore legitimate only at a teardown point chosen AFTER the write, and nothing in the test chose one.
698
+
699
+ An `armed` step now sits between `step1` and the nap, and the teardown waits for it. `activityExecute` returns only once a step's result is durably recorded — that is what lets replay skip it, and what the idempotency-key scenario in the same suite already depends on — so `armed` starting IS the journal write having landed. No duration is left in that path.
700
+
701
+ Distinct from the ceiling raises around it, which address a resumed run needing longer than the timeout under load. This one is not a timeout: no amount of waiting turns a re-executed step back into a skipped one.
702
+ - **@voltro/cli** — The dev-SSR streaming tests defined "the shell" as *every chunk that arrived before 0.6 × the deferral delay* — an assertion about the machine wearing the shape of an assertion about the renderer. Under a loaded CI runner the shell lands after that deadline, the derived `shell` string comes out EMPTY, and the failure reads `Expected SHELL_LAYOUT_EAGER_OK`, as though the renderer had dropped a field. Green on every developer machine.
703
+
704
+ The shell is now everything BEFORE the chunk carrying the deferred value, and the claim the first-byte deadline was reaching for is stated as what it actually is: the eager field's chunk index is strictly lower than the deferred value's. No duration remains in that path. The one clock that stays is the lower bound on WHEN the deferred value arrived — a slower machine only makes that more true.
705
+
706
+ It still fails a buffered implementation, which is the point of the suite: one chunk means the deferred index is 0, the shell is empty, and the eager-field assertion fails.
707
+
708
+ **Found because a red suite had been reporting green.** CI's test step ends in `| tee`, and GitHub's default `run` shell is `bash -e` — *without* `pipefail` — so the step's exit status was tee's. `@voltro/cli#test` failed, turbo exited 1, and the step reported SUCCESS; the comment above it asserted pipefail was on. It surfaced only because the failing package died before printing its summary, which tripped the undeclared-skip check further down. One line later and the gate would have gone green on a failing test. The workflow now sets `defaults.run.shell: bash` so no future piped step can reintroduce it.
709
+
710
+ ---
711
+
42
712
  ## [0.24.0] — 2026-08-02
43
713
 
44
714
  ### ⚠ BREAKING
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/plugin-billing",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "Billing on the official Stripe SDK — subscriptions, plans, entitlements and usage metering. Proration, failed-payment retries, tax, promotion codes, trials and the checkout seat stepper are Stripe's own; the plugin adds the entitlement engine, the enforce interceptor, typed BillingError/EntitlementExceeded, the /billing/webhook mount with Stripe's signature verification, the BillingService Tag, requireEntitlement(), and a browser-safe useStartCheckout() hook. Money is integer minor units end-to-end.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -47,11 +47,11 @@
47
47
  "node": ">=24.0.0"
48
48
  },
49
49
  "dependencies": {
50
- "@voltro/database": "0.24.0",
51
- "@voltro/env": "0.24.0",
52
- "@voltro/logger": "0.24.0",
53
- "@voltro/plugin-webhooks": "0.24.0",
54
- "@voltro/protocol": "0.24.0",
50
+ "@voltro/database": "0.26.0",
51
+ "@voltro/env": "0.26.0",
52
+ "@voltro/logger": "0.26.0",
53
+ "@voltro/plugin-webhooks": "0.26.0",
54
+ "@voltro/protocol": "0.26.0",
55
55
  "stripe": "22.3.2"
56
56
  },
57
57
  "peerDependencies": {