@voltro/protocol 0.25.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.
package/CHANGELOG.md CHANGED
@@ -39,6 +39,250 @@ _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
+
42
286
  ## [0.25.0] — 2026-08-04
43
287
 
44
288
  ### ⚠ BREAKING
package/dist/index.d.ts CHANGED
@@ -38,6 +38,28 @@ export declare interface ActionProcedureDescriptor<Name extends string, Input ex
38
38
  readonly exposeAsTool: ExposeAsTool | undefined;
39
39
  /** True when the procedure is kept OFF the wire — no client-group entry and no
40
40
  * route in dev or serve. See `internal` on the definer's options. */
41
+ /**
42
+ * Replace a PLUGIN route that answers to this same tag.
43
+ *
44
+ * Without it, a user route and a plugin route sharing a tag is a hard error,
45
+ * and correctly so — two handlers behind one name is not a thing a caller can
46
+ * reason about. But refusing is the wrong answer when the app deliberately
47
+ * wants its own version: the two escapes available otherwise are to rename
48
+ * your procedure (so the split runs along "who built it" rather than along a
49
+ * domain boundary) or to `alias` the whole plugin away (same, one level up).
50
+ * For a frontend developer that is the worst possible partition.
51
+ *
52
+ * A reporter wanted exactly this: adopt `@voltro/plugin-notifications`, whose
53
+ * surface is richer than theirs, add `archive`/`unarchive` beside it — which
54
+ * already composes, since the collision check compares FULL tags and not
55
+ * prefixes — and replace `markRead`, because theirs maintains archive state.
56
+ *
57
+ * Explicit, never inferred. Silently letting the app win would mean a plugin
58
+ * upgrade that adds a route could shadow an app procedure with no diff to
59
+ * read; declaring it makes the intent reviewable and puts the override in the
60
+ * file that performs it.
61
+ */
62
+ readonly overridesPlugin: boolean | undefined;
41
63
  readonly internal: boolean | undefined;
42
64
  }
43
65
 
@@ -488,6 +510,28 @@ export declare interface ConnectionInfoValue {
488
510
  * (every `useMutation` call does). Read by `bindMutation` to dedupe a retried
489
511
  * mutation. Absent for callers that don't send it. */
490
512
  readonly idempotencyKey?: string;
513
+ /**
514
+ * Unix-SECONDS expiry of the credential that authorized this call, when it
515
+ * has one. Absent for credentials with no expiry (anonymous, a non-expiring
516
+ * strategy) — and absent means "no bound", so the failure direction is the
517
+ * behaviour that already existed.
518
+ *
519
+ * It exists for LONG-LIVED work. A request is checked once and is over in
520
+ * milliseconds, so expiry never mattered; an event subscription is a
521
+ * standing state that reconnects forever by design, so one opened a minute
522
+ * before the token dies would otherwise keep delivering for days on a
523
+ * credential that is long gone. `bindEvent` ends the stream here, and
524
+ * `useEvent`'s existing reconnect immediately re-opens it — which is a NEW
525
+ * request, so it re-resolves the subject and re-runs the guards for real.
526
+ * That is what makes the bound seamless rather than a disconnection the app
527
+ * has to handle: still entitled, it continues; no longer entitled, it fails
528
+ * loudly instead of quietly continuing.
529
+ *
530
+ * This bounds EXPIRY, not revocation. A role revoked mid-session is not
531
+ * observed until the credential runs out — do not let this field grow a
532
+ * doc comment that claims otherwise.
533
+ */
534
+ readonly credentialExpiresAt?: number;
491
535
  }
492
536
 
493
537
  /** The two credential shapes a connection can hold. `oauth2` = an
@@ -694,6 +738,9 @@ export declare const defineAction: <const Name extends string, Input extends Sch
694
738
  * need to check who is asking.
695
739
  */
696
740
  readonly internal?: boolean;
741
+ /** Replace a PLUGIN route answering to this same tag. Explicit, never
742
+ * inferred — see `overridesPlugin` on the descriptor. */
743
+ readonly overridesPlugin?: boolean;
697
744
  }) => ActionProcedureDescriptor<Name, Input, Output, Error>;
698
745
 
699
746
  /**
@@ -825,6 +872,9 @@ export declare const defineMutation: <const Name extends string, Input extends S
825
872
  * need to check who is asking.
826
873
  */
827
874
  readonly internal?: boolean;
875
+ /** Replace a PLUGIN route answering to this same tag. Explicit, never
876
+ * inferred — see `overridesPlugin` on the descriptor. */
877
+ readonly overridesPlugin?: boolean;
828
878
  }) => MutationProcedureDescriptor<Name, Input, Output, Error>;
829
879
 
830
880
  /**
@@ -919,6 +969,9 @@ export declare const defineQuery: <const Name extends string, Input extends Sche
919
969
  * need to check who is asking.
920
970
  */
921
971
  readonly internal?: boolean;
972
+ /** Replace a PLUGIN route answering to this same tag. Explicit, never
973
+ * inferred — see `overridesPlugin` on the descriptor. */
974
+ readonly overridesPlugin?: boolean;
922
975
  }) => QueryProcedureDescriptor<Name, Input, Output, Error>;
923
976
 
924
977
  /**
@@ -935,6 +988,22 @@ export declare const defineStream: <const Name extends string, Input extends Sch
935
988
  * dev or serve. Same contract as `internal` on the other definers; a stream
936
989
  * without it would be a hole in the same boundary. */
937
990
  readonly internal?: boolean;
991
+ /** Replace a PLUGIN route answering to this same tag. Explicit, never
992
+ * inferred — see `overridesPlugin` on the descriptor. */
993
+ readonly overridesPlugin?: boolean;
994
+ /**
995
+ * WHO MAY LISTEN.
996
+ *
997
+ * A stream is the same long-lived grant a subscription is, and it was the one
998
+ * primitive that could not express authorization at all — queries, mutations
999
+ * and actions carry `guards:`, streams did not, so any protection lived
1000
+ * hand-written inside an executor where nothing could verify it existed.
1001
+ *
1002
+ * Checked at subscribe and re-checked before every element, so a resource
1003
+ * un-shared or a membership ended stops the stream rather than continuing to
1004
+ * push. Same shape and same semantics as a query's.
1005
+ */
1006
+ readonly guards?: Guards;
938
1007
  }) => StreamProcedureDescriptor<Name, Input, Element, Error>;
939
1008
 
940
1009
  export declare interface DeleteTarget<Input = unknown> extends NestedTargetFields<Input> {
@@ -1269,11 +1338,6 @@ export declare interface EventWebhookSpec {
1269
1338
  readonly description?: string;
1270
1339
  /** Payload schema version. Bump when subscribers must adapt. Default 1. */
1271
1340
  readonly version?: number;
1272
- /** Default retry policy for new subscriptions (`{ attempts, backoffMs }`). */
1273
- readonly retry?: {
1274
- readonly attempts?: number;
1275
- readonly backoffMs?: number;
1276
- };
1277
1341
  /** Shared ceiling across ALL deliveries of this event — the runaway-emit
1278
1342
  * guard. Over-limit deliveries are deferred, never dropped. */
1279
1343
  readonly rateLimit?: {
@@ -1597,6 +1661,28 @@ export declare interface MutationProcedureDescriptor<Name extends string, Input
1597
1661
  readonly exposeAsTool: ExposeAsTool | undefined;
1598
1662
  /** True when the procedure is kept OFF the wire — no client-group entry and no
1599
1663
  * route in dev or serve. See `internal` on the definer's options. */
1664
+ /**
1665
+ * Replace a PLUGIN route that answers to this same tag.
1666
+ *
1667
+ * Without it, a user route and a plugin route sharing a tag is a hard error,
1668
+ * and correctly so — two handlers behind one name is not a thing a caller can
1669
+ * reason about. But refusing is the wrong answer when the app deliberately
1670
+ * wants its own version: the two escapes available otherwise are to rename
1671
+ * your procedure (so the split runs along "who built it" rather than along a
1672
+ * domain boundary) or to `alias` the whole plugin away (same, one level up).
1673
+ * For a frontend developer that is the worst possible partition.
1674
+ *
1675
+ * A reporter wanted exactly this: adopt `@voltro/plugin-notifications`, whose
1676
+ * surface is richer than theirs, add `archive`/`unarchive` beside it — which
1677
+ * already composes, since the collision check compares FULL tags and not
1678
+ * prefixes — and replace `markRead`, because theirs maintains archive state.
1679
+ *
1680
+ * Explicit, never inferred. Silently letting the app win would mean a plugin
1681
+ * upgrade that adds a route could shadow an app procedure with no diff to
1682
+ * read; declaring it makes the intent reviewable and puts the override in the
1683
+ * file that performs it.
1684
+ */
1685
+ readonly overridesPlugin: boolean | undefined;
1600
1686
  readonly internal: boolean | undefined;
1601
1687
  }
1602
1688
 
@@ -2533,6 +2619,28 @@ export declare interface QueryProcedureDescriptor<Name extends string, Input ext
2533
2619
  readonly exposeAsTool: ExposeAsTool | undefined;
2534
2620
  /** True when the procedure is kept OFF the wire — no client-group entry and no
2535
2621
  * route in dev or serve. See `internal` on the definer's options. */
2622
+ /**
2623
+ * Replace a PLUGIN route that answers to this same tag.
2624
+ *
2625
+ * Without it, a user route and a plugin route sharing a tag is a hard error,
2626
+ * and correctly so — two handlers behind one name is not a thing a caller can
2627
+ * reason about. But refusing is the wrong answer when the app deliberately
2628
+ * wants its own version: the two escapes available otherwise are to rename
2629
+ * your procedure (so the split runs along "who built it" rather than along a
2630
+ * domain boundary) or to `alias` the whole plugin away (same, one level up).
2631
+ * For a frontend developer that is the worst possible partition.
2632
+ *
2633
+ * A reporter wanted exactly this: adopt `@voltro/plugin-notifications`, whose
2634
+ * surface is richer than theirs, add `archive`/`unarchive` beside it — which
2635
+ * already composes, since the collision check compares FULL tags and not
2636
+ * prefixes — and replace `markRead`, because theirs maintains archive state.
2637
+ *
2638
+ * Explicit, never inferred. Silently letting the app win would mean a plugin
2639
+ * upgrade that adds a route could shadow an app procedure with no diff to
2640
+ * read; declaring it makes the intent reviewable and puts the override in the
2641
+ * file that performs it.
2642
+ */
2643
+ readonly overridesPlugin: boolean | undefined;
2536
2644
  readonly internal: boolean | undefined;
2537
2645
  }
2538
2646
 
@@ -2890,6 +2998,10 @@ export declare interface StreamProcedureDescriptor<Name extends string, Input ex
2890
2998
  /** True when the stream is kept OFF the wire — no client-group entry and no
2891
2999
  * route in dev or serve. See `internal` on the definer's options. */
2892
3000
  readonly internal: boolean | undefined;
3001
+ /** WHO MAY LISTEN. Checked at subscribe AND re-checked before every element,
3002
+ * the same as a query's — a stream is a long-lived grant and the scopes that
3003
+ * justified it can be withdrawn while it is still open. */
3004
+ readonly guards: Guards | undefined;
2893
3005
  }
2894
3006
 
2895
3007
  export declare const streamToRpc: <Name extends string, Input extends Schema.Schema.Any, Element extends Schema.Schema.Any, Err extends Schema.Schema.All>(descriptor: StreamProcedureDescriptor<Name, Input, Element, Err>, extraErrors?: ExtraErrors) => Rpc.Rpc<Name, Input extends Schema.Struct.Fields ? Schema.Struct<Input> : Input, Stream<Element, Schema.Schema.All>, typeof Schema.Never, never>;
@@ -2934,36 +3046,36 @@ export declare class SubjectService extends SubjectService_base {
2934
3046
 
2935
3047
  declare const SubjectService_base: Context.TagClass<SubjectService, "@voltro/Subject", {
2936
3048
  readonly id: string;
2937
- readonly tenantId: string;
2938
3049
  readonly type: "user";
3050
+ readonly tenantId: string;
2939
3051
  readonly scopes?: readonly string[] | undefined;
2940
3052
  readonly metadata?: {
2941
3053
  readonly [x: string]: unknown;
2942
3054
  } | undefined;
2943
3055
  } | {
2944
3056
  readonly id: string;
2945
- readonly tenantId: string;
2946
3057
  readonly type: "apiKey";
3058
+ readonly tenantId: string;
2947
3059
  readonly scopes?: readonly string[] | undefined;
2948
3060
  readonly metadata?: {
2949
3061
  readonly [x: string]: unknown;
2950
3062
  } | undefined;
2951
3063
  } | {
2952
3064
  readonly id: string;
2953
- readonly tenantId: string;
2954
3065
  readonly type: "serviceAccount";
3066
+ readonly tenantId: string;
2955
3067
  readonly scopes?: readonly string[] | undefined;
2956
3068
  readonly metadata?: {
2957
3069
  readonly [x: string]: unknown;
2958
3070
  } | undefined;
2959
3071
  } | {
2960
3072
  readonly id: null;
2961
- readonly tenantId: string | null;
2962
3073
  readonly type: "anonymous";
3074
+ readonly tenantId: string | null;
2963
3075
  } | {
2964
3076
  readonly id: string;
2965
- readonly tenantId: null;
2966
3077
  readonly type: "system";
3078
+ readonly tenantId: null;
2967
3079
  readonly scopes?: readonly string[] | undefined;
2968
3080
  readonly metadata?: {
2969
3081
  readonly [x: string]: unknown;
package/dist/index.js CHANGED
@@ -92,7 +92,8 @@ var Fe = c.Union(c.String, c.Number), u = c.Record({
92
92
  guards: e.guards,
93
93
  publicApi: e.publicApi,
94
94
  exposeAsTool: e.exposeAsTool,
95
- internal: e.internal
95
+ internal: e.internal,
96
+ overridesPlugin: e.overridesPlugin
96
97
  }), v = (e) => g({
97
98
  kind: "mutation",
98
99
  name: e.name,
@@ -103,7 +104,8 @@ var Fe = c.Union(c.String, c.Number), u = c.Record({
103
104
  guards: e.guards,
104
105
  publicApi: e.publicApi,
105
106
  exposeAsTool: e.exposeAsTool,
106
- internal: e.internal
107
+ internal: e.internal,
108
+ overridesPlugin: e.overridesPlugin
107
109
  }), y = (e) => g({
108
110
  kind: "action",
109
111
  name: e.name,
@@ -115,14 +117,17 @@ var Fe = c.Union(c.String, c.Number), u = c.Record({
115
117
  target: e.target,
116
118
  publicApi: e.publicApi,
117
119
  exposeAsTool: e.exposeAsTool,
118
- internal: e.internal
120
+ internal: e.internal,
121
+ overridesPlugin: e.overridesPlugin
119
122
  }), Ue = (e) => g({
120
123
  kind: "stream",
121
124
  name: e.name,
122
125
  input: e.input,
123
126
  element: e.element,
124
127
  error: e.error ?? c.Never,
125
- internal: e.internal
128
+ internal: e.internal,
129
+ overridesPlugin: e.overridesPlugin,
130
+ guards: e.guards
126
131
  }), b = (e, t) => t && t.length > 0 ? c.Union(e, ...t) : e, x = (e, t) => t && t.length > 0 ? c.Union(e, s) : e, S = (e, t) => l.make(e.name, {
127
132
  payload: e.input,
128
133
  success: h(e.output),
package/dist/rest.d.ts CHANGED
@@ -31,6 +31,28 @@ declare interface ActionProcedureDescriptor<Name extends string, Input extends S
31
31
  readonly exposeAsTool: ExposeAsTool | undefined;
32
32
  /** True when the procedure is kept OFF the wire — no client-group entry and no
33
33
  * route in dev or serve. See `internal` on the definer's options. */
34
+ /**
35
+ * Replace a PLUGIN route that answers to this same tag.
36
+ *
37
+ * Without it, a user route and a plugin route sharing a tag is a hard error,
38
+ * and correctly so — two handlers behind one name is not a thing a caller can
39
+ * reason about. But refusing is the wrong answer when the app deliberately
40
+ * wants its own version: the two escapes available otherwise are to rename
41
+ * your procedure (so the split runs along "who built it" rather than along a
42
+ * domain boundary) or to `alias` the whole plugin away (same, one level up).
43
+ * For a frontend developer that is the worst possible partition.
44
+ *
45
+ * A reporter wanted exactly this: adopt `@voltro/plugin-notifications`, whose
46
+ * surface is richer than theirs, add `archive`/`unarchive` beside it — which
47
+ * already composes, since the collision check compares FULL tags and not
48
+ * prefixes — and replace `markRead`, because theirs maintains archive state.
49
+ *
50
+ * Explicit, never inferred. Silently letting the app win would mean a plugin
51
+ * upgrade that adds a route could shadow an app procedure with no diff to
52
+ * read; declaring it makes the intent reviewable and puts the override in the
53
+ * file that performs it.
54
+ */
55
+ readonly overridesPlugin: boolean | undefined;
34
56
  readonly internal: boolean | undefined;
35
57
  }
36
58
 
@@ -209,6 +231,28 @@ declare interface MutationProcedureDescriptor<Name extends string, Input extends
209
231
  readonly exposeAsTool: ExposeAsTool | undefined;
210
232
  /** True when the procedure is kept OFF the wire — no client-group entry and no
211
233
  * route in dev or serve. See `internal` on the definer's options. */
234
+ /**
235
+ * Replace a PLUGIN route that answers to this same tag.
236
+ *
237
+ * Without it, a user route and a plugin route sharing a tag is a hard error,
238
+ * and correctly so — two handlers behind one name is not a thing a caller can
239
+ * reason about. But refusing is the wrong answer when the app deliberately
240
+ * wants its own version: the two escapes available otherwise are to rename
241
+ * your procedure (so the split runs along "who built it" rather than along a
242
+ * domain boundary) or to `alias` the whole plugin away (same, one level up).
243
+ * For a frontend developer that is the worst possible partition.
244
+ *
245
+ * A reporter wanted exactly this: adopt `@voltro/plugin-notifications`, whose
246
+ * surface is richer than theirs, add `archive`/`unarchive` beside it — which
247
+ * already composes, since the collision check compares FULL tags and not
248
+ * prefixes — and replace `markRead`, because theirs maintains archive state.
249
+ *
250
+ * Explicit, never inferred. Silently letting the app win would mean a plugin
251
+ * upgrade that adds a route could shadow an app procedure with no diff to
252
+ * read; declaring it makes the intent reviewable and puts the override in the
253
+ * file that performs it.
254
+ */
255
+ readonly overridesPlugin: boolean | undefined;
212
256
  readonly internal: boolean | undefined;
213
257
  }
214
258
 
@@ -517,6 +561,28 @@ declare interface QueryProcedureDescriptor<Name extends string, Input extends Sc
517
561
  readonly exposeAsTool: ExposeAsTool | undefined;
518
562
  /** True when the procedure is kept OFF the wire — no client-group entry and no
519
563
  * route in dev or serve. See `internal` on the definer's options. */
564
+ /**
565
+ * Replace a PLUGIN route that answers to this same tag.
566
+ *
567
+ * Without it, a user route and a plugin route sharing a tag is a hard error,
568
+ * and correctly so — two handlers behind one name is not a thing a caller can
569
+ * reason about. But refusing is the wrong answer when the app deliberately
570
+ * wants its own version: the two escapes available otherwise are to rename
571
+ * your procedure (so the split runs along "who built it" rather than along a
572
+ * domain boundary) or to `alias` the whole plugin away (same, one level up).
573
+ * For a frontend developer that is the worst possible partition.
574
+ *
575
+ * A reporter wanted exactly this: adopt `@voltro/plugin-notifications`, whose
576
+ * surface is richer than theirs, add `archive`/`unarchive` beside it — which
577
+ * already composes, since the collision check compares FULL tags and not
578
+ * prefixes — and replace `markRead`, because theirs maintains archive state.
579
+ *
580
+ * Explicit, never inferred. Silently letting the app win would mean a plugin
581
+ * upgrade that adds a route could shadow an app procedure with no diff to
582
+ * read; declaring it makes the intent reviewable and puts the override in the
583
+ * file that performs it.
584
+ */
585
+ readonly overridesPlugin: boolean | undefined;
520
586
  readonly internal: boolean | undefined;
521
587
  }
522
588
 
package/dist/session.d.ts CHANGED
@@ -108,6 +108,33 @@ export declare const resolveSessionSecret: () => string;
108
108
  */
109
109
  export declare const resolveSessionSecrets: () => SessionSecrets;
110
110
 
111
+ /**
112
+ * The cookie the framework's own session strategy writes and reads.
113
+ *
114
+ * Exported and single-sourced because two readers now need it — the auth
115
+ * strategy in `dev.ts` and `sessionExpiryFromHeaders` below. Two copies of one
116
+ * env expression is the "derived twice" shape: both sites look correct and
117
+ * they disagree the moment someone sets the variable.
118
+ */
119
+ export declare const SESSION_COOKIE_NAME: string;
120
+
121
+ /**
122
+ * The verified expiry of the session cookie in `headers`, in unix SECONDS, or
123
+ * `undefined` when there is no session or it does not verify.
124
+ *
125
+ * It VERIFIES rather than decoding. An unverified read would be worse than
126
+ * nothing here: the value bounds how long a subscription may live, so a client
127
+ * that could forge a far-future `exp` would lift exactly the ceiling this
128
+ * exists to impose. The cost is one HMAC check on a call that already carries
129
+ * the cookie.
130
+ *
131
+ * A SHARED builder on purpose. `voltro dev` and `voltro serve` assemble their
132
+ * middleware independently, and a value derived twice is the shape this repo
133
+ * has been bitten by — dev and serve agreeing on a field while disagreeing on
134
+ * what it contains. Both call this.
135
+ */
136
+ export declare const sessionExpiryFromHeaders: (headers: Record<string, string | undefined>) => number | undefined;
137
+
111
138
  export declare type SessionPayload = typeof SessionPayload_2.Type;
112
139
 
113
140
  declare const SessionPayload_2: Schema.Struct<{
package/dist/session.js CHANGED
@@ -106,6 +106,11 @@ var i = t.Struct({
106
106
  e >= 0 && r.splice(e, 1);
107
107
  }
108
108
  return r.push(`SameSite=${n.sameSite ?? "Lax"}`), (n.secure ?? !0) && r.push("Secure"), n.domain && r.push(`Domain=${n.domain}`), r.join("; ");
109
+ }, A = process.env.VOLTRO_SESSION_COOKIE ?? "voltro:session", j = (e) => {
110
+ let t = O(e.cookie ?? e.Cookie, A);
111
+ if (!t) return;
112
+ let n = d();
113
+ if (n) return T(t, n)?.exp;
109
114
  };
110
115
  //#endregion
111
- export { o as DEFAULT_PREVIOUS_SESSION_KID, a as DEFAULT_SESSION_KID, f as MIN_SESSION_SECRET_LENGTH, p as assertProductionSessionSecret, k as buildSetCookie, D as checkBearer, O as readCookie, d as resolveOptionalSessionSecrets, c as resolveSessionSecret, l as resolveSessionSecrets, S as signSession, E as timingSafeStringEqual, w as verifySession, T as verifySessionKeyed };
116
+ export { o as DEFAULT_PREVIOUS_SESSION_KID, a as DEFAULT_SESSION_KID, f as MIN_SESSION_SECRET_LENGTH, A as SESSION_COOKIE_NAME, p as assertProductionSessionSecret, k as buildSetCookie, D as checkBearer, O as readCookie, d as resolveOptionalSessionSecrets, c as resolveSessionSecret, l as resolveSessionSecrets, j as sessionExpiryFromHeaders, S as signSession, E as timingSafeStringEqual, w as verifySession, T as verifySessionKeyed };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/protocol",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "The Voltro wire + plugin contract — defineQuery/Mutation/Action/Stream, definePlugin, sessions / JWT / API-keys, and the RPC protocol.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@effect/sql": "^0.52.0",
56
- "@voltro/database": "0.25.0",
56
+ "@voltro/database": "0.26.0",
57
57
  "jose": "^6.2.4"
58
58
  },
59
59
  "peerDependencies": {