@voltro/ui-shadcn 0.71.1 → 0.73.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,474 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.73.0] — 2026-09-17
43
+
44
+ ### Added
45
+
46
+ - **Typed writes on the Effect channel — the row check and StoreError compose** — `@voltro/runtime`
47
+
48
+ `insertRow`, `insertManyRows`, `upsertRow`, `upsertRowOutcome` and `insertIgnoreRowOutcome` check their payload against the table — a required column the row omits is a compile error at the call site — and return a `Promise`. `EffectStore` carries every one of those operations on the typed error channel and takes `(table, row)` with an unchecked `Row`.
49
+
50
+ So the two properties excluded each other. A handler that wanted the row checked gave up `StoreError`; one that wanted the channel passed an unchecked row. There was no third option: wrapping a typed helper in `Effect.promise` runs the write outside the Effect and discards exactly the channel at issue.
51
+
52
+ Five helpers close it, with the same signatures over a store whose methods return Effects:
53
+
54
+ ```ts
55
+ const store = yield* EffectStore
56
+ const saved = yield* insertRowEffect(store, players, { tenantId, name })
57
+ const { row, outcome } = yield* upsertRowOutcomeEffect(store, players, player, {
58
+ conflictColumns: ['tenantId', 'externalId'],
59
+ update: ['name', 'score'],
60
+ })
61
+ ```
62
+
63
+ `EffectStore` satisfies each helper's store parameter structurally, so it is the argument at every call site. The row type, the conflict-key constraint and the return type are spelled as the Promise forms spell them, so moving between the two families means reading one signature rather than two.
64
+
65
+ One deliberate difference: `upsertRowOutcome` throws when a hand-written `DataStore` lacks `upsertWithOutcome`. `EffectStore` always provides it, so the Effect form does not carry that failure — an error case for an unreachable condition is one every caller must handle and none can trigger.
66
+
67
+ ### Fixed
68
+
69
+ - **A module that re-exports the generated route builder is not a hand-roll** — `@voltro/cli`
70
+
71
+ `voltro doctor`'s `hand-route-module` rule read the importing file and asked whether it mentioned `.framework/routes.generated`. An app that keeps exactly one module importing the generated builder and re-exports it — so that a single file owns the dependency — therefore had every consumer of that alias reported, while using the typed builders the rule recommends.
72
+
73
+ The rule now resolves the imported module and stays silent when it re-exports the generated builder. A genuinely hand-maintained route or url module is reported as before.
74
+
75
+ An alias onto a generated artefact is the adoption of this rule, and a finding that fires on the adoption is an argument for deleting the seam that made it clean.
76
+ - **The migration codemod no longer re-indents the body it rewrites** — `@voltro/cli`
77
+
78
+ `0.72.0/01_migration-context-raw` rewrites two lines per migration and produced diffs of 85 to 133 changed lines. Two causes, both removed.
79
+
80
+ A migration whose body is an expression — `up: (ctx) => Effect.gen(function* () { … })`, the common shape — was hoisted into a block so the `raw(reason)` binding had somewhere to live, which indented the entire body one level. The binding is placed inside the generator instead, so the body keeps its position and the change is the two lines it actually is.
81
+
82
+ Separately, every node any codemod inserted was printed with four-space indentation while this framework's own templates use two. That is fixed for all codemods, not just this one.
83
+
84
+ Neither changes what the codemod means. It changes whether the diff can be read — which matters here more than most, because every `raw(reason)` it writes is a placeholder its author has to fill in before committing.
85
+ - **voltro serve refuses a serve bundle built by a different framework version** — `@voltro/cli`
86
+
87
+ `voltro serve` loads the precompiled serve bundle, and a bundle built before an upgrade imports perfectly — it simply runs the previous version's code. Nothing said so. Updating `@voltro/*` without re-running `voltro build` therefore left a process serving the old framework while the installed one had moved on, and the only way to notice was to recognise a log line's shape as belonging to the older release.
88
+
89
+ The bundle already carries the version that built it. `voltro serve` now reads it before importing and refuses when it disagrees with the installed CLI, naming both versions and the command that fixes it:
90
+
91
+ ```
92
+ FATAL: the serve bundle was built by @voltro/cli 0.71.1, but 0.72.0 is installed.
93
+ Serving it would run 0.71.1's code against 0.72.0's dependencies.
94
+ Rebuild it: voltro build .
95
+ ```
96
+
97
+ A mismatch refuses rather than falling back to the slower boot path: it is a build error of the deployment, and quietly running a different version is the failure being fixed. A bundle carrying no version marker is left alone — those predate the marker and are not evidence of anything.
98
+ - **A workflow step can reach the store under the test runner** — `@voltro/testing`
99
+
100
+ A workflow step may `yield* EffectStore`, and in production it gets one: the runtime provides the layer from the context's store. `makeWorkflowRunner` provided the step recorder and the workflow's own layer and nothing else, so the step died under test with
101
+
102
+ ```
103
+ Service not found: @voltro/EffectStore
104
+ ```
105
+
106
+ while the identical code ran in production. The only supported way to drive a workflow in a test could not run the form the framework documents, which left `Effect.promise` as the shape people shipped instead.
107
+
108
+ The runner now provides the same layer from the same place production does, from the context it already receives. A context without a store stays runnable, so a test that never touches the store does not acquire a requirement.
109
+ - **The AI usage receipt key no longer refuses to migrate an existing ledger** — `@voltro/ai`, `@voltro/cli`
110
+
111
+ `_voltro_ai_usage.receiptKey` arrived NOT NULL with neither a default nor a backfill, so adding it to a database that already holds usage rows was refused:
112
+
113
+ ```
114
+ auto-migrate: REFUSED — 1 blocked operation(s):
115
+ - add-column [_voltro_ai_usage]: NOT NULL column on a table whose row count is unknown
116
+ auto-migrate failed — aborting boot
117
+ ```
118
+
119
+ The refusal was correct and its advice was not reachable: it asks whoever owns the declaration to add `.backfill()` or `.default()`, and this table is the framework's, not the app's. `VOLTRO_AUTO_MIGRATE=0` left the app on the previous schema and `voltro db apply` refused the same way before a deploy, so an upgrade could not be rolled out at all.
120
+
121
+ The column now derives a per-row value from the row's own id. A literal default could not do this job — the column is UNIQUE, so one value for every existing row collides on the second — and the derivation is computed per row rather than in SQL because string concatenation has no dialect-neutral spelling and this table ships on five.
122
+
123
+ A guard now plans every framework table against a populated database and fails on any column added since the last release that an app could not migrate onto. Two earlier shapes of that check were built and discarded, each disproved by its own measurement: both asked every framework column to be addable, and a column created together with its table never is.
124
+
125
+ ---
126
+
127
+ ## [0.72.0] — 2026-09-16
128
+
129
+ ### ⚠ BREAKING
130
+
131
+ - **A failure report is written where the failure is decided, never by a callback** — `@voltro/workflow`, `@voltro/cli`, `@voltro/voltro`
132
+
133
+ The last three routes into `onFailure` that went through a process-local callback — an intent dead-lettered for a permanent start error or the attempt cap, and a run cancelled by `timeouts.finish` — now write their report as a resident row in the transaction that makes the decision: the dead-letter commits with its report and the caller receipts settled `abandoned` against it; the cancel request carries its cause, and the terminal transition reads it back and records the run's report in the same commit. The failure recovery delivers from the row, so a process that dies between deciding and delivering leaves only the delivery to redo. `AdmissionDrainDeps.onAbandoned` is gone — a custom drain adapter drops the property — and so are the `workflow onFailure start resolved` / `could not be started` log lines it produced; the report's `acceptedExecutionId` is the delivery evidence.
134
+
135
+ **`voltro update` carries you across this** — codemod `0.72.0/05_failure-reports-replace-the-abandon-callback`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.72.0).
136
+ - **A file migration is transactional by default; raw SQL is the escape hatch** — `@voltro/database`, `@voltro/cli`
137
+
138
+ `ctx.sql` is gone from the file-migration context. The raw `@effect/sql` client is `ctx.raw('why')` now — the reason is required, it is what a reviewer reads first, and `voltro doctor`'s new `migration-raw-sql` rule lists every migration that reaches for it. In its place the context carries the whole schema surface as code: `schema.createTable`, `dropTable`, `renameTable`, `addColumn`, `alterColumn`, `addIndex`, `addForeignKey`, `dropForeignKey`, `dropIndex`, `dropColumn`, `renameColumn`, `truncate`, each inspecting the live schema first and emitting the dialect's own statement — and `schema.evolve(table(…))`, which declares a table as it should be from here on and lets the planner that drives `voltro db apply` diff and apply the difference with the same statements, backfills and convergence proof. A migration is `transactional: true` by default: body and ledger row in one unit, nothing left behind on failure. On MySQL and MariaDB the runner measures whether the body committed the unit from inside (DDL does) and fails the run with the migration's name and the fix. The codemod rewrites `sql` bindings to `raw(reason)` with a placeholder reason and marks every existing migration `transactional: false`, so nothing already written changes meaning; a new migration gets the default.
139
+
140
+ **`voltro update` carries you across this** — codemod `0.72.0/01_migration-context-raw`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.72.0).
141
+ - **Flow resolvers see the owner and planner provider; media slots carry a type** — `@voltro/plugin-ai-flows`
142
+
143
+ `MediaRunContext` is `FlowRunContext` and carries `ownerId` beside `runId` and `tenantId` — every host loaded the run row again for it, and a cadence run had none. `resolveAgent` may fail with `FlowAgentUnavailable` (`not-found`, `access-revoked`, `model-unusable`): its `message` goes to the host, only its `publicMessage` reaches the planner's model, which used to read every resolver message verbatim as tool output. `resolveTools` receives a third argument, `{ provider }` — a thunk to the planner's provider — so a host can offer the matching provider-native search; a failure there fails the planner as before, and the resolver's own message now reaches the server log instead of being dropped. A media input slot of `from: 'upload'` may declare `mediaType`; a step artifact already knows its own. Both reach a media generator as `inputRefs` (`{ url, mediaType }`), beside the URL map, for the models that require typed references. The codemod renames the type; the new fields are additive.
144
+
145
+ **`voltro update` carries you across this** — codemod `0.72.0/04_flow-run-context`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.72.0).
146
+ - **A rejected credential is refused; the anonymous tenant is the server's choice** — `@voltro/protocol`, `@voltro/cli`, `@voltro/voltro`
147
+
148
+ When an auth strategy rejects the credential it was handed, the resolver now throws `Unauthenticated` (`credential rejected: <reason>`) instead of resolving the caller as anonymous with a `credentialRejected` mark that every surface had to remember to check. Only an app-owned `fallback` still receives the mark. `auth.anonymousTenantId` names the one tenant every anonymous caller resolves to; when set, the `x-tenant` header is not consulted and `anonymousTenantRequired` has nothing left to require. Both boot paths.
149
+
150
+ **`voltro update` carries you across this** — codemod `0.72.0/06_rejected-credentials-are-refused`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.72.0).
151
+ - **The devtools overlay is a searchable console that docks like a browser's** — `@voltro/devtools`, `@voltro/cli`
152
+
153
+ The in-page overlay no longer morphs a floating button into a nine-tab panel. Closed, it is a status capsule at the bottom centre: the api's connection state, live subscriptions, pending or failed mutations and logged errors, all read from in-page buses so a closed overlay costs no network. Open (click, `Alt+V`, or `⌘K` / `Ctrl+K` from anywhere on the page), it is a console with one search across subscriptions, mutations, traces and logs, scope chips instead of tabs, and a detail pane beside the list that renders the selected record — the cache entry's recovery state and data, a mutation's input and output, a trace's span waterfall, a log record's fields and cause.
154
+
155
+ The console docks to the bottom, top, left or right edge, or floats as a window you drag and resize, through the dock menu in its header (arrow keys move between the positions) or `Alt+Shift+Arrow`. Its size is remembered per dock side and the whole dock model persists in `localStorage`. It lies over the page by default; the dock menu's "Push the page" switch pads the document on the docked side instead, so the host reflows beside it — opt-in, because a `position: fixed` bar or a `100vw` layout ignores the padding.
156
+
157
+ For a foreign host that renders `<VoltroDevtools />` itself, the `initialAnchor` prop (eight viewport positions) is now `initialDock` (`'bottom' | 'top' | 'left' | 'right' | 'window'`), and the exported `Anchor` type is `Dock`. The codemod renames the prop, maps each anchor onto the edge it sat on, and renames the type import; the generated web entry passed neither and needs nothing.
158
+
159
+ Three defects in the old overlay are fixed on the way. The traces, webhooks and indexes sources read the bare payload shape, so against a server that answers inspect requests with an observation (`{ data, scope, origin, completeness }`) they threw inside their poll — and, with nothing catching it, unmounted the host page; they read the envelope now, and an error inside the overlay is contained in its own chrome with a reset. `Alt+V` matched the letter key, which on macOS the Option key composes into `√`, so the documented shortcut never fired there; it matches the physical key now. And the overlay renders inside a shadow root with its own base styles: a host rule such as `header { flex-wrap: wrap }` no longer reaches its elements, in either direction, and the panels' hover states — written in a variant order Tailwind v4 compiles to nothing — apply again.
160
+
161
+ **`voltro update` carries you across this** — codemod `0.72.0/02_devtools-dock-replaces-anchor`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.72.0).
162
+
163
+ ### Added
164
+
165
+ - **A deploy killed mid-way is undone by the next one** — `@voltro/cli`, `@voltro/database`
166
+
167
+ On the dialects that undo rather than roll back (mysql, mariadb, sqlite, turso) the undo ran in the process that saw the failure — and a process killed mid-deploy saw nothing, so what it had applied stayed and no line said so. `voltro db apply` now writes its baseline into `_voltro_deploys` before the first statement and deletes the row when it finishes. The next apply on that database finds a row still `running`, says which deploy died (id, start, who), undoes from that baseline, and only then does its own work. An undo that cannot complete leaves the row as `failed` with its report, and every later apply refuses until `--forget-deploy <id>` says an operator has repaired by hand. Measured with a real `SIGKILL` on sqlite and mariadb. Postgres and SQL Server need none of it: their open transaction dies with the process.
168
+ - **Device identity beside API keys — pairing, exchange, rotation, replay** — `@voltro/database`, `@voltro/protocol`, `@voltro/runtime`, `@voltro/cli`, `@voltro/voltro`
169
+
170
+ `devices: true` in `app.config.ts` gives a machine caller a credential that does not sit on its hardware for years: an admin (`admin:full` or the new `devices:manage`) pairs a device and receives a one-time pairing code; the device exchanges it for a short-lived access token and a refresh token, calls the API as a `serviceAccount` subject in the pairer's tenant with the paired scopes (bounded by the token's expiry), and rotates the pair before it runs out. A refresh token presented after it was rotated out is a replay and revokes the device. Routes under `/v1/devices` (pair, exchange, rotate, list, revoke), the `_voltro_devices` table (hashes only), `makeDeviceService` / `frameworkDeviceStrategy` in `@voltro/runtime`; both boot paths.
171
+ - **A file migration gets the typed store, schema operations and data helpers** — `@voltro/database`, `@voltro/cli`
172
+
173
+ The context a `migration({ up, down })` body receives carries more than the raw `sql` client now. `store` is the application's data store over the same database — the same codecs, query builder, `updateMany` and `forUpdate` a handler has — so a data migration reads and writes rows the way the app does and stops re-implementing the JSON and timestamp handling of one driver. `schema` answers `tableExists` / `columnExists` / `indexExists` / `foreignKeyExists` from the live schema and performs `dropForeignKey`, `dropIndex`, `dropColumn` and `renameColumn` with the dialect's own statement, inspecting first so a re-run reports `already-absent` instead of matching on an error code. `each` visits a table in pages keyed by `id`; `rewrite` reads, patches and writes back under an optimistic guard on the values just read, repeating until a pass changes nothing, so rows the previous deploy is still writing are retried rather than overwritten. `expect` registers a post-condition the runner checks after `up` — a migration whose check fails is not recorded and runs again. `skipUnless` records a migration as skipped with its reason when a precondition does not hold. `transactional` runs a scope on one transaction; `migration({ transactional: true })` runs the whole body and its ledger row in one — on MySQL and MariaDB too, for a migration that is data only, since DDL commits implicitly there. `down: irreversible('reason')` says a migration has no inverse, and `voltro db rollback-file` refuses with that reason instead of running a body that pretends. A rollback on SQL Server no longer fails on a `LIMIT` the dialect does not have.
174
+ - **A media job is a durable step with its own identity; the queue is fenced** — `@voltro/ai`, `@voltro/voltro`
175
+
176
+ `mediaStep({ modality, prompt, params, inputs, store })` runs an image, video or speech generation as an offloaded durable step: the queue row is the job's identity, allocated before the first paid call, so a replay finds the row instead of submitting again. The dispatcher performs the job, persists every artifact through the storage plugin's `mediaPersist` capability under `<row>#<attempt>#<index>` and resumes the run with URLs and ref ids — never bytes. A job whose worker was lost is not submitted again: the run resumes with `AiOffloadError.outcome === 'unknown'` and the row stays for an operator. Every write after a claim is fenced (`fence` on `_voltro_ai_inferences`): a worker whose row was reclaimed can no longer overwrite the new worker's result or re-queue a finished job. `QueuedInference` gains `media` and `fence`; `kind` admits `media`; `InferenceDispatchDeps.persistMedia` is new. Additive.
177
+ - **A plugin can offer capabilities to the framework — first, media persistence** — `@voltro/protocol`, `@voltro/plugin-storage`
178
+
179
+ `PluginDefinition.capabilities` names what a plugin can do for other framework machinery. The storage plugin offers `mediaPersist` — a put and a URL ingest, each named by the job's operation — which the offloaded-inference dispatcher uses to keep a media job's artifacts; behind a bound data store the operation id makes a repeated persist land on one object. `ingestUrl` accepts an `operationId`.
180
+ - **A put that names its operation is resumable, and its delete is durable** — `@voltro/plugin-storage`, `@voltro/cli`
181
+
182
+ `storage.put({ operationId })` reserves the operation's identity and its quota in one transaction before a byte moves; a repeat with the same id — a job runner's retry, two replicas completing the same job — returns the finished ref or completes the half-done write, instead of leaving a second reference whose bytes outlived the winner's delete. Different inputs under a finished id are refused (`StorageRejected`, `operation-identity-reused`). Deleting such a ref retains the physical key until the provider acknowledges, so a delete that failed at the provider is repeatable and a put arriving after the delete counted zero gets a new key. Four framework tables carry it, migrated with the rest.
183
+ - **A start handle can be read without waiting** — `@voltro/runtime`, `@voltro/voltro`
184
+
185
+ `ctx.workflows.readStart(handle)` answers what a recorded start has become — `pending`, `accepted` under an execution, or `not-executed` with the reason (`dropped`, `skipped` with the incumbent, `abandoned` or `rejected` with the failure report) — once, without polling. A `queued` handle is resolved through its durable receipt, so the answer survives the process that recorded the handle; a dropped or skipped handle answers from itself. Inside a mutation transaction it refuses, like `wait`: the receipt is provisional until commit.
186
+ - **Tenant selection is a Subject primitive, for any credential** — `@voltro/protocol`, `@voltro/database`, `@voltro/runtime`, `@voltro/cli`, `@voltro/plugin-auth`, `@voltro/voltro`
187
+
188
+ A subject that belongs to several tenants can select the one it acts in, whatever credential it presents — an IdP JWT and an API key included, not only the auth plugin's session cookie. `selectSubjectTenant(subject, tenantId, memberships)` is the one rule; `selectTenant` / `clearTenantSelection` (`@voltro/runtime`) apply it and store the selection in `_voltro_tenant_selections`; `auth.memberships` in `app.config.ts` names the app's one source of memberships (`authMemberships(userStore)` for the auth plugin), and the resolver applies a stored selection after the strategy matched and before `resolveScopes`, on both boot paths. A selection the memberships no longer cover is ignored and cleared; one that cannot be read fails closed. `/auth/switch-tenant` writes the same row beside its cookie. Additive on every package: new exports and a new optional `auth` field.
189
+ - **A text or object generation takes an abort signal, and its tools see it** — `@voltro/ai`
190
+
191
+ `generateText` and `generateObjectWithTools` accept `abortSignal`. It interrupts the provider call and is threaded into every tool's context, so a tool call the model emitted just before the abort is not executed after it — callers no longer keep their own binding to the turn's signal.
192
+ - **VOLTRO_WEB_PORT and VOLTRO_API_PORT override the port per app kind** — `@voltro/cli`
193
+
194
+ `PORT` names one port for the whole process, so a project running its api and its web app from one env file could not give the web app its own port. The per-kind variables outrank `PORT` and apply to their kind only; the rest of the precedence (`--port`, `app.config.ts` `port`, the default) is unchanged.
195
+ - **A workflow run is a page, with a timeline that has a time axis** — `@voltro/devtools-ui`
196
+
197
+ The dashboards open a run on its own page (`…/workflows/runs/<runId>`): a header with status, source, version, parent, trace, absolute start and live duration; only the actions that apply to the run's state; a timeline in body order — steps with their attempts stacked, durable sleeps, signal waits and child runs — on a time axis with ticks, each entry selectable (mouse, ↑/↓, j/k) into a side panel with input, output, error, stack trace and retry policy; the run's payload, output and lifecycle events below. The list's run rows link there instead of expanding inline. The definitions tab starts a workflow with a JSON payload through the inspect start endpoint, and the empty runs state says so. The "Suspend" button is gone: the api refuses every external suspend, so the button only ever failed.
198
+ - **Send a declared event, start a run for a tenant, act on ticked runs** — `@voltro/cli`
199
+
200
+ `POST /_voltro/inspect/workflows/events` sends a declared domain event as the operator through the same durable path `ctx.events.publish` drives — the event-log row plus a start of every trigger listening — and answers what it triggered; an undeclared name is refused with the known names. The inspect start accepts `tenantId`, and the run then executes as a service account in that tenant with operator authority (a system subject is cross-tenant and cannot carry one). The bulk flow-control verb accepts `runIds` beside its filter, still capped and still partitioned by eligibility. Workflow stats carry each tag's own bucket `series` for a per-workflow sparkline over the window, and with a `tag` a `byVersion` comparison (runs, outcomes, p50, p95 per recorded workflow version). Both boot paths.
201
+ - **The console's Workflows scope follows the inspect stream instead of polling** — `@voltro/devtools`
202
+
203
+ The in-page console opens the api's inspect stream through the dev proxy and re-reads runs, steps, events and the running-runs badge the moment a row changes on `workflowRuns` / `workflowSteps` / `workflowEvents` (one re-read per burst). The poll is a ten-second fallback for a proxy that cannot stream.
204
+ - **The devtools console has a Workflows scope** — `@voltro/devtools`
205
+
206
+ The in-page console shows the workflow runs of every connected api — live, over the client's own workflow subscriptions, with no inspect token involved. A run expands into its steps (status, attempt, duration, input and output), its lifecycle events and a trace id; a running run can be cancelled, and one parked on `awaitSignal` offers the awaited signal names with a payload field. Each api carries a start form: pick a workflow, edit the JSON payload (prefilled from that workflow's last run), start, and watch the run appear in the same page. The scope badge counts running runs.
207
+ - **The devtools console captures console warnings, errors and uncaught exceptions** — `@voltro/devtools`, `@voltro/web`
208
+
209
+ The overlay's Logs scope read only `@voltro/logger` records, and the framework's own client-side warnings — the api-client mismatch notice, for one — are `console.warn` calls, so in the situations a developer opens a console for the scope stayed empty. It now also records every `console.warn` / `console.error`, uncaught error and unhandled promise rejection on the page, with the first `Error` argument as the record's cause so the detail pane shows its stack, and the capsule counts them among its errors and warnings. A logger record that is also echoed to the console appears once. `console.log` and `console.info` are not captured. The calls come from `@voltro/web`'s dev console bridge, which now exposes them through `subscribeConsoleCalls` — one console patch, one seam — rather than from a second patch on top of it.
210
+ - **Inspect describes each workflow; stats carry latency and per-step figures** — `@voltro/cli`
211
+
212
+ `/_voltro/inspect/rpc` describes each workflow beyond its name and file: the payload's JSON schema, version with `compatibleWith` and `patches`, the declared flow control with every function reduced to `keyed: true` / `declared: true`, the signal and update names it accepts, its access (`internal`, the guard scopes, or the `openAccess` reason), and what triggers it — the `*.trigger.tsx` events and the schedules that name it — all read off the definition the boot registered, on both boot paths. `/_voltro/inspect/workflows/stats` answers `latency` (samples, p50, p95, max over the window's terminal runs) and, when asked for one `tag`, `steps`: per step the runs it appeared in, attempts, failures, retries, p50 and p95 over the newest runs of that workflow.
213
+ - **The workflow sparkline follows the time preset, with a tooltip per bucket** — `@voltro/devtools-ui`
214
+
215
+ The overview asks the stats endpoint for the time preset's window at a fine bucket (five seconds over fifteen minutes, five minutes over a day) and draws each workflow's server series as an SVG area line with a failure marker per bucket and a hover tooltip carrying the bucket's time to the second and its counts. The throughput chart shares the window and label. Exported: `Sparkline`, `statsWindowFor`, `formatWindow`.
216
+ - **Workflow stats take a window in minutes and buckets in seconds** — `@voltro/cli`
217
+
218
+ `/_voltro/inspect/workflows/stats` takes `minutes` (a window in minutes, over `hours`) and `bucketSeconds` (a bucket from one second up, floored so a window holds at most 2000 buckets) and answers `windowMinutes` and `bucketSeconds` beside the hour and minute fields. Fifteen minutes in five-second buckets is one request; the per-tag `series` follows the same buckets.
219
+ - **The UserStore is nine roles; postgresUserStore maps identity onto your table** — `@voltro/plugin-auth`
220
+
221
+ `UserStore` is now the union of nine role interfaces — `IdentityStore`, `MfaStore`, `MembershipStore`, `SessionStore`, `TokenStore`, `InvitationStore`, `ImpersonationStore`, `PasskeyStore`, `LockoutStore` — and every handler's `store` parameter is typed with the roles it reads, so a store implementing one role is a complete argument to the handlers that need only that role. `postgresUserStore(sql, { identity: { table, columns } })` maps the identity role onto an existing table with its own name and column names (`updatedAt: false` for a table without the stamp; a column mapped `false` reads null and refuses the write that exists for it, naming the mapping; `deactivatedAt` is readable once mapped); every other role stays on the plugin's tables. Additive: the full `UserStore` and `postgresUserStore(sql)` are unchanged.
222
+ - **Pause at the workflow, send events, bulk over ticked runs, explain a run** — `@voltro/devtools-ui`
223
+
224
+ A workflow's page carries its admission — paused or admitting, waiting, holding, dead-lettered, the queued intents with their collapsed count — with Pause (reason) and Resume in place, and a per-version table over the window. The events tab sends a declared event or replays a logged one and shows what it started; deliveries link to loaded runs. Ticked run rows get a selection bar that dry-runs, then cancels or replays by run id. "Start run…" takes an optional tenant. Saved views are chips; the attention block has a dead-letter tile; the placement input sits behind an operator toggle. The run page shows the run's admission row ("N starts collapsed into this run"), a nondeterminism warning naming the step, and an attempt-to-attempt diff of input and output. New capability `canEmitWorkflowEvent`.
225
+ - **The workflows page shows the declaration, attention, step figures, presets** — `@voltro/devtools-ui`
226
+
227
+ The definitions tab is a table — per workflow its triggers, schedules, flow control, version and access as chips, a 24-hour sparkline, the window's counts and a start button. A workflow's page adds a Declaration card (payload fields, triggers with cron, flow control, signals and updates, version and patches, access) and a per-step table from the stats endpoint; the stat tiles use the server's window latency when it answers one. The runs tab opens with a Needs-attention block (failed, stuck past three times the p95, waiting, running) whose tiles filter the list; the filter bar has 15 min / 1 h / 24 h / 7 d presets and every active filter is a removable chip. "Start run…" opens on a payload template built from the declared schema. Exported: `DefinitionsTable`, `WorkflowConfigCard`, `StepMetricsTable`, `AttentionBlock`, `ActiveFilterChips`, `payloadTemplate`, `summariseAttention`, `presetOf`.
228
+
229
+ ### Changed
230
+
231
+ - **A connection credential may be a thunk; a throwing connection file refuses boot** — `@voltro/runtime`, `@voltro/cli`, `@voltro/voltro`
232
+
233
+ `defineConnection`'s `clientId` and `clientSecret` accept `() => string` (`OAuthClientCredential`; `oauthCredentialValue` reads one). A `*.connection.ts` is imported by discovery before the env gate runs — and `voltro doctor` runs none — so `clientId: serverEnv.X` at module top level threw exactly where the documented example put it. A thunk is read when the connection is used, always after the gate; a plain string is still validated at declaration. A connection file that cannot be imported now fails discovery — a boot refuses and doctor reports it — instead of a warning and an app that came up without its OAuth connection. The field type widens from `string` to `string | (() => string)`; an app writes the field and the vault reads it, so no call site changes — a reader of its own definition uses `oauthCredentialValue`.
234
+ - **A deploy is all-or-nothing on every dialect** — `@voltro/cli`, `@voltro/database`
235
+
236
+ `voltro db apply` (and `voltro migrate`) now treats every pending file migration and the whole schema plan as one unit. On Postgres and SQL Server that is ONE transaction: a failure anywhere — a throwing backfill, a refused plan, a broken migration — rolls back everything, including the rows an earlier migration of the same run had written and the ledger rows that would have said they were applied; the online operations Postgres refuses inside a transaction (`CREATE INDEX CONCURRENTLY`) follow after the commit. MySQL, MariaDB, SQLite and Turso cannot hold DDL in a transaction, so there the runner undoes instead: it snapshots the schema before the run, and on failure runs the `down` of every file migration the run applied (newest first), plans the schema back to the snapshot — a rename the run applied is renamed back, not dropped and re-added — and clears the resume ledger. The report names what was undone, what stays because its `down` is `irreversible()`, and what came back without its rows because the run had dropped it. On those dialects the question before the first statement now names the pending migrations and the irreversible ones among them; `--yes` answers it in a pipeline. `VOLTRO_DEPLOY_TRANSACTION=off` switches both mechanisms off.
237
+ - **A flow step says where its cost came from, and the run says how complete** — `@voltro/plugin-ai-flows`
238
+
239
+ Every recorded step carries `costSource` — `gateway`, `estimated` or `unpriced` — so a 0 beside an unknown model or an unpriceable media call no longer reads as free. The run row folds them into `costCompleteness` (`actual` / `estimated` / `incomplete` / `unknown`), the one field to bill on.
240
+ - **The lift advice names the one way it can change behaviour** — `@voltro/cli`
241
+
242
+ `store-effect-lift` fires on `Effect.tryPromise` as well as `Effect.promise`, and for a `tryPromise` the `catch` is part of the error channel: a read that currently cannot fail — because the rejection is mapped and then absorbed upstream — starts failing once the typed channel carries it. Following the advice therefore removes a "this must always render" guarantee that nothing declared, silently, in a diff that otherwise looks mechanical.
243
+
244
+ The rule says so now, and names the explicit form (`Effect.either` plus a fallback). Naming the benefit and none of the ways the change lands is what this rule's own history already records twice.
245
+
246
+ Separately, EVERY pattern the doctor uses to ask whether a function was CALLED now tolerates explicit type arguments — twenty of them, where the previous release reached seven. The guard that is supposed to keep it that way was the reason: it described what a call site looks like, so each new spelling (a regex literal, an alternation, a dotted method) was a hole nobody could see. It now treats any raw call-paren as a finding and asks the four genuine non-calls to carry a written reason, which is a list that cannot grow silently.
247
+ - **A pending flow run follows what its start became** — `@voltro/plugin-ai-flows`
248
+
249
+ A flow run whose start was recorded `queued` no longer stays `pending` forever when that start is dropped, skipped or abandoned. `reconcileFlowStarts(ctx)` reads every pending row's receipt and marks the row `failed` with `error: 'FlowStartNotExecuted'` and the reason; the cadence tick runs it first on every replica and reports it under `reconciled`. An admitted start is caught live: the engine's run row appearing under a `queued` handle turns the handle into the accepted execution, through the same change tap that converges a facade cancel.
250
+ - **An AI usage row is a receipt, written once per request attempt** — `@voltro/ai`, `@voltro/voltro`
251
+
252
+ `recordAiUsage` takes `requestId` and `attempt` (or a `receiptKey` outright) and the ledger row carries a unique `receiptKey` derived from them. A retried record of the same attempt — a workflow replay, a handler that re-ran after a timeout — finds the existing row and returns `receipt: 'replayed'` beside the cost breakdown instead of charging the tenant twice; a fresh row returns `receipt: 'recorded'`. `aiStep` and the queued inference dispatch pass their ids.
253
+ - **readControl is wired in dev and serve; the 0.71.0 "pending" note is superseded** — `@voltro/cli`
254
+
255
+ The 0.71.0 entry for `readControl(...)` ended with "built-in host integration remains pending", and the 0.71.1 entry beside it said dev and serve expose read-only receipt observation. Both were true at their moment and the pair reads as a contradiction. The state now: `voltro dev` and `voltro serve` both serve `readControl` through the built-in control procedure; a custom host supplies its own reader only if it replaces the built-in one.
256
+ - **The admission recovery warning names its reason and is written once** — `@voltro/cli`
257
+
258
+ `workflow admission: physical recovery incomplete` carried two lists under one name — `releaseFailures: 0` printed beside a failure in phase `release` — and no reason. It now carries the fields the verdict is made from (`discoveryProblems`, `placementFailures` with placement, phase and the database's own message, `releaseRowFailures`, `drainFailures`), and the drain tick writes a warning when the picture changes rather than on every tick: the same condition is logged once, a different one again, and `workflow admission: physical recovery healthy again` closes it. The other per-tick warnings of the drain are held the same way.
259
+ - **The inference dispatcher finds media persistence among the plugins** — `@voltro/cli`
260
+
261
+ Both boot paths hand the offloaded-inference dispatcher the first plugin offering `mediaPersist` (the storage plugin when installed), through one shared lookup. Without it a queued media job fails with the reason rather than resuming its run with bytes.
262
+ - **Zero-valued video parameters reach the gateway; aspectRatio accepts adaptive** — `@voltro/ai`, `@voltro/voltro`
263
+
264
+ The Gateway SDK's media serializers drop falsy values, so `durationSeconds: 0` and `fps: 0` vanished while `seed: 0` was already rebound. All three are pinned onto the request body now, only when set. `aspectRatio` on a video request accepts `'adaptive'` for the models that derive it from the reference input. The `aspectRatio` type widens; every value that compiled still does.
265
+
266
+ ### Fixed
267
+
268
+ - **A bounded, indexed text column keeps its declared width on SQL Server** — `@voltro/database`
269
+
270
+ `text().maxLength(64).unique()` was created as `NVARCHAR(450)` on SQL Server — the index bound overrode the declared width — so the reader reported 450, every later plan proposed retyping the column, and `voltro db apply` on a fresh SQL Server database never converged. The declared width is the column's width now, capped at the 900-byte key limit the bound exists for.
271
+ - **A CHECK added to a framework-owned table is rolling-deploy safe** — `@voltro/database`
272
+
273
+ `voltro db plan` flagged an `add-check` on a `_voltro_*` table as unsafe under a rolling deploy and recommended "make code satisfy the constraint first" — an action the app's operator cannot take for a table only the framework writes. Such a CHECK is now classified safe: the framework's membership lists only widen between releases, so an instance on the previous release writes only the labels the new list still admits. Under `VOLTRO_ROLLING_DEPLOY=1` the apply no longer refuses for it.
274
+ - **Corrected upgrade note for insert-ignore outcomes; diagnostic type exported** — `@voltro/cli`
275
+
276
+ The 0.71.0 note printed `insertIgnoreWithOutcome(table, row, conflictColumns, attr?)`; the method is `insertIgnoreWithOutcome(table, row, { conflictColumns })`, and a host that followed the note got a type error. A published note cannot be edited, so a corrected one is re-issued under 0.72.0 for every project that mentions the method. The 0.71.0 note on transactional starts asked `WorkflowPayloadDiagnostic` implementations for `encode`/`decode` while no package exported the type; `@voltro/cli` exports it now.
277
+ - **A decimal's precision and scale are read, compared and applied** — `@voltro/database`
278
+
279
+ A column declared `decimal(10, 2)` carried its size on the declared side only. No dialect reader reported `numeric_precision`, the planner compared neither field, and the schema fingerprint hashed both — so the two halves of the migration engine disagreed about every such column forever, and a CHANGED size was applied nowhere: `decimal(10, 2)` → `decimal(12, 4)` planned nothing and left the database as it was.
280
+
281
+ The postgres, mysql/mariadb and mssql readers report the size now, and the planner compares it — gated on the live side actually reporting one, so a dialect whose reader is silent behaves exactly as before rather than planning the same ALTER on every boot. sqlite and turso have no fixed-point type, so nothing is invented there.
282
+
283
+ A size change is classified like a text width: widening keeps every value and is safe; anything that loses integer digits or decimals is `needs-backfill` and carries the query that counts the rows at risk. Note that more decimals is not automatically wider — `decimal(10, 2)` → `decimal(10, 4)` takes two integer digits away.
284
+
285
+ Proven end to end on postgres and MariaDB: the ALTER runs, an existing `123.45` reads back as `123.4500`, the re-plan is empty and the two readers agree. The shared dialect-parity scenario now carries the step, so every engine states whether it can express a decimal size and is asserted in both directions.
286
+ - **A declared index on a reference column survives introspection on Turso** — `@voltro/database`
287
+
288
+ `voltro db apply` could not converge on Turso once a table declared an index on a reference column: the operations applied without error, and the very next plan proposed the same `add-index` again — indefinitely, because a plan that does not converge records no fingerprint.
289
+
290
+ The introspector discards the index SQLite creates implicitly behind a foreign key, and keeps the one the framework declared, by comparing the catalog's name against the name the framework emits. That comparison was byte-exact. Turso's catalog lowercases index identifiers even where the `CREATE` preserved mixed case — measured in both readers, `sqlite_master` and `PRAGMA index_list`, while the stored statement text keeps the original spelling. So a declared `<table>_<column>_idx` on a reference column failed the keeper test, was discarded as an implicit index, and never reached the snapshot. The planner saw a missing index and asked for it; `CREATE INDEX IF NOT EXISTS` found it already there and changed nothing.
291
+
292
+ Identifiers are case-insensitive in this engine family, so the comparison folds case — the same rule the index diff already applies when pairing declared and live names. Nothing changes on the other dialects, whose catalogs preserve the spelling they were given.
293
+ - **The field guards answer on every dialect — and two answers were wrong** — `@voltro/database`
294
+
295
+ The three derived field guards asked their question on postgres alone, and said so as a stated limit. That is the same shape as the defects they exist to find: an answer taken from the dialect somebody happened to be holding. They now run every probe on all six, and two of the new answers were defects.
296
+
297
+ **`text().maxLength(64)` on sqlite moved the fingerprint and planned nothing.** sqlite and turso render plain `TEXT` — measured, not assumed — so no reader reports a width back and the planner skips the comparison outright, while the hash carried the declared bound. An empty plan under two fingerprints is the one state no command can close: `voltro db apply` has no work, never records the new hash, and `voltro serve` refuses the boot for as long as the declaration stands. The declared snapshot drops a width those two dialects cannot hold, which is what its own contract already says — say the database the declaration produces. No DDL changes; `TEXT` carries no bound to render.
298
+
299
+ **An array's element was compared where the kind does not survive.** Off postgres an array is stored as JSON or TEXT, so the element is gone and the fingerprint drops it with the kind — but the planner still compared it, which would propose an `alter-column-type` rendering the same JSON column and a migration that cannot converge. It is compared only where the kind survives now.
300
+
301
+ Where dialects legitimately differ, the roster states the answer PER DIALECT and exhaustively, so naming one forces an answer for all six rather than letting the unnamed ones drift: a text width exists on four of six, an index's access method is postgres vocabulary, HNSW tuning goes wherever HNSW goes.
302
+
303
+ The rosters also gained a third outcome. A dialect that REFUSES a declaration outright — mysql on a partial index, naming `uniqueActive` as the portable answer — is not silently ignoring it, and collapsing the two into "not planned" would lose the distinction this whole family of checks draws.
304
+ - **Deploy-ledger and storage-operation rows stay out of a cross-environment bundle** — `@voltro/data-transfer`
305
+
306
+ `_voltro_deploys` (the deploy this database is in the middle of, with its undo baseline) and the four storage-operation tables behind `put({ operationId })` (operations, object heads, put intents, per-tenant locks) are classified environment-local: a `scope: all` export leaves them out and an import skips them, because a row from another deployment would make the target undo a deploy it never started or act on a provider key it does not own.
307
+ - **A dropped column that holds a foreign key drops the constraint first** — `@voltro/database`, `@voltro/cli`
308
+
309
+ `column: dropped()` on a column that carries a FOREIGN KEY planned the `DROP COLUMN` alone and left the constraint standing. Postgres drops the constraint with the column; MySQL and MariaDB refuse (`ER_FK_COLUMN_CANNOT_DROP`), SQL Server refuses with a dependency error — and on a dialect that applies per operation the refusal landed after everything before it had committed. The plan now drops the constraint first, on every dialect, under the name the database catalog reports for it (the readers carry the constraint name now; it is not part of the schema fingerprint and never re-plans). Two more defects the same step surfaced on real servers: a SQLite table rebuild ran with foreign-key enforcement ON, so rebuilding any REFERENCED table failed at its `DROP TABLE` — the rebuild runs on one reserved connection with enforcement off and is checked with `PRAGMA foreign_key_check` before the connection is handed back; and adding a `reference()` to an existing table on SQL Server spelled `ON DELETE RESTRICT`, which it does not accept — it is `NO ACTION` there, as the create path already knew.
310
+ - **An invalid flow text parameter is refused by field name** — `@voltro/plugin-ai-flows`
311
+
312
+ `invalid text params: expected maxTokens …` said which fields exist, not which one was wrong. The message now leads with the offending names — `unsupported field: webSearch`, `invalid value for: temperature` — and never the values.
313
+ - **A JS backfill receives the whole row, decoded through the column codecs** — `@voltro/database`
314
+
315
+ `column.backfill((row) => …)` promised the row and delivered `{ id }`: every other column read as `undefined`, so a year computed from a timestamp came out `NaN` and one dialect stored it as `0` without a warning. The callback now receives every column of the row, decoded the way the store decodes it — JSON parsed, timestamps as `Date`s, booleans as booleans — on every dialect, and the value it returns is encoded for the column the same way. Two things the same step surfaced on real servers: a batched backfill on SQL Server paged with `LIMIT`, which it does not accept, and on SQLite the three-step add left the column nullable after the backfill, so every later plan proposed the NOT NULL tightening again — the applier now pages SQL Server with `OFFSET … FETCH` and rebuilds the SQLite table to the declared shape once the backfill has filled it.
316
+ - **A migration says it needs two connections instead of timing out on a pool of one** — `@voltro/cli`, `@voltro/database`
317
+
318
+ The migration lock holds one connection for the whole apply and the statements run on another, so a pool sized 1 waited out its acquire timeout and reported a connection error. `voltro db apply` / `voltro migrate` now refuse up front when `DB_MAX_CONNECTIONS=1`, naming the sizing; and when a pool the environment did not size (a pooler's cap) hands out no second connection, the lock logs the same explanation at the failure — raise the pool to 2 for the migrating process, or point the migration at the database directly behind a pooler.
319
+ - **A native enum column can be created at all** — `@voltro/database`
320
+
321
+ A table with a `dbEnum()` column could not be created by `voltro db apply` or by a boot auto-migrate:
322
+
323
+ ```
324
+ applier: statement failed (op=create-table)
325
+ type "probe_status" does not exist
326
+ ```
327
+
328
+ The bring-up emitter has always created the postgres type; the operation-driven path never did, and an operation names a column's type only by tag. That is the two-emitter split this repo has paid for before, so both callers now share the one emitter — its blocks are guarded `DO` statements, so running them on every apply is a no-op once the type exists and emits nothing off postgres.
329
+
330
+ One step further in, the column had the same defect as an array: the reader mapped a user-defined type to `text`, so a declared `enum` re-planned a type change whose statement produces the column that is already there. The reader recovers the kind and the type NAME now, the same recovery the vector types already had.
331
+
332
+ And the schema fingerprint stops hashing what no reader can report: the type-narrowing acknowledgement, a `raw()` column's verbatim DDL, an array's element type, an enum's labels, and a postgis column's geometry. Each one made a converged table part from its own database forever, under a plan with nothing in it. An array's STORAGE is now stated by the declared side instead, so the two sides agree about it rather than reporting a difference neither can act on.
333
+
334
+ Two limits stated rather than implied: changing an enum's value set is still not planned, and neither is a postgis column's geometry kind or SRID — the metadata for the second belongs to a plugin, and the core reader has no hook to see it.
335
+ - **The loser of two concurrent AI budget releases gets already-released** — `@voltro/ai`
336
+
337
+ `releaseAiBudget` read the counter before claiming the receipt, so on PostgreSQL the loser of a race saw the winner's credit already applied and died with "receipt exceeds its counter" — a refusal about a contradiction that was never there (the in-memory store never raced). The receipt is claimed first now; a loser re-reads it and returns `already-released`. The money was always right; the answer is now too.
338
+ - **A blocked column drop carries its foreign-key drop, so an unblock admits both** — `@voltro/database`
339
+
340
+ The planner emitted the `drop-foreign-key` that must precede a column drop only when the drop was not blocked. A lossy drop is planned blocked and unblocked afterwards (`VOLTRO_DESTRUCTIVE_OK`, and every drop a deploy undo plans), and then ran without its constraint drop: MySQL and MariaDB refused to drop the FK's index (`ER_DROP_INDEX_FK`) and the column stayed. The FK drop now travels with its column — refused together, admitted together.
341
+ - **A signal or update whose row did not land is refused, not receipted** — `@voltro/workflow`
342
+
343
+ `sendWorkflowSignal` and `sendWorkflowUpdate` returned an `eventId` even when the recorder's insert into `_voltro_workflow_run_events` had failed — the recorder logged "continuing" and handed back the id it had minted, so a human review answered through `respondToFlow` reported `accepted` for a signal the run would never see. The recorder now says whether the row was recorded, and the two senders throw `WorkflowMessageNotRecorded` when it was not; `respondToFlow` surfaces that as its `delivery-unknown` refusal. Lifecycle events keep the no-throw contract.
344
+ - **A soft-drop snapshot table no longer moves the schema fingerprint** — `@voltro/database`
345
+
346
+ `VOLTRO_SOFT_DROP=1` renames a dropped object aside as `<name>__dropped_<ts>` so it can be reviewed before the retention window closes. The planner skips those by name — a table and a column alike — and the fingerprint filtered only the column half. So an app that dropped a TABLE that way carried a schema fingerprint that disagreed with its own database for the whole window, under a plan with nothing in it: `voltro db apply` reports up to date, the drift comparison reports a difference, and no command can reconcile them because there is nothing to apply.
347
+
348
+ Both halves are filtered now, on the same rule the planner uses.
349
+
350
+ Found by asking the two readers the same question over a matrix of set-level shapes rather than by reading either of them — the third defect of that class this release, and the one that needed no unusual declaration to reach: dropping a table with the flag on is enough.
351
+ - **A `source:` finding names the procedure that actually read the table** — `@voltro/runtime`
352
+
353
+ The development-only `source:` gap finding could blame the wrong procedure: a procedure that is not recorded — already reported, or declaring no `source:` — entered no read scope, so its reads landed in the scope of whichever recorded procedure ran before it in the same execution context, and the finding named that one for a table it never reads (a subscription re-run beside another was enough). Every procedure enters a scope now; an unrecorded one enters a muted scope that records nothing and shields the previous one.
354
+ - **A spatial column's SRID and subtype are part of the schema** — `@voltro/database`
355
+
356
+ Changing the SRID or the geometry subtype of a `geometry()` / `geography()` column planned NOTHING. The declaration moved, the database did not, and the first write in the declared frame came back `Geometry SRID (3857) does not match column SRID (4326)` — a promise the schema made to application code that failed at runtime instead of at migrate time.
357
+
358
+ Both readers were blind for the same reason. A PostGIS column introspects as `USER-DEFINED`, which the type mapper collapses to `text`; the declared side is also a `text` column carrying a spatial marker, and that marker was stripped from the fingerprint because nothing could read it back. So the planner saw no change and the hash did not move — the two agreed, and were both wrong.
359
+
360
+ The postgres reader now reports the family, the subtype and the SRID from `geometry_columns` and `geography_columns`, and it probes `pg_extension` first: no PostGIS means no spatial column can exist, so an empty answer is a fact rather than a failed read. That distinction is what makes a comparison safe in BOTH directions — a planner that guesses on an unreadable catalog proposes an `ALTER` it can never converge. The two views spell the subtype differently from each other on the same server, so the comparison folds the case in the one place the planner and the fingerprint share.
361
+
362
+ What the planner does about a difference depends on whether a transform exists. A changed SRID is a reprojection: `ST_Transform` moves every stored value exactly, so it applies like any other full-table operation. A changed subtype, or a change between `geometry` and `geography`, is refused — a point is not a polygon — and is acknowledged with the same `.narrowedFrom(…, { using })` that every other transform the planner will not guess at already uses, so the refusal has a key.
363
+
364
+ The fingerprint hashes the parameters again now that they can be read back. Without that, `voltro serve`'s prod-mismatch gate would boot cleanly against a database whose spatial columns no longer match the declaration — the reported symptom one layer out.
365
+ - **A table rebuild survives on Turso Cloud and embedded replicas** — `@voltro/database`, `@voltro/sql-turso`
366
+
367
+ On Turso Cloud and on an embedded replica, `voltro db apply` reported success and changed nothing. Every operation the SQLite family performs as a table rebuild — an enum-membership `CHECK` added or widened, a nullability change, a column drop — ran without error, was visible while it ran, and was gone afterwards. The next plan proposed the same work again, and because a plan that does not converge records no fingerprint, every later boot repeated it.
368
+
369
+ The rebuild asks for a pinned connection so it can turn foreign-key enforcement off for the duration — its own comment says why it must not be a transaction: the pragma is a no-op inside one, and the engine refuses DDL there. But the only way to pin a connection hands back the TRANSACTION connection, and this client implements that as a real interactive transaction whose finalizer rolls back anything not explicitly committed. The rebuild never commits, because it never believed it was in a transaction. The local engine is unaffected: it serves both pinned and transactional connections from the same pool, so nothing there rolls back.
370
+
371
+ The rebuild now runs on a connection that is pinned without being transactional, and the interactive transaction stays what it is for the store's own `transactional()` writes. Measured on both clients: the rebuilt table keeps its shape once the scope closes, and the re-plan is empty.
372
+ - **A workflow start runs under the execution id it was remembered under** — `@voltro/runtime`, `@voltro/workflow`, `@voltro/cli`
373
+
374
+ A start derived its execution id twice: once to file the caller context, once inside the engine. With an `idempotencyKey` that is not a pure function of the payload (one mixing in `Date.now()`, say) the two disagreed, the body could not find its own context, and the run died before its first row — acknowledged as `running`, absent from every run list, logged at debug level only. Measured: 8 of 8 such starts vanished. The id is now derived once and the execution runs under it, so such a key behaves as "unique per start". Independently, an execution refused before its body — a missing or unresolvable start context, a rejected authority or row filter — is now reported to the host and logged at ERROR by `voltro dev` and `voltro serve` with the workflow name and execution id, instead of disappearing into the fiber's default handler.
375
+ - **A vector's dimension is a schema change, not a silent deadlock** — `@voltro/database`
376
+
377
+ Changing `vector(3)` to `vector(4)` moved the schema fingerprint and planned NOTHING. That pairing is the worst of the three ways these two readers can disagree, because no command can close it: `voltro db apply` has no operation to apply, so it never records the new fingerprint, and `voltro serve`'s prod-mismatch gate then refuses the boot for as long as the declaration stands — pointing the operator at an apply they have already run. A deploy that changes an embedding model was bricked.
378
+
379
+ `sameColumnShape` compared thirteen things and neither `vectorDim` nor `vectorPrecision` was among them, while the fingerprint hashed both. Both are now compared, gated on the live side reporting a dimension so a silent reader cannot make the planner plan forever.
380
+
381
+ A dimension change is REFUSED by default rather than applied, because pgvector refuses it too: `ALTER TABLE … TYPE vector(4)` against existing rows answers `expected 4 dimensions, not 3`. An embedding of one dimension is not an embedding of another, and halving the precision to `halfvec` discards bits. The refusal carries the same `.narrowedFrom(…, { using })` key every other transform the planner will not guess at already uses — measured end to end, rows included: an empty table applies straight through, and a populated one carries its vectors across the expression the fix line recommends.
382
+
383
+ **How it was found is the part worth keeping.** Not by reading the planner. A new derived guard asks, for every field on `ColumnSnapshot`, whether changing it produces a planned operation and whether it moves the hash — and requires a written reason for any answer other than "both". It found this on its first run. The roster is `Record<keyof ColumnSnapshot, …>`, so a field added later does not compile until somebody has decided what reads it.
384
+ - **A width change on a text primary key is planned, not silently dropped** — `@voltro/database`
385
+
386
+ `.maxLength()` on a key declared `id: text().maxLength(n)` with `.primaryKey(['id'])` moved the schema fingerprint and produced no operation. The width comparison was gated on both sides being the `text` KIND, and a sole primary key named `id` never reads back as one — so the column most likely to carry an explicit width was the one the comparison skipped. The result is a database whose column keeps rejecting values the declaration allows, with the two halves of the migration engine disagreeing about whether anything changed.
387
+
388
+ The live side may now be the `id` kind; the declared side must still be `text`. That asymmetry is deliberate and measured: widening both sides makes a declared `id()` compare its rendered width (`VARCHAR(64)` on the mysql family, from a declaration with no length) against the live one, which churns an operation no apply closes.
389
+
390
+ The guard is the part that generalises. The planner and the fingerprint now answer one question over a matrix that spans primary-key spellings, defaults, enum membership, foreign-key rules, nullability and widths: a row where one plans nothing while the other says the schema moved fails the suite. Each row declares which verdict it expects, so a fixture cannot quietly test the opposite case, and breaking any one equivalence on one side only is red.
391
+ - **A value added to a native enum reaches the database** — `@voltro/database`
392
+
393
+ Adding a label to a `dbEnum()` declaration planned nothing, and the first write of that value in production came back `invalid input value for enum`. The declaration promised a label the database refused — silent where it could be fixed, loud where it could not.
394
+
395
+ The reader reports an enum's labels now, the planner emits `add-enum-value` for each one the type is missing, and the applier runs `ALTER TYPE … ADD VALUE IF NOT EXISTS`. It is additive for a rolling deploy: a replica on the previous release never writes the new label and never reads one it does not know.
396
+
397
+ A REMOVED label is deliberately not an operation, and the asymmetry is about consequence. A missing label breaks the next write; a lingering one breaks nothing — the app never writes it, old rows still read, and postgres has no `DROP VALUE`, so removing one means recreating the type and rewriting every column that uses it. Blocking a plan for that would stop an unrelated deployment because somebody tidied a declaration.
398
+
399
+ The label set is part of the schema FINGERPRINT, which is what makes both halves of that sentence hold. It was excluded while no reader could report the labels, and leaving it excluded once one could would have reproduced the original defect one layer out: an added label planned an operation without moving the hash, so a deployment that skipped `voltro db apply` got a clean `voltro serve` boot and failed at the first write — in the gate whose entire job is to catch exactly that. A REMOVED label, planning nothing, now puts an empty plan under two different hashes; that is reported as a named divergence rather than left as the one state no command can resolve. The rule this settles, for the next field: hash what the planner acts on, and NAME what it deliberately does not.
400
+
401
+ It is not postgres only, and the assumption that it was — no other dialect has a native enum type — hid a blocker rather than a gap. The mysql family does have one: `mapMysqlType` answered `text` for a native `ENUM('a','b')`, so a declared `enum` met a live `text`, the planner emitted an `alter-column-type` whose DDL produces the column already there, and `applyPlan` refused to record a fingerprint. **A `dbEnum` could not be migrated on any dialect but postgres** — the apply reported success and the next run started over.
402
+
403
+ All four are planned and applied now, by the three mechanisms the dialects actually have: `ALTER TYPE … ADD VALUE` on postgres, a redeclared `MODIFY COLUMN … ENUM(…)` carrying the full set on the mysql family (the labels come back in `COLUMN_TYPE`, so the reader reports the kind there too), and a recreated CHECK on mssql and sqlite, where the declaration genuinely does render as text plus a membership constraint. The enum TYPE NAME is postgres-only and is absent from the op elsewhere rather than carried as a value no database has — the mysql family's enum is anonymous.
404
+
405
+ The cross-dialect evolution scenario covers all of it: create the column, write a declared label, widen the set, write the new one, and assert the two readers still agree — on every dialect, because the postgres suite was green throughout the four-dialect blocker.
406
+ - **An array's element type is part of the schema** — `@voltro/database`
407
+
408
+ Changing `array(text())` to `array(integer())` planned nothing and moved no fingerprint. It is the quietest failure this area produces: there is no refused write to notice. Numbers written into a `text[]` column come back as STRINGS, so an app whose declaration promises `number[]` is handed a `string[]` at runtime, and nothing raises anywhere.
409
+
410
+ The reason recorded for the silence — no reader reports an array's element type — was true of the READER and not of the database. Postgres answers `data_type = 'ARRAY'` with the element in `udt_name` (`_int4`, `_text`), which it had been doing all along; the type mapper had no `ARRAY` case and the column fell into the catch-all as plain `text`.
411
+
412
+ So the same three-case split the native enum needed: postgres reports the kind AND the element, so nothing is collapsed there and a changed element is planned and hashed; the mysql family stores the whole array as JSON and mssql/sqlite as TEXT, which genuinely erase it, so those still collapse to their storage and carry the element with them. An element change is refused rather than cast, because the engine will not choose a cast and a value that does not parse fails the ALTER — with the same `.narrowedFrom(…, { using })` key every other transform the planner will not guess at already uses, proven on a real column with rows.
413
+
414
+ The element map is MEASURED rather than derived from the type names, and one entry is why: the framework emits `DOUBLE PRECISION[]` for `array(real())`, so the catalog answers `_float8`, not `_float4`. An unknown element returns nothing rather than falling back to `text` — a fallback there would make an array of some future element compare equal to an array of text, which is this defect one level down.
415
+ - **A table with an array or raw column can be migrated at all** — `@voltro/database`
416
+
417
+ Declaring `array(text())` or `raw('TEXT')` made every migration of that table fail:
418
+
419
+ ```
420
+ applyPlan: the migration did not converge. 84 operation(s) were executed
421
+ without error, but re-planning against the live schema still finds 2
422
+ ```
423
+
424
+ No reader reports either KIND back — postgres answers `text` for a `TEXT[]`, and a `raw()` column reads back as whatever its DDL produced — so the planner emitted an `alter-column-type` whose statement produces exactly the column that is already there. The apply then refused to record a fingerprint, correctly, and the next run began again from the top. Exit 1, on every dialect, for any app declaring one such column.
425
+
426
+ An array now collapses to the storage its dialect uses (`<element>[]` on postgres, JSON on the mysql family, TEXT elsewhere), and a `raw()` column's type is not compared at all: the escape hatch means the DDL belongs to the caller, and there is no general way back from what the database made of it. Both decisions are single-sourced, because the planner asks the second question at two call sites and only one of them is reachable from the convergence test.
427
+
428
+ `arrayElement` and `rawDdl` also leave the schema fingerprint. Like the type-narrowing acknowledgement before them, no reader reports either, so hashing them made a converged table part from its own database.
429
+ - **An expression index hashes the way it is planned** — `@voltro/database`
430
+
431
+ An expression or json-path index cannot round-trip its key text — a database re-spells `lower(email)` as `lower((email)::text)` — so the planner deliberately pairs those by name and uniqueness and refuses to read the keys, or the index re-emits on every plan and never converges. The schema fingerprint hashed the key text anyway. Every app declaring one therefore carried a permanently divergent fingerprint under an empty plan: nothing to apply, and two sides that never agree about whether the schema moved.
432
+
433
+ Both readers now go through one comparable form, so the key text leaves the hash exactly where the planner already refuses to read it. The predicate normalisation and the index-storage shape move into the same function rather than being spelled twice.
434
+
435
+ The agreement between the two readers is asserted directly now, over a matrix that spans primary-key spellings, defaults, enum membership, foreign-key rules, nullability, widths, expression and partial indexes, and composite uniques. A shape where one side plans nothing while the other says the schema moved fails the suite.
436
+ - **An external api `url` without a path reaches the api's WebSocket** — `@voltro/cli`
437
+
438
+ A web app that declares an api by `url:` (a package that is not a workspace dep, or a cross-origin api) had to spell the api's WebSocket path itself: `url: 'https://api.example.com'` was handed to the browser as `ws://api.example.com/`, the api upgrades only on its `/ws` path, and the page sat in `Reconnecting…` forever with nothing naming the cause. An origin without a path now resolves to that api's default `/ws`; a url that carries a path is used as written, so `wss://api.example.com/ws` and a custom `transport.wsPath` are unchanged.
439
+ - **An HNSW index converges when pgvector lives outside the public schema** — `@voltro/database`
440
+
441
+ An app that pins its schema with `DB_SCHEMA` never finished booting once it declared an HNSW index. The connection's `search_path` points at that schema, and the framework's own `CREATE EXTENSION IF NOT EXISTS vector` is unqualified — so the extension, and the operator classes it brings, are created THERE rather than in `public`.
442
+
443
+ Introspection spelled an operator class outside `public`/`pg_catalog` as `<schema>.<opclass>`. The declared side has no catalog to ask: it maps the distance metric to a bare `vector_cosine_ops`, which is also the token the DDL writer emits. The two spellings could never match, so every plan proposed `drop-index` + `add-index`, executed both, and re-planned the same two against a database that already matched the declaration. A plan that does not converge records no fingerprint, so the next boot did it again — indefinitely, with the migration reporting the framework bug itself.
444
+
445
+ An operator class is now spelled bare when it resolves unqualified from the connection's `search_path`, and qualified only when it genuinely does not. That subsumes the previous `public`/`pg_catalog` case and still distinguishes a class that is actually out of reach, so a declaration and an existing index describe the same physical shape wherever the extension was installed. Verified against a live pgvector in all three directions: the schema on the path reads bare, the same schema off the path stays qualified, and a `pg_catalog` class is unchanged.
446
+ - **The id column kind carries the storage it was inferred over** — `@voltro/database`
447
+
448
+ Introspection reports the `id` column kind for any sole primary key named `id` — a NAME-based inference, because a database column cannot carry a DSL spelling. The kind alone therefore erased the one distinction that matters when the two sides disagree: a minted string key and a sequence both read back as `id`, so a declaration that changed from one to the other compared EQUAL and the type change was never planned.
449
+
450
+ Every dialect reader now states the storage it inferred over, derived from the raw type it had already read, and the declared side derives the same answer from its `idScheme`. BOTH readers consult it: the planner pairs the two spellings of a primary key only when they are made of the same thing, collapses the kind to what it stores so a real conversion is planned as one, and the fingerprint hashes the same distinction — a difference one of them sees and the other does not is the zero-operations-under-two-hashes outcome this package treats as a defect in itself. A snapshot that predates the field falls back to evidence rather than to a default: a postgres BIGSERIAL says what it is through its implicit sequence.
451
+
452
+ This is the half the previous fix left open. Pairing the spellings closed the loop that blocked every migration; it did not remove the guess underneath, and an approximation nobody can see is the kind that outlives the defect that introduced it.
453
+ - **Switching an index to an expression index is planned, not a deadlock** — `@voltro/database`
454
+
455
+ Changing `.index(['email'])` to `.expressionIndex('…', [{ expr: 'lower("email")' }])` moved the schema fingerprint and planned NOTHING — the same unresolvable pairing a vector's dimension produced, one level up. `voltro db apply` has no operation, so it never records the new hash, and `voltro serve` refuses the boot for as long as the declaration stands.
456
+
457
+ The shortcut that caused it looks right and conflates two claims. An expression index's KEY TEXT genuinely cannot round-trip: a database re-spells `(lower("email"))` as `lower(email)`, so comparing the strings re-emits the index forever. The FLAG is a different thing — both sides set it, and the fingerprint hashed it. The planner skipped the column comparison as soon as EITHER side was an expression index, which also skipped noticing that one side was and the other was not.
458
+
459
+ Now the flag is compared and the text still is not: the key comparison is skipped only when BOTH sides are expression indexes.
460
+
461
+ Found by the index-level twin of the column field guard, which asks of every field an `IndexSnapshot` carries whether changing it is planned and whether it moves the hash. Indexes had never been asked: `sameIndexShape` compared five things and the fingerprint hashed nine.
462
+ - **An operator workflow start carries the system subject** — `@voltro/cli`, `@voltro/protocol`
463
+
464
+ `voltro workflows start` and `POST /_voltro/inspect/workflows/start/<tag>` answered `500 Workflow placement requires explicit caller identity`: since runs are placed by their caller's tenant, a start needs an identity, and the operator facade passed none. It now starts under the system subject — the identity schedules and webhook triggers already use — with `source: 'inspect'`. Separately, the dashboard's default "all replicas" selection painted "This view must compare observations per replica" over every workflow tab of a single-replica api; one answering replica is now the view, and the refusal remains for two or more.
465
+ - **Cancelling a flow run closes the row and the execution together** — `@voltro/plugin-ai-flows`
466
+
467
+ `cancelFlow` marked the run row cancelled and left the workflow execution suspended; cancelling through the workflow facade did the opposite. One operation now does both — the row first (with `cancelledAt` and `completedAt`), then the execution — and reports `engine: 'cancelled' | 'unreachable'`. The other direction converges too: a run cancelled through the workflow facade (the dashboard's cancel) is written to the engine's run row, and the plugin's change tap marks the flow row cancelled from it — so both paths end in one state, whichever was used. The run-start patches are guarded on `startedAt`, so a replay after a suspension no longer rewrites when the run started.
468
+ - **`voltro dev` boots an app that has a wire-reachable workflow** — `@voltro/cli`
469
+
470
+ `voltro dev` crashed at boot with `Cannot access 'extraErrors' before initialization` for any app whose discovery found at least one workflow that is startable over the wire. The plugin error union was declared after the closure that lifts each workflow into its start rpc, and `tsc` accepts that read because it happens inside an arrow function — so discovery, codegen and the access audit all passed before the dead zone threw. The declaration now precedes its first reader. `voltro serve` was never affected.
471
+ - **Every wire rpc carries the plugin error union, on both boot paths** — `@voltro/cli`
472
+
473
+ The generated client group unions the plugins' cross-cutting error schemas into every lift, but the two server paths did not agree with it or with each other: `voltro dev` bound the undo, approval and connection built-ins WITH the union (they ride the user procedure lists) and every event and workflow built-in without, while `voltro serve` bound streams, events and every built-in without. Against an app with a plugin that declares an error schema, the browser therefore reported `the api client this page loaded does not match what the server binds: __voltro.undo.apply, __voltro.undo.log, __voltro.undo.redo and 1 more` on every page load, and the advice in that message — reload, clear `.framework/.vite` — could not help, because nothing was stale. One rule now, on both paths and in codegen: a plugin refusal can hit any request, so every rpc on the wire decodes it typed.
474
+ - **Image generation survives gateway metadata without images, and takes maxRetries** — `@voltro/ai`
475
+
476
+ A normal Gemini answer through the gateway (`providerMetadata.google` carrying grounding but no `images`) made the SDK's aggregation throw `metadata.images is not iterable` after the call had been charged. `generateImage` now gives every provider entry an images list before the SDK aggregates, and rebuilds the top-level `providerMetadata` from the calls, so a grounding block is no longer reduced to `{ images }`. `maxRetries` is passed through; a retried 500 is a paid retry, so set it where the caller retries itself.
477
+ - **`@effect/experimental` is declared where `@effect/sql` needs it as a peer** — `@voltro/database`, `@voltro/protocol`
478
+
479
+ `@effect/sql` lists `@effect/experimental` as a required peer, and the two packages that depend on `@effect/sql` did not carry it — every install of a consumer printed `missing peer @effect/experimental` while resolving correctly. Both declare it now; the warning is gone.
480
+ - **The in-memory store compares a Date against its ISO text by instant** — `@voltro/database`, `@voltro/runtime`
481
+
482
+ `gte('startsAt', someDate)` against rows a fixture wrote with ISO strings matched nothing in `InMemoryDataStore`: the evaluator compared `Date` and `string` as different types and returned "unknown". A driver decodes the column to a Date and compares instants; the evaluator does the same now when one side is a Date and the other an ISO-shaped string. `eq` follows the same rule.
483
+ - **voltro migrate / db apply create the vector and postgis extensions** — `@voltro/database`
484
+
485
+ `CREATE EXTENSION IF NOT EXISTS vector` (and `postgis`) was emitted by the bring-up path only, so a `voltro migrate` or `db apply` against a fresh database reached `CREATE TABLE … VECTOR(n)` before the extension existed and operators put the statement in a shell script in front of it. One function now serves both paths; the plan applier runs it before the first statement.
486
+ - **The flow cadence tick skips start reconciliation inside a transaction** — `@voltro/runtime`, `@voltro/plugin-ai-flows`, `@voltro/data-transfer`, `@voltro/plugin-auth`, `@voltro/cli`
487
+
488
+ A cadence tick driven from inside a mutation transaction reported every pending flow row as a reconciliation failure, because the transactional workflow facade refuses to observe a start whose receipt is provisional until commit. The facade now says so (`startsDeferredUntilCommit`) and the reconciler leaves those rows to the next tick outside a transaction. The two new framework tables are classified for cross-environment transfer (`_voltro_devices` stays with its deployment, `_voltro_tenant_selections` travels as app data), the nullability manifest carries them, and the session revocation tests pin the refusal a rejected credential receives.
489
+ - **The change-trigger drift report names the schema, channel and count it compared** — `@voltro/database`
490
+
491
+ "N reactive table(s) have NO change trigger" was reported by a boot eleven seconds after the migrate job had installed them, and nothing in the line said what had been compared. It now ends with the schema it looked in, the channel the names were derived from, and how many `framework_changes_*` triggers were present there — the three facts that decide the verdict.
492
+ - **The workflow built-ins declare their access and answer for the caller** — `@voltro/protocol`, `@voltro/runtime`, `@voltro/cli`, `@voltro/client`
493
+
494
+ Under `security.defaultDeny` every workflow built-in — `__voltro.workflow.runs`, `.run`, `.run.steps`, `.run.events`, `.domainEvents`, `.event.deliveries`, and the cancel / resume / signal / update / control procedures — was refused for every caller, because none declared an access decision. So `useWorkflowRuns`, `useWorkflowRunSteps`, `useWorkflowRunEvents`, `useWorkflowSignal` and the rest of the client's workflow hooks were dead in every secured app. They now declare `openAccess` and decide in the executor: the read feeds keep only the rows the caller may see — a system subject or one holding `admin:full` sees every run, an authenticated subject the runs of its own tenant, an anonymous caller none — and a step or event feed of a run the caller may not see is empty rather than an error, so run ids cannot be enumerated through it. The control procedures keep refusing a caller who is not authenticated in the target run's tenant. The same six executors serve `voltro dev` and `voltro serve`.
495
+ - **Every table the workflow drainer reads is created by the migrator** — `@voltro/cli`, `@voltro/workflow`
496
+
497
+ `_voltro_workflow_restart_requests` and `_voltro_workflow_pool_permits` were declared and read by the admission drainer on every tick, but neither was in the framework table roster, so `voltro db apply` and a boot never created them. On a freshly migrated database the drainer's read failed once a second, the error was dropped, and the log showed `workflow admission: physical recovery incomplete` for as long as the process lived. Both are created for workflow apps now, on every dialect. `_voltro_rebac_tuples` — the table the default relationship tuple source reads — had the same gap and is created for every app. A test derives the roster from the declarations, so a declared framework table can no longer be left out.
498
+ - **`useWebhooks` names the two file conventions that wire the service** — `@voltro/plugin-webhooks`
499
+
500
+ The `ctx.webhooks is not set` error told the reader to add the plugin to `app.config.ts` and "declare at least one `*.webhook.tsx` file". Neither was the fix: the service is wired from discovery, not from the plugin list, and an OUTGOING event in a `*.webhook.tsx` file is exactly what discovery ignores (the walker warns `no descriptor default-exported` and the outgoing list stays empty). The message now says what wires it — an event with a `webhook:` block in a `*.event.ts` file, or an incoming endpoint in a `*.webhook.tsx` file — and the webhooks docs open with the same split instead of "both directions in `*.webhook.tsx`".
501
+
502
+ ### Internal (no consumer-facing effect)
503
+
504
+ - **A composite key separator is written as an escape, not a raw control byte** — `@voltro/database`
505
+
506
+ The tenant-selection row key joined its two parts with a literal NUL byte rather than the `U+0000` escape. The hashed value is identical either way, so nothing a consumer can observe changes — but a source file containing a NUL is **binary** to every text tool, so `grep` and every text-based audit in the repository skipped it in silence, which reads exactly like a clean file. It is written as the escape now.
507
+
508
+ ---
509
+
42
510
  ## [0.71.1] — 2026-09-14
43
511
 
44
512
  ### Added
@@ -5,10 +5,38 @@ property of its respective copyright holders and is used under the terms of
5
5
  its license. This file is provided for attribution; it grants no rights in
6
6
  @voltro/ui-shadcn itself, which is proprietary (see LICENSE).
7
7
 
8
- Generated from the resolved runtime dependency closure (103 packages).
8
+ Generated from the resolved runtime dependency closure (104 packages).
9
9
 
10
10
  ---
11
11
 
12
+ ## @effect/experimental@0.61.1
13
+
14
+ License: MIT
15
+
16
+ ```
17
+ MIT License
18
+
19
+ Copyright (c) 2023-present The Contributors
20
+
21
+ Permission is hereby granted, free of charge, to any person obtaining a copy
22
+ of this software and associated documentation files (the "Software"), to deal
23
+ in the Software without restriction, including without limitation the rights
24
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
25
+ copies of the Software, and to permit persons to whom the Software is
26
+ furnished to do so, subject to the following conditions:
27
+
28
+ The above copyright notice and this permission notice shall be included in all
29
+ copies or substantial portions of the Software.
30
+
31
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
32
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
33
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
34
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
35
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
36
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
37
+ SOFTWARE.
38
+ ```
39
+
12
40
  ## @effect/sql@0.52.1
13
41
 
14
42
  License: MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/ui-shadcn",
3
- "version": "0.71.1",
3
+ "version": "0.73.0",
4
4
  "description": "Voltro's first-party shadcn/ui kit: Tailwind v4 design tokens (light + dark), 30+ primitives, layout compositions, styled widgets for the @voltro/ui seam, and the canonical theme/language preference-cookie helpers.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -52,7 +52,7 @@
52
52
  "@radix-ui/react-toggle-group": "^1.1.19",
53
53
  "@shikijs/langs": "^4.4.3",
54
54
  "@shikijs/themes": "^4.4.3",
55
- "@voltro/ui": "0.71.1",
55
+ "@voltro/ui": "0.73.0",
56
56
  "class-variance-authority": "^0.7.1",
57
57
  "clsx": "^2.1.1",
58
58
  "shiki": "^4.4.3",