@voltro/plugin-auth-auth0 0.34.0 → 0.36.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 +235 -0
  2. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -39,6 +39,241 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.36.0] — 2026-08-13
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/integration-http, @voltro/plugin-atlassian** — A 401 from an upstream now produces `code: 'unauthorized'`, not `code: 'session_expired'`. The connection vault's own failure — where we DO know the credential is unusable — becomes `code: 'credential_unusable'`.
47
+
48
+ `session_expired` asserted a cause the status cannot support. A 401 says the credential was not accepted and says nothing about why: expired, revoked, insufficient scope and MALFORMED all produce it. A consumer's plugin sent ciphertext as a bearer token (a separate defect, fixed in the same release), the upstream answered 401, this name called it an expired session, and their health check acted on the name and deleted a valid session. Login loop, with every symptom pointing at a revoked credential.
49
+
50
+ Names get acted on, which is the whole reason to split them:
51
+
52
+ - `'unauthorized'` — the upstream refused. Non-transient, so still never retried; `status` rides along so a caller that knows more about its own upstream can decide for itself. Deciding for them is what this gives up. - `'credential_unusable'` — the connection vault could not produce a credential (no grant, revoked grant, refresh failed). Here the claim is ours to make, because the failure is ours rather than the far end's.
53
+
54
+ The 401 message stopped saying "session expired" too. It now says the credential was refused and that the reason is not in the response — which is the honest sentence and the one that would have saved the day this cost.
55
+
56
+ Its test asserts the CLAIM rather than banning the word: the first version forbade `/expired/i` and went red against the corrected message, which lists expiry as one of several things a 401 can mean. That distinction is the point of the change, so the assertion had to be about `session expired` specifically.
57
+
58
+ **`voltro update` carries you across this** — codemod `0.35.1/01_unauthorized-replaces-session-expired`.
59
+
60
+ ### Added
61
+
62
+ - **@voltro/web** — **`apiSurface: compatible` — why the three altered golden lines cannot break a caller.** `LoaderContext` and `LoaderFn` each gained a type parameter WITH a default, so an unparameterised reference still resolves. The one that needed proving is `query?`, which went from a written-out signature to `LoaderQuery<Procedures>` — and `LoaderQuery` is a conditional whose false branch is character-for-character the previous signature. `unknown` does not extend `ProcedureTypeMap`, so the defaulted instantiation takes that branch.
63
+
64
+ Proved with `tsc` rather than by reading it: a probe asserting mutual assignability between `LoaderQuery<unknown>` and the old signature compiles, and inverting the probe fails — with tsc printing the resolved type as `<T = unknown>(tag: string, input?: Record<string, unknown> | undefined) => Promise<T>`, which is the old signature verbatim.
65
+
66
+ `LoaderContext` takes the app's procedure map, so a loader's `query` infers its input and output from the descriptor instead of returning `unknown`.
67
+
68
+ ```ts
69
+ import type { AppProcedures } from '<your-api>/rpcGroup'
70
+
71
+ export const loader = async ({ query }: LoaderContext<AppProcedures>) => {
72
+ const rows = await query?.('bookmarks.list', { limit: 100 })
73
+ // ^ inferred; an unknown tag or a wrong input shape is a compile error
74
+ }
75
+ ```
76
+
77
+ `AppProcedures` is generated already and has been for a while — it was wired to `createHooks` on the CLIENT and to nothing on the server, so every loader call site spelled its own output type by hand and a typo in a tag compiled. A consumer reported it twice.
78
+
79
+ The extraction reuses `ProcedureInput` / `ProcedureOutput` from `@voltro/client` rather than re-deriving them: a second answer to "what does this tag return" drifts the first time a descriptor field is renamed, and both answers look right in isolation.
80
+
81
+ Opt-in, and non-breaking: with no map named, the signature is the previous `<T = unknown>(tag: string, …)`. The framework cannot import an app's generated file, which is the same reason `createHooks<AppProcedures>` takes it explicitly.
82
+
83
+ Covered by a `.test-d.ts`, because the failure mode is "it compiles when it should not" and no runtime assertion can observe that. Two of its cases exist because the first version was vacuous: an `interface` fixture does not satisfy the map constraint (no implicit index signature — the codegen emits an alias for exactly this reason), so the typed branch fell back silently and every `@ts-expect-error` came back unused.
84
+
85
+ ### Fixed
86
+
87
+ - **@voltro/cli** — The store a plugin receives through `bindDataStore` now carries the storage codec, so an `.encrypted()` column read through it decrypts.
88
+
89
+ A consumer measured both stores inside one request: `ctx.store` gave a 44-character plaintext PAT, and the store their plugin's `credentialsResolver` received gave 113 characters of `enc:v1:…`. Ciphertext is a syntactically valid bearer token, so nothing threw. Jira answered 401, `@voltro/integration-http` named that `session_expired`, their PAT health check did the reasonable thing with that name and deleted the session, and the user got login → dashboard → login forever. A configuration error in the costume of an authentication refusal, where every symptom pointed at the one explanation that was wrong.
90
+
91
+ The part worth recording is that `bootStoreCodec.ts` was written for exactly this, after it happened at two other seams, and its header predicts this consumer's symptom verbatim: "a route reading an `.encrypted()` column got the literal string `enc:v1:…` back … the failure reads as 'wrong credential'". The fix was applied per-seam. `bindDataStore` was not one of the seams anybody listed, so it happened a third time — and a per-seam test stayed green throughout, because it covered the two seams somebody remembered.
92
+
93
+ `bootStoreHandouts.test.ts` asserts the rule instead: no boot path hands a plugin the raw driver, on either boot path, with the wrapper applied before the handout. The codec needs no Subject — it is how a column is spelled on disk versus in JS — so there was never anything a boot-level store could not carry.
94
+
95
+ Also relevant to anyone who followed the 0.28.0 codemod: that codemod told apps to stop carrying a credential on the Subject and look it up in the resolver instead. Doing exactly that is what put an app on this seam, so the instruction and `.encrypted()` were not simultaneously satisfiable through it.
96
+ - **@voltro/runtime, @voltro/cli** — The rpc/WebSocket query and stream arms now resolve row visibility before the executor sees a context. Fixes a 0.35.0 regression that made every read throw for an app with a registered row filter, and the older leak underneath it.
97
+
98
+ 0.35.0 shipped two things for the row filter: the registration moved to `globalThis` (so a duplicate `@voltro/runtime` instance cannot hide it), and a scoped store built without a resolved scope started throwing instead of silently serving unfiltered rows. The first was a real fix for a real hazard. The second was correct in principle and immediately fatal in practice, because the framework itself had a path that did exactly what it now refuses.
99
+
100
+ The consumer who reported the original leak ran the two-line check we asked for and `getRowFilter()` was visible from their request path — so the instance split was NOT their cause, and our hypothesis was wrong. Their measurement is what found the real one: the refusal fired, meaning the registration was FOUND and `ctx.rowFilter` was still undefined at the store. Nothing was missing; a step was.
101
+
102
+ Four arms reach a request context. `makeOneShotQueryRunner` (REST) and `makeQuerySubscriber` (SSE) both `await withRowFilter(...)` and say so in a comment. The rpc query handler and the stream handler — each hand-copied into both boot paths — handed the raw request straight through. So a user's executor received a context whose `ctx.store` applied no row filter, on the two arms that carry the most traffic. It survived because subscriptions are refiltered per DELIVERY, which made a descriptor-returning query look correct end to end while the executor's own reads were not.
103
+
104
+ `withScopedRequest` is the seam that fixes it once: a request that already carries a scope passes through untouched (resolving twice would run the app's `load` twice per request), an app with NO filter stays fully synchronous, and an app with one gets an Effect — which every one of these call sites already accepts. A boot-path parity test pins both stream arms and the shared producer.
105
+
106
+ The refusal also stopped firing for a SYSTEM subject. That is not a softening: `resolveRowFilterScopeFor` returns `NO_ROW_FILTER` for a system subject, so the only correct value was already determined, and several legitimate paths (schedules, resumed workflows, the webhook trigger context) build a context directly with no scope. Demanding a decision there is what took the api down.
107
+
108
+ ---
109
+
110
+ ## [0.35.0] — 2026-08-13
111
+
112
+ ### ⚠ BREAKING
113
+
114
+ - **@voltro/runtime, @voltro/cli, @voltro/plugin-clickhouse, @voltro/plugin-duckdb, @voltro/plugin-analytics-postgres** — The analytics CDC-mirror's version is derived from the CHANGE under `changeScope: 'fleet'` (postgres CDC, mysql binlog) — warehouse baseline plus the change's per-key position in the totally-ordered fleet stream — instead of each replica's own clock. An N-replica deployment still issues N duplicate writes per change (every replica observes the whole stream; that is the transport), but they are now BYTE-IDENTICAL — same row image, same version — so the sinks' existing guards (ClickHouse `ReplacingMergeTree(version)`, DuckDB/postgres `excluded.version > version`) dedupe them for free, with no leader election and no clock anywhere. This closes the real defect behind the N× cost: under clock skew larger than the gap between two changes to one row, a peer's duplicate of the OLDER image could take the higher version and win in the warehouse permanently and silently. A replica joining mid-stream seeds each key's numbering from the warehouse's own high-water mark via the new REQUIRED `AnalyticsMirrorImpl.maxVersion` read (all shipped warehouse sinks implement it; tombstoned deletes keep their version so the read answers after a delete — a custom sink follows the codemod note). The once-per-boot `changeScope=fleet` warning that named this cost is REMOVED — the hazard it named is gone. Local-scope stores keep the hybrid-clock version unchanged. New tunable: `VOLTRO_ANALYTICS_MIRROR_VERSION_STATE_LIMIT` bounds the per-key version state (default 100000; least-recently-changed keys re-seed from the warehouse on their next change).
115
+ - **@voltro/cli** — The declared framework table set no longer reads a runtime flag. `CDC`, `VOLTRO_UNDO` and `VOLTRO_TRACING_PERSIST` each moved it before this release; `app.config.ts` gained `schema: { traces?, undo? }` to declare the two that still need a decision.
116
+
117
+ A consumer measured two fingerprints from one source tree, one database and one `NODE_ENV`, differing only in `CDC`. Their Helm chart gives the pre-upgrade migrate Job its own `env:` list — `NODE_ENV`, `DB_*`, the obvious migration inputs — while `CDC: "0"` lives in the pods' block, because change data capture is obviously a runtime concern. Nothing about the name reads as schema-affecting, so it was in none of their three overlays' jobs. The declared set is what the schema fingerprint hashes, so that is a GREEN migrate job followed by every pod refusing to boot. Latent for months, and it would have fired on their next deploy.
118
+
119
+ Counting the family after their report found three, not one — measured on a mariadb app at `NODE_ENV=production`, each flag flipped alone: `CDC=0` removed `_voltro_cdc_offsets`, `VOLTRO_UNDO=on` added `_voltro_undo_log`, `VOLTRO_TRACING_PERSIST=all` added `_voltro_traces`. All three are the kind of value an operator puts on the pods and not on the job, and only one of them had been noticed.
120
+
121
+ `NODE_ENV` had already produced this exact failure in 0.34.0 and was fixed by making one decider resolve it for every command. That fix does not generalise here: a job legitimately does not carry an observability flag, so there is nothing to agree on. The rule is therefore stated rather than patched — **the declared set may depend only on inputs every process in one deployment computes identically** (the source tree, `app.config.ts`, the dialect, and `NODE_ENV`), and `declaredSchemaGates.test.ts` sweeps every `VOLTRO_*` / `CDC` / `DB_*` name the framework reads anywhere in `packages/*/src` and fails if any of them moves the set. Derived rather than listed, because a test naming the three known offenders only re-checks what someone already remembered — which is how the two unreported ones survived.
122
+
123
+ The three got two different answers, deliberately. `CDC` left the derivation entirely: `_voltro_cdc_offsets` follows the DIALECT now, so a mariadb or mssql app declares it whether or not that process drives CDC. The cost is one empty offsets table and it is the same trade `impliesScheduleTables` already makes in writing. `VOLTRO_UNDO` and `VOLTRO_TRACING_PERSIST` could not simply be dropped — both can legitimately turn a table on in production, and a declared set that ignored them would leave capture writing to a table nobody created — so they keep their runtime meaning and lose their declaring power. Turning capture OFF still needs no declaration and never will; turning it ON without one is refused at boot, on both boot paths, with the config field named.
124
+
125
+ The `prod-mismatch` refusal also stopped being two hashes and a command. It prints which of the three decided tables THIS process declared and from which input, because the ledger stores no table set to diff against and the command it used to recommend was the one the operator had just run successfully. That half matters beyond the framework's own tables: a plugin's `extendSchema.tables` is app code and can read anything, so the rule above cannot be enforced for it.
126
+
127
+ **`voltro update` carries you across this** — codemod `0.35.0/05_declared-schema-drops-runtime-flags`.
128
+ - **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/devtools-ui** — A declared event must decide who may listen — the boot gate now covers `defineEvent`, closing SEC-1's sibling. `defineEvent`'s `guards:` was optional and `bindEvent` skipped an empty list, so under `security.defaultDeny` an event with NO access declaration was silently subscribable by anyone who could open the socket, while the identical shape was already refused for every procedure.
129
+
130
+ `defineEvent` now accepts `openAccess: '<reason>'` — mutually exclusive with `guards:`, reason string required — exactly as the four procedure definers do. The erased `{ open }` decision rides the same `guards` array every enforcement path reads; `bindEvent` treats it as "no check" (an open event pays what an unguarded one pays: nothing), and `eventToRpc` no longer unions `ScopeError` into the wire contract for an event that cannot produce a denial.
131
+
132
+ **Breaking for `security.defaultDeny` apps (the default):** an app with a `*.event.ts` declaring neither `guards:` nor `openAccess:` now refuses to boot under `voltro dev` and `voltro serve`, naming every undecided event — the same message, from the same gate, procedures get. `voltro doctor` lists the same set. Migration: give each event a decision (`guards: [{ scope: '…' }]` or `openAccess: '<why anyone may listen>'`); an app that wants the old default-allow declares `security: { defaultDeny: false }` once, in `app.config.ts`. Plugin-declared events are not judged — the gate reads the app's own discovered files only.
133
+
134
+ The events inspect snapshot (and the devtools Events panel) now counts only ENFORCEABLE guards and carries the `openAccess` reason, so a deliberately open event renders as "open access" instead of as "1 guard" over an event anyone may subscribe to.
135
+
136
+ **`voltro update` carries you across this** — codemod `0.35.0/01_event-access-decision`.
137
+ - **@voltro/plugin-ai-flows** — **The breaking half, first:** `RunStepStatus` gained `'skipped'`. A sixth member means an exhaustive switch over it stops compiling and a status-keyed lookup has a hole — so a manual codemod fires on any app that names the type or its literals. Everything else here is additive (optional fields, new exports, one nullable column that rides the declarative differ).
138
+
139
+ Flows can now BRANCH, FAN OUT, and no longer chain without a bound. Three additions, and the third is a defect fix wearing a feature's clothes.
140
+
141
+ **`when:` — a conditional step.** A step runs only if its condition holds against the run context; a false condition SKIPS the step rather than failing it, so it produces no output and anything referencing it sees an absent value. The run timeline carries the rendered reason (`{{mode}} equals "full"`), because a step that silently vanished is indistinguishable from a step nobody declared.
142
+
143
+ The condition is STRUCTURED data (`{ ref, op, value }`), not an expression string, and that is three decisions in one. A flow can be authored as a stored row a user edits in a browser — an expression there is an evaluator running user-authored source on the server. The visual editor can offer a dropdown over a structure and cannot over a string it would have to parse. And `validateFlow` already walks every reference, so a structured `ref` joins that check for free: a typo'd condition would otherwise evaluate absent, take the false branch, and skip its step on every run, forever, with nothing logged.
144
+
145
+ Truthiness here deliberately differs from JavaScript's: `0` and `''` are TRUTHY. A step gated on a generated count or string means "did the producer run", not "is it non-zero" — the second is `{ op: 'neq', value: 0 }`, sayable when meant.
146
+
147
+ **`group:` — concurrent steps.** Consecutive steps sharing a group name run at the same time, each keeping its own durable step, so a replay resolves every branch from the journal exactly as it would sequentially. Kept as a flat field rather than a nested `parallel([...])` because the durable step name, the run timeline and a `human` step's signal name are all INDEX-keyed — nesting would re-index every flow already running.
148
+
149
+ Three rules, all enforced at registration: grouped steps cannot read each other's outputs (they have no order between them), a group must be contiguous (a name that stops and resumes would run as two sequential fan-outs), and a `human` review cannot join a group (it suspends the whole run). Context writes are applied after the whole segment in AUTHORED order — applying them as branches land would make the run context depend on scheduling, which a durable replay must never do.
150
+
151
+ **A chain is bounded — this half is a fix.** `chainTo` carried exactly one guard, a flow could not chain to itself, so `A → B → A` and any deep chain were unbounded: each hop starts a child run with a fresh idempotency key, so nothing collapsed it and nothing was counting the hops. A run now carries the chain that led to it ON THE PAYLOAD — deliberately not reconstructed from the run rows, because a guard whose evidence comes from a query is a guard that permits the loop whenever the query fails. A chain is refused on a cycle, or at `maxChainDepth` (default 5; `aiFlowsPlugin({ maxChainDepth })` or `VOLTRO_AI_FLOW_MAX_CHAIN_DEPTH`), and the refusal lands on the run row's new `chainRefusal` column naming the path. The parent run still SUCCEEDS: a refused follow-up is a configuration problem, not a reason to destroy a completed result.
152
+
153
+ All three are driven through the REAL durable executor in tests, not just their pure helpers — a primitive that is correct and reaches nothing is the defect class this package's own segmentation module exists to prevent.
154
+ - **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/ai, @voltro/plugin-billing, @voltro/plugin-flags, @voltro/plugin-governance, @voltro/plugin-notifications, @voltro/plugin-presence, @voltro/plugin-storage** — Every first-party plugin rpc route now declares an access decision (`guards:` or `openAccess: '<reason>'`), and `security.defaultDeny` is enforced in the DISPATCH spine as defense in depth behind the boot gate: a descriptor that reaches the wire with no decision (a third-party plugin route, an embedder's hand-bound descriptor) is refused per-request with a typed `ScopeError` before the transaction / external I/O. Twelve previously-open routes now require a scope: `billing.startCheckout` / `portalUrl` / `previewChange` / `changePlan` / `changeSeats` / `invoices` → `billing:manage`; `billing.reportUsage` → `billing:report`; `governance.export` / `erase` → `admin:full` (already enforced in-handler, now declared); `storage.mintUploadUrl` / `ingestUrl` → `storage:manage`; `storage.listRefs` → `storage:browse`. Migration: grant the scope to the role/subjects that legitimately hold each capability (rbac role, `resolveScopes`, api-key scopes) — the codemod lists every route and the open-by-design surfaces that did NOT change. `PluginRpcRoute` gains `guards`/`openAccess` fields, carried through the route lift into the enforced descriptor; the synthesized agent/undo/connections built-ins declare `openAccess` so they keep serving under default-deny.
155
+
156
+ ### Added
157
+
158
+ - **@voltro/workflow, @voltro/cli** — `awaitSignal` now logs a one-time hint (once per workflow, never per poll) when its declared `timeoutMs` exceeds a threshold, naming `awaitSignalSuspending` — the drop-in variant that SUSPENDS the run and frees the worker slot for human-approval-length waits (WF-11). Threshold: `workflows: { suspendSignalHintMs }` in `app.config.ts` (default 5 minutes), env override `VOLTRO_WORKFLOW_SUSPEND_HINT_MS`. A hint only — the framework never swaps the variant under a run, because the two journal differently and a silent swap mid-history is a replay trap.
159
+ - **@voltro/data-transfer, @voltro/sql-postgres, @voltro/cli** — The logical importer bulk-loads postgres targets via `COPY … FROM STDIN` (PERF-13). `voltro data import` engages it automatically wherever plain-INSERT semantics provably hold — `--mode replace`, or the default `upsert` into a table that is empty at import time (the fresh-target shape of every cross-dialect migration) — and never under `--atomic`. A refused COPY batch is atomic (nothing landed), so the importer replays exactly that batch through the per-row path with held-row / deferred-FK semantics intact. MEASURED on a 7-column table (text/int/bool/jsonb/timestamptz), 50 000 rows, local postgres: row-by-row 12.8 s (~3.9 k rows/s) vs COPY 0.59 s (~84.6 k rows/s) — **21.7×**. New seams: `ImportOptions.copyLoader` / `copyBatchSize` (default 5000) in `@voltro/data-transfer`, and `makePgCopySession` / `encodeCopyRow` in `@voltro/sql-postgres` (a submittable CopyIn query over the existing `pg` driver — no new dependency). Other dialects keep the per-row writes.
160
+ - **@voltro/cli** — `voltro probe access` asks a RUNNING app whether its declared access is actually enforced — the question none of the existing checks ask.
161
+
162
+ `voltro check`, the boot access gate and `security.defaultDeny` all verify that a decision was DECLARED. None of them verifies that the declaration REFUSES anyone. That distinction is not hypothetical here: the dispatch spine and the boot gate were separate for several releases, a procedure filtered out of the rpc group while still bound in the handler map served silently on one path and crashed the other, and `check` itself counted a decided-open route as guarded. Every one was the declaration and the behaviour disagreeing, found by reading rather than by asking.
163
+
164
+ It calls every guarded procedure with NO credentials and reports three verdicts: `refused` (enforcement works), `admitted` (the finding), and `inconclusive` — the call failed for a reason that is not an access refusal, usually payload validation running before the guard. `inconclusive` is never counted as a pass; `--strict` fails on it, which is what CI wants.
165
+
166
+ It probes ANONYMOUSLY on purpose. That is strictly weaker than scope-by-scope differentiation and strictly safer: the alternative puts credential minting into a command that can be pointed at production. Procedures declared `openAccess:` are skipped — probing them would report every deliberately-public route as a finding and bury the real ones, which is the same signal-to-noise failure `kind: 'open'` was added to the wire to fix.
167
+
168
+ `fetchJson` gained an explicit `anonymous` option for this one caller; it is an opt-out, never a default, and both directions are pinned by a test — a bearer attached here would make every result meaningless while still printing green.
169
+
170
+ **Validated against a live app, and it took two corrections to get there.** The first version sent a readable request id, which the transport converts with `BigInt(id)` — so every probe came back as a Defect before any guard ran, and every app looked broken. The second read a top-level `_tag` off an object while `POST /rpc` answers an ARRAY of envelopes, so a correctly-refused call scored as `admitted`. Both versions had a green unit suite, because the fixtures asserted the shape the code assumed. The fixtures are now copied from a real transcript.
171
+ - **@voltro/cli, @voltro/runtime** — `app.config.ts` gained `reactive: { deliveryConcurrency, rawReadTrackingLimit }` — the delivery-loop tunables were env-only, which left a number the framework picks on the project's behalf undeclarable in the one file that carries every other tunable.
172
+
173
+ Resolution stays inside the Dispatcher constructor (`resolveReactiveConfig`), so neither boot path can drift, and the env vars still win over the declared value: an operator acting on a running deployment outranks the project file. The threading itself is source-pinned across all three files (`dev.ts`, `serveCommand.ts`, `serveApi.ts`) because the serve side is a two-file relay and the union is where an option goes missing invisibly.
174
+ - **@voltro/cli, @voltro/database** — Data residency is DECLARABLE and wired. `tenancy.residency` in `app.config.ts` opens one store per servable region on both boot paths and routes every request to its tenant's home region — or refuses it.
175
+
176
+ The primitives have existed for two rounds (`setResidencyConfig`, `residentPlacement`, `bindResidentStore`), exported and tested, with **zero callers**. A user could reach them, but nothing in the framework did: there was no way to declare residency and no request ever consulted it. That gap was pinned by a test walking every workspace source, which went red on this change and asked for the module header to be corrected — it now names its consumers instead of asserting it has none, so a SECOND unreviewed caller still fails.
177
+
178
+ ```ts
179
+ tenancy: {
180
+ isolation: 'namespace',
181
+ residency: {
182
+ servableRegions: ['eu-west'],
183
+ regionUrlEnv: { 'eu-west': 'DB_URL_EU', 'us-east': 'DB_URL_US' },
184
+ homes: [{ tenantId: 'acme', region: 'eu-west' }],
185
+ },
186
+ }
187
+ ```
188
+
189
+ `regionUrlEnv` names an env VAR, not a URL — a connection string is a secret and `app.config.ts` is committed. Everything else about a region's store (pool bounds, TLS, `search_path`, timeouts) is inherited from the primary connection, so a region cannot silently run with different limits than its deployment.
190
+
191
+ **Every failure is a refusal, never a fallback**, because a residency system that degrades to a default store violates residency at exactly the moment something is misconfigured. Unresolvable tenant, unmapped home, or a home region this deployment does not serve are all typed refusals; the last one names the region so a gateway can route it.
192
+
193
+ Four declarations are refused at BOOT rather than warned about: residency without `isolation: 'namespace'` (the region keeps regions apart, the namespace keeps tenants apart — one without the other is not isolation), a servable region with no env-var name, one whose env var is unset, and a tenant mapped to two regions.
194
+
195
+ Two boundaries worth knowing:
196
+
197
+ - `ctx.storeForTenant(id)` resolves residency for THAT tenant, not the caller's, so a handler acting on another tenant reaches that tenant's region or is refused. Background work (schedules, workflows) runs with no tenant and must use it — `ctx.store` there is the primary store. - A transaction is never re-routed. A caller-supplied store is used as given; it already went through residency to exist, and moving writes off the connection holding the lock is a worse failure than the one residency prevents.
198
+
199
+ Homes resolve once at boot (an array, or a function reading your own table), so adding a tenant home needs a restart — chosen over a cache with a staleness window on a decision whose whole value is that it is never wrong.
200
+ - **@voltro/runtime, @voltro/cli** — `voltro schedule backfill <name> --from <iso> --to <iso> [--yes] [--limit N]` and `POST /_voltro/inspect/schedules/:name/backfill` (WF-14) — fire every cron occurrence of a schedule over an explicit range, sequentially, each recorded against its own cron-derived `scheduledAt` with `trigger: 'manual'`. Fills the gap boot backfill (walks from the last recorded run only) and cluster-cron catch-up (capped at one day) leave open. Bounded and confirmable: above 25 occurrences it refuses without `--yes` (printing the count), above the per-request cap (default 1 000, `--limit` up to a hard ceiling of 10 000) it refuses outright, firing nothing — never a silent prefix. Wired on both boot paths through one shared hook.
201
+ - **@voltro/devtools-ui, @voltro/plugin-search** — The Search dashboard panel now RENDERS the drift surface REL-1 shipped server-side and no dashboard showed (the additive-JSON silent-drift shape the 4-layer rule exists for): per-index `dropped` / `pendingDrift` / `drifted` / last-drift badges, the repair queue itself (`GET /drift` — oldest first, with attempt counts and the engine's last error), and a **Resync now** action (`POST /resync`) gated on the new `canResyncSearch` capability (its own flag — a resync re-reads only the drifted rows; a reindex re-reads the whole table). Landed across all four layers in one change set: shared `SearchPage` + wire types + capability + EN/DE strings here; HTTP fetchers + page wiring in voltro-devtools; tenant-scoped `apps.inspectSearchDrift` / `apps.inspectSearchResync` proxies + hooks + page wiring in voltro-cloud (the indexes proxy schema carries the new fields as OPTIONAL, so a customer app from before the drift ledger still decodes). `search.query` also now carries an explicit access decision (`openAccess`, with the tenant-scoping rationale in source) instead of the undecided SEC-1 shape.
202
+ - **@voltro/cli** — Workflow wakes over the change stream (WF-8): on a fleet where remote changes reach the change spine (Postgres LISTEN/NOTIFY CDC — the common broker-less multi-replica deployment), a remote replica's `signal-sent` event, start context, or run transition now triggers an immediate, coalesced `pollStorage` on every replica, so cross-replica signal/step latency stops being bounded by the 10 s storage poll. Honest subset by design: the cluster engine has no per-run wake seam, so the change event wakes the poll early rather than replacing it — the poll tick stays the safety net. Local-origin changes never wake (a replica waking on its own recorder rows would be a poll storm). Wired by the same `makeWorkflowWake` builder on both boot paths.
203
+ - **@voltro/runtime, @voltro/workflow, @voltro/cli** — `ctx.workflows.start(name, payload, { at: Date })` — delayed one-off starts (WF-13). The start is parked as a durable `_voltro_workflow_pending` row (`mode: 'delayed'`) and fired by the coordinated drainer when `at` arrives, so it survives restarts and fires on whichever replica drains. At `at` it becomes an ordinary ARRIVAL: declared flow control (debounce, singleton, rateLimit, …) judges it as of that moment — `at` never bypasses a control. The handle reports `status: 'queued'` with `deferral: { mode: 'delayed', dueAt }`. An `at` in the past starts immediately; `{ at, wait: true }` is refused. `at` is an absolute instant by design (no `delay` spelling): a delay is ambiguous about its epoch and every queue system answers it differently, while an instant composes with the schedule/backfill surfaces.
204
+ - **@voltro/workflow, @voltro/cli** — `workflows: { recording: 'coarse' }` in `app.config.ts` (env override `VOLTRO_WORKFLOW_RECORDING`) — turns off the two fire-and-forget per-step writes to `_voltro_workflow_run_steps` (WF-10) for hot high-step workflows. Run rows, run events (signals/timers/cancels/stall reports) and the cluster engine's durable journal are unaffected — replay and redrive work exactly as before; the cost is an empty step timeline for runs recorded under coarse. Measured before it was built (`packages/cli/scripts/admission-throughput.mjs`): the recorder costs exactly 2 store writes per step, off the step's critical path — which is why the knob is a skip, not a batcher.
205
+ - **@voltro/workflow, @voltro/cli** — `workflow({ schedule })` — the workflow-side cron declaration (WF-12), with Temporal Schedules' overlap vocabulary about the RUN: `onOverlap: 'skip' | 'buffer' | 'cancelOther'`. Pure sugar over the shipped scheduler: at boot it lowers into a real schedule named `workflow:<name>` (same coordinated claims, run rows, Schedules panel, `voltro schedule` verbs). The synthesised firing awaits the workflow run to completion, which is what makes skip/buffer bind on the run's duration; `cancelOther` cancels only the still-running run this schedule itself started. The firing watchdog (`schedule.maxRuntime`) defaults to 24 h here. Cron and timezone are validated at definition time.
206
+
207
+ ### Changed
208
+
209
+ - **@voltro/plugin-clickhouse** — `clickhouseAnalytics` now BATCHES `track()` inserts by default (PERF-11) — 20 events / 5 s, plugin-posthog's conservative numbers — instead of one HTTP insert (and one MergeTree part) per event. What changes observably for an app that never set `batch`: a successful `track()` now means "buffered", not "ClickHouse accepted the row"; events become readable up to 5 s after they were tracked; a flush failure drops that batch with a warning (a hard crash loses whatever is still buffered — graceful shutdown drains via `dispose`). Opt OUT with `batch: false` to restore one immediate, confirmed insert per event; `batch: { maxSize, flushIntervalMs }` tunes the window. No compile break — `batch` widened to `ClickhouseBatchOptions | false`, and the previous opt-in spelling keeps working (its defaults are now 20/5000 rather than 1000/5000).
210
+ - **@voltro/database, @voltro/cli, @voltro/workflow** — The migration advisory lock is now scoped to the configured schema (`DB_SCHEMA`) instead of one framework-wide constant. A postgres advisory lock is database-scoped and MySQL `GET_LOCK` is server-wide, so two apps sharing one database in different schemas used to serialize each other's migrations and defer each other's boot-time trigger repair — with a log line blaming "another instance". Now: postgres derives a stable 64-bit key from the schema name (FNV-1a 64 of `voltro_migration_lock:<schema>`, sign bit cleared; collisions across schemas are possible and only reintroduce serialization, never a race); mysql/mariadb/mssql suffix the lock NAME with the schema (hashed past MySQL's 64-char `GET_LOCK` cap). Every taker moved together in this change — the declarative applier, the file-based runner, the boot auto-migrate, the CLI's reactive-trigger boot repair, and the workflow cluster first-boot gate (its own distinct key, same derivation). On mysql/mariadb, where `GET_LOCK` is server-wide and `DB_SCHEMA` is not a connection pin, setting `DB_SCHEMA` to your database name is how two apps on one server un-share the lock.
211
+
212
+ **Rolling-deploy story.** An app WITHOUT `DB_SCHEMA` (or with `DB_SCHEMA=public`) keeps the EXACT pre-change lock key and name — old and new replicas contend on the same lock throughout the rollout; nothing to do. An app WITH a non-default `DB_SCHEMA` changes its lock key when it lands this version: during that one rollout window, old-generation and new-generation replicas do not mutually exclude their DDL. The boot auto-migrate DDL is idempotent (`IF NOT EXISTS`-shaped), so the practical exposure is the known postgres `CREATE TABLE IF NOT EXISTS` catalog race — worst case one replica's boot fails and restarts. Avoid running `voltro db apply` concurrently with THAT rollout; after it, everything contends on the schema-scoped key.
213
+ - **@voltro/plugin-search** — `POST /reindex` now STREAMS the source table (keyset-paginated `streamTable`) and upserts one bounded page at a time instead of loading the whole table into memory — the old shape was an OOM on exactly the tables big enough to need a reindex (PERF-12). The page size is a new tunable, `searchPlugin({ sync: { reindexBatchSize } })` (default 1000), and the `/indexes` panel reports it as part of the policy in force. `backfillIndex` keeps its plain-array signature for small explicit seeds. Additive surface only: a new optional `SearchSyncOptions` knob + a new `SYNC_DEFAULTS` key — no existing call site changes meaning.
214
+ - **@voltro/plugin-search** — Sync-stat counters no longer pay a read+CAS against the OLTP primary on EVERY indexed-table write (PERF-14). Counts buffer in memory and flush per window — `searchPlugin({ sync: { statsFlushIntervalMs } })` (default 5000 ms; `0` restores the per-event durable write) with an early flush at `statsFlushMaxBuffered` (default 1000) pending counts. Mirrors the runtime's api-key usage buffer, SHUTDOWN included: plugin deactivate drains the tail on both boot paths, so a graceful deploy loses nothing; a hard crash loses at most the current window of counters (never a change — the drift ledger stays the durable record). `GET /indexes` drains the buffer before reading, so the panel stays truthful mid-window. `StatsStore` gained a delta-applying `add` (the flush target); both shipped impls carry it and nothing consumes user-provided `StatsStore` implementations.
215
+ - **@voltro/database, @voltro/plugin-webhooks, @voltro/testing, @voltro/voltro, @voltro/workflow** — Golden churn from this round's signature WIDENINGS, classified per package:
216
+
217
+ - **@voltro/database** — every migration entry point (`applySchema`, `runMigrate`, `runFrameworkBootstrap`, `applyNamespacedSchema`, `provisionTenantNamespace`, the lock functions) gained a trailing OPTIONAL parameter (`SchemaApplyOptions` / `MigrationLockScope`) for the schema-scoped lock and the dialect retry predicate. Every existing call compiles unchanged; omitting the parameter is exactly the old behavior. - **@voltro/workflow / @voltro/testing / @voltro/voltro** — the same widenings re-exported through the aggregates, plus `PresenceWrite`-adjacent type surface already classified in this release's presence entry. - **@voltro/plugin-webhooks** — `deliverWebhookWorkflow`'s payload type inference had COLLAPSED to `AnyStructSchema | Struct<Fields>`, which made `execute`'s requirements `any` for every consumer: there was no type contract in force to break, only one that silently did not exist. It now infers the real payload struct. The export's only callers are the framework's own boot paths (it exists for cluster-runner registration); an app that passed a wrong-shaped payload under `any` now gets the compile error it should always have had — which is the fix, not collateral.
218
+
219
+ ### Fixed
220
+
221
+ - **@voltro/cli, @voltro/protocol** — `voltro doctor`, the boot refusal, and `GuardSpec.resource`'s own doc comment now name the per-resource form of an access decision. All three listed two ways to decide and there are three.
222
+
223
+ A consumer with 565 undecided procedures set `security: { defaultDeny: false }` across their app, and their reasoning was correct at every step from what they were shown. Their authority is per-team — a viewer in one team, an admin in another — so a subject-global `guards: [{ scope }]` would state a check they do not perform, and the boot refusal warns against exactly that ("reaching for a scope every caller already holds satisfies the gate, reads as protection, and enforces nothing"). `openAccess:` would be untrue. Both offered forms were rightly rejected, so they turned the gate off and kept enforcing in handlers.
224
+
225
+ The form that fits them — `guards: [{ action, resourceType, resource }]`, backed by `defineResourcePolicy` and a tuple source registered over their own tables — has shipped for several releases, is wired on both boot paths, fails closed without a resolver, and is documented under Authentication → Authorization. They looked: they read `GuardSpec.resource`, whose doc comment described the resolver as "a future ReBAC / `accessPolicy()` resolver". That sentence was written before the ReBAC path shipped and never updated, and it is the only thing a reader of that type has. A doc comment that says "future" about something built is not a small inaccuracy — it argued a careful team out of a security gate.
226
+
227
+ An enumeration inside a refusal is read as exhaustive, and the more careful the reader, the more thoroughly they act on it. `accessDecisionForms.test.ts` pins all three forms in all three places, including the `defaultDeny: false` branch — an app that has already given up is precisely the audience that needs to learn there was a third option.
228
+ - **@voltro/protocol, @voltro/database, @voltro/runtime, @voltro/plugin-broadcast, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-sqlite, @voltro/sql-mssql** — A reactivity-channel publish now says where it came from — `origin: 'inline'`, because it happened in THIS process — instead of borrowing the `'injected'` stamp its transport seam applies by default. Two defects came out of that one mislabel, both silent:
229
+
230
+ - **A channel published synchronously from inside a change listener never left the replica.** `plugin-broadcast` suppresses re-publishes while it is injecting, and the bracket was coarse: it dropped EVERY emission made in that window, not just the event it had injected. So `onChange` → `publishReactivity` woke the local node and no peer ever heard it — no error, no log. The one plugin doing cross-replica fan-out (presence) escaped only because it re-publishes from its own transport callback. The guard now suppresses by provenance, so a local publish made inside the bracket travels like any other. - **A replica could not tell its own channel publish from a peer's.** Both arrived `'injected'`, so a listener fanning a channel onward had nothing to key on and needed a boolean per channel to avoid an echo. `origin` answers it now.
231
+
232
+ Two supporting changes, each with its own failure mode:
233
+
234
+ - `origin` no longer survives the wire. It describes how an event reached THIS process, so the receiving replica strips what the sender serialised and stamps its own. Without this the guard fails OPEN — measured, an arrival still claiming `'inline'` amplified one publish into 163 events and killed the test worker. - The transport-origin stamp has one definition (`externalChangeEvent`, `@voltro/database`) instead of five hand copies across the memory store and the four dialect stores. `injectOriginParity.test.ts` fails if any store grows its own again — a store that hand-stamps would override a caller's stated origin, and the visible result is a channel that stops crossing replicas on that dialect only.
235
+
236
+ `DataStore.injectExternalChange` keeps its shape: an event that states no origin is still stamped `'injected'`. A store passed to `plugin-broadcast` that does not stamp at all (the interface is structural) is detected by identity and falls back to the old coarse suppression rather than amplifying.
237
+ - **@voltro/cli, @voltro/devtools-ui** — `voltro check` no longer mistakes a deliberate `openAccess:` mutation for an unguarded one — and no longer mistakes it for a guarded one either. The manifest serialises the decision as a `kind: 'open'` guard entry carrying the reason string; `toInput` now translates it into `openAccess` on the graph procedure with `hasGuards: false` (nothing IS checked), and the `rbac/unguarded-mutation` rule skips a procedure whose author already "confirmed it is intentionally public" — the rule's own fix text. Previously the open entry was counted as a guard, so the finding disappeared for the wrong reason: the open mutation read as protected.
238
+
239
+ `@voltro/devtools-ui` gains the hand copy of the `SerialisedGuard` wire union (it is deliberately dependency-free, so it cannot import the CLI's), renders an access badge on the RPC page — guard count, or "open access" with the reason in the tooltip — and the copy is pinned from the owning side by `serialisedGuardParity.test.ts`, in the style of `migrationOpKindParity`.
240
+ - **@voltro/cli** — `ctx.query` in a loader rejects an `error` event instead of returning it as the query's rows.
241
+
242
+ A consumer put a guard with an unheld scope on a query and called it over HTTP with a valid user JWT. The batch came back 200 with an `error` chunk carrying a `ScopeError` and an `Exit: Success` after it. Both are correct — the transport worked and the guard worked — but `buildLoaderQuery` unwrapped exactly ONE member of the three-member `subscriptionEvent` union and passed the other two through as data. The loader returned the error OBJECT, it went into the SSR seed, and the component called `.map()` on it: `TypeError: kept.map is not a function`, on 135 pages, for every user.
243
+
244
+ The `.catch(() => null)` in our own documented loader pattern could not fire, because nothing was thrown. Neither could `seedPagePreloads`' `onError`, for the same reason. The same rejection over the live socket sets `error` and leaves data empty, so the two transports were saying different things about one event.
245
+
246
+ The unwrap is exhaustive now: `snapshot` yields its rows, `error` rejects with the typed error attached (as `cause` and `voltroError`, so an app can still branch on `ScopeError` rather than parse a string), and a first-event `delta` — structurally impossible today — throws naming itself a framework bug rather than handing a loader an id-keyed patch. `buildLoaderQuery` has one implementation shared by `voltro dev` and `voltro start`, so the fix covers the production path the report did not measure.
247
+
248
+ The HTTP 200 is unchanged, and the reporter is right that it should be: the batch transport did succeed.
249
+ - **@voltro/database, @voltro/cli** — The schema apply (`applySchema` / boot auto-migrate / framework bootstrap) now consults the dialect's own transient-failure predicate instead of dying on the first `SQLITE_BUSY`. The gap was located in `@voltro/sql-turso`: `busy_timeout` cannot retry the deferred-upgrade lock class, the store path already honoured the dialect's `retryFilter`, and the migration applier never consulted it — so a DDL statement that met the schema lock failed on the first attempt while every equivalent DML statement would have been retried. The CLI threads each loaded dialect's `retryFilter` through the new `SchemaApplyOptions`; retries are bounded (`VOLTRO_MIGRATION_DDL_RETRIES`, default 4, exponential backoff with jitter) and only ever re-run statements that are safe to re-run: per-statement on the per-operation dialects (sqlite/turso/mysql/mariadb, `IF NOT EXISTS`-shaped or covered by the duplicate-index tolerance), whole-transaction on postgres/mssql, whose retryable classes (deadlock victim, serialization failure) roll the transaction back cleanly. No `retryFilter` threaded means no retry — exactly the previous behavior. The declarative plan applier (`applyPlan`) is deliberately unchanged: its operations are not uniformly idempotent, and partial failure there is owned by the resume ledger.
250
+ - **@voltro/web, @voltro/client** — The web first load no longer ships `msgpackr` — 190.5 → 180.6 KB gz (−9.9 KB, 5.1% of the whole first load) for every app, measured on the zero-procedure fixture and re-pinned into `bundle-budget.json`.
251
+
252
+ It shipped because `@effect/rpc`'s RpcSerialization module top-level-imports msgpackr while every Voltro path selects `layerJson`, and msgpackr declares no `sideEffects` flag — so no bundler was allowed to drop it. The fix is a dependency patch adding `sideEffects: false`, declared at BOTH workspace roots (the @effect/cluster patch rule: a meta-root `pnpm install` must apply it too, or the two installs fight over node_modules). An app that genuinely calls `makeMsgPack` keeps the library — the flag only permits dropping it when unused.
253
+
254
+ The bundle-budget gate's slack floor is what keeps this fixed: the budget is re-pinned to the new number, so quietly re-inflating past it fails CI.
255
+ - **@voltro/cli, @voltro/database** — Under `tenantIsolation: 'namespace'`, a tenant's namespace is now PROVISIONED on its first use — schema DDL plus the `onTenantCreate` seed lifecycle, memoised per namespace per process.
256
+
257
+ Neither ever happened: `provisionTenantNamespace` — documented as "the entry point the CLI / runtime use, eager at migrate time or lazily on first use" — had zero callers in the entire codebase, `withNamespace` returns a pure view, and so a fresh tenant's first request died on "relation does not exist" while the `onTenantCreate` seeds (recorded as wired) fired only from tests that called the function directly. Found by the claimed-wirings checker the moment it learned to read active-voice claims.
258
+
259
+ Both boot paths build the provisioner from one builder; the namespace view's async methods await the memoised ensure (one resolved-promise await after the first settle), sync members stay sync, and the method classification is a DERIVED guard — a new `DataStore` method fails the test until someone decides whether it must await provisioning. A failed provision is surfaced and forgotten, never cached: one transient DDL failure must not become a permanently broken tenant on a replica. Verified end to end on live postgres, control included: the raw view still fails against a fresh namespace, the provisioned one creates the schema and fires the lifecycle exactly once.
260
+
261
+ Eager provisioning stays the app's move (call `provisionTenantNamespace` from a seed or startup over your own tenant table) — the framework has no tenant registry to enumerate, and the docs now say so instead of implying otherwise.
262
+ - **@voltro/runtime, @voltro/cli, @voltro/testing** — `setRowFilter`'s registration moved from a module-local variable to a `globalThis` cell, and a scoped store that receives no filter while one is registered now throws instead of serving unfiltered rows.
263
+
264
+ A consumer measured four read paths returning every row of the tenant to every employee — 19/19 contracts, 231/231 time-entry requests, 6/6 user settings, 4637/4637 shifts — on both HTTP and WebSocket, with `row filter registered` in the boot log and 27 green tests. One of those paths was the only thing keeping `userSettings.update` safe, so every user could edit every other user's settings.
265
+
266
+ Their exclusion work was exhaustive and right at every step: the load succeeded, `predicateFor` returned predicates for the right tables, nothing was `unconstrained`, no handler bypassed the store, and a `setRowFilter` + `makeTestContext` pair reproduced CORRECT filtering. That left them concluding the remaining variable was the store — memory in the green reproduction, postgres in the red deployment.
267
+
268
+ It is not the store, and the correction is the transferable part: their reproduction ran inside ONE module instance and production does not necessarily. `ROW_FILTER` was a module-local `let`, so an app's `*.startup.tsx` and the framework's serve pipeline write and read different variables whenever they hold different copies of `@voltro/runtime` — the serve bundle inlines the framework while app modules stay external, and strict pnpm resolves one version into two physical directories when two importers have different peer contexts. The pipeline reads `undefined`, correctly interprets it as "this app registered no filter", and serves everything.
269
+
270
+ `@voltro/database`'s `coreTablesRegistry` carries this exact fix with a comment describing this exact failure, for a value whose worst case is a crash at boot. This one's worst case is silent data exposure and it did not have it.
271
+
272
+ The second half answers the reporter's second ask directly: a filter that cannot be applied must fail loudly rather than pass quietly. `undefined` and "we could not tell" had collapsed into one value, and the doc comment on that option already asserted they must not. Every deliberately unfiltered path — system sweeps, `runAsSystem`, change-stream subscribers, the seeding store in a test — now passes `NO_ROW_FILTER` explicitly, because "this app has no filter" and "this path is unfiltered on purpose" are different claims and only the second is a decision somebody made.
273
+ - **@voltro/cli** — A bare `voltro serve` under docker compose now drains on SIGTERM — in-flight requests complete against a fully-alive app, the listener refuses new work, live WebSockets are ended cleanly, and the process exits on its own, well inside `VOLTRO_SHUTDOWN_GRACE_MS`. No preStop hook or endpoint removal required. Two real defects closed (both measured against a live server): a single connected WebSocket wedged `nodeServer.close()` — node's `closeAllConnections()`/`closeIdleConnections()` cannot end an upgraded socket while `close()` still waits on it — so EVERY shutdown with a connected web client ran to the 10s deadline cut and the steps queued behind the close (the store's connection-pool close included) silently never ran; and the shutdown hook deactivated plugins and drained the analytics mirror BEFORE the request drain, so a request finishing during shutdown hit dead services and its writes were never mirrored. The drain is bounded: in-flight requests get 60% of the shutdown grace (floor 500ms), stragglers are then destroyed, and idle keep-alive sockets are swept continuously so a finished response never delays exit. The stale `serveApi` comment claiming `NodeRuntime.runMain` owns SIGTERM (and pointing at a k8s preStop hook as the fix) is rewritten to describe the drain that actually runs. Verify against a real serve with `node scripts/serve-drain-check.mjs`.
274
+
275
+ ---
276
+
42
277
  ## [0.34.0] — 2026-08-12
43
278
 
44
279
  ### ⚠ BREAKING
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/plugin-auth-auth0",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "description": "Auth0-backed AuthStrategy for the Voltro framework. Verifies Auth0-issued JWTs via the tenant's JWKS endpoint. Conforms to @voltro/protocol AuthStrategy so it composes with other IdP plugins.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -33,7 +33,7 @@
33
33
  "node": ">=24.0.0"
34
34
  },
35
35
  "dependencies": {
36
- "@voltro/protocol": "0.34.0"
36
+ "@voltro/protocol": "0.36.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "effect": "^3.22.0"