@voltro/plugin-webhooks 0.80.0 → 0.82.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,1610 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.82.0] — 2026-10-01
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **Aggregate extrema infer source-column results and counts return numbers** — `@voltro/database`, `@voltro/cli`, `@voltro/voltro`
47
+
48
+ `min()` and `max()` in `.aggregate()` infer the selected source-column type plus null, including exact decimal strings and text. `count()` and `countDistinct()` return number instead of the broad number/date/null union. Update explicit aggregate result annotations and handle empty-input extrema. Explicit `column<T>()` projections and arithmetic/window result types are unchanged; this type correction does not add decimal SUM/AVG support.
49
+
50
+ **`voltro update` carries you across this** — codemod `0.82.0/17_aggregate-results-follow-column-types`. 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.82.0).
51
+ - **`auth.anonymous` decides what an unauthenticated caller is; serve requires it** — `@voltro/protocol`, `@voltro/cli`, `@voltro/testing`
52
+
53
+ An app that set nothing ran, in production too, on the development convenience: a request that matched no auth strategy resolved to the tenant its OWN `x-tenant` header named, else the dev tenant `acme`. Every `openAccess` procedure that read the subject's tenant therefore answered for a client-chosen tenant — a plugin's member-only reads answered for any organisation the header named. A warning at boot said so; a warning is not a decision.
54
+
55
+ `app.config.ts` now decides once, in `auth.anonymous`: `'refuse'` (no unauthenticated callers), `'no-tenant'` (the empty tenant — tenant-scoped tables read empty, tables without `tenant()` and procedures that need no tenant answer), `'header'` (the `x-tenant` header's tenant, required — only for public-per-tenant data, because every `openAccess` procedure and every table without `tenant()` is then public for a client-chosen tenant) or `{ tenantId }` (one fixed tenant, the server's choice). `voltro serve` refuses to boot until it is set; `voltro dev` runs an undecided app on the header, else the dev tenant, and says so at boot. An anonymous subject never carries a `null` tenant any more — to the store that is a system subject and reads unscoped.
56
+
57
+ `composeAuthStrategies(…, { anonymous })` takes the same policy (default `'no-tenant'`); `anonymousTenantId`, `anonymousTenantRequired` and `anonymousTenantDefault` are gone, on the composer, in `app.config.ts` and on `makeTestApp` (whose default is the dev convenience). The codemod rewrites the three options; a second note names every api config that has not decided.
58
+
59
+ **`voltro update` carries you across this** — codemod `0.82.0/22_anonymous-callers-are-the-apps-decision`. 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.82.0).
60
+ - **Billing checks who may pay and where checkout returns; checkouts name a customer** — `@voltro/plugin-billing`
61
+
62
+ `billingPlugin({ admit })` lets the app refuse a tenant before any billing call reaches the provider — a deleted or suspended organization, say — per action (`checkout`, `change`, `resume`, `portal`, `cancel`). `redirectOrigins` limits where checkout and the portal may return the customer; without it the plugin warns at boot when Stripe is active, and every return URL must be an absolute http(s) URL. `startCheckout` now creates the tenant's Stripe customer first (idempotent per tenant), names it on every session and expires the tenant's other open checkouts, so two tabs cannot both be paid. Seat quantities must be positive integers and usage quantities finite. `STRIPE_WEBHOOK_EVENTS` lists the event types the plugin maps. Migration: a custom `BillingProvider` implements `ensureCustomer` and `expireOpenCheckouts`; pass absolute return URLs.
63
+
64
+ **`voltro update` carries you across this** — codemod `0.82.0/21_billing-lifecycle-at-the-provider`. 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.82.0).
65
+ - **Billing owns the Stripe account, reconcile and production gate; adds cancel hook** — `@voltro/plugin-billing`
66
+
67
+ `StripeCatalog` + `runStripeProvision` check or provision a Stripe environment (products, prices, meters, tax settings, customer portal, webhook endpoint) from one declaration, so a sandbox and live stay the same account; objects created by hand are adopted, a price change needs `--allow-price-change`, a live write `--confirm-live`, and no key, id or secret is printed. Rows stay in step with Stripe without a timer: every webhook syncs the customer it names from a fresh read (the payload is only the trigger, and `onEvent` runs for the current state on every delivery); `syncTenant` / the `billing.sync` route is the checkpoint for a checkout or portal return and a billing page load, and every paid action runs it first; undelivered events (Stripe keeps them 30 days) are caught up at startup and after a late delivery, claimed once across replicas. `isTenantClosed` lets any sync close a deleted tenant that is still paying. `reconcileFromProvider` is an operator's full pass, never scheduled; when the provider knows none of many live subscriptions at once (the wrong account or the mock), it cancels none and reports `refusedUnknown`. A production boot — any NODE_ENV that is not development — refuses the mock by absence, Stripe without `STRIPE_WEBHOOK_SECRET` and Stripe without `redirectOrigins`; `provider: 'mock'` declares a deployment that does not sell. `useSubscriptionCancel` (`/web`) drives a two-step cancellation on the app's own page. Migration: set `provider: 'mock'` where production deliberately does not bill, pass `redirectOrigins` and the webhook secret with Stripe, and drop a cron that only reconciled; call `billing.sync` where checkout and the portal return.
68
+
69
+ **`voltro update` carries you across this** — codemod `0.82.0/21_billing-lifecycle-at-the-provider`. 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.82.0).
70
+ - **Billing cancels, resumes and reconciles at the provider; failed onEvent retries** — `@voltro/plugin-billing`
71
+
72
+ `BillingProvider` gains `listSubscriptions`, `cancelSubscription`, `resumeSubscription` and `addSubscriptionPrices`; `stripeProvider()` and the mock implement them. New on the service: `cancelSubscription` / `resumeSubscription` (period-end cancellation and its undo, also as the `billing.cancel` / `billing.resume` routes), `closeAccount` (flush usage, then end every subscription once that usage has settled on the meter, with a final invoice for it) and `reconcileFromProvider` (correct every row to Stripe's list, add missing metered prices, report tenants billed twice). `changePlan(tenant, 'free')` cancels at period end instead of sending `free` as a price id, `startCheckout` refuses a tenant that already pays, and the plan is read from the subscription's price before `metadata.plan`. A failing `onEvent` side effect now answers the webhook with 500 so Stripe redelivers it. `customer.deleted` unlinks the customer, and an ended subscription can no longer be revived or hide a live one. Migration: a custom provider implements the four members; `onEvent` side effects must be idempotent.
73
+
74
+ **`voltro update` carries you across this** — codemod `0.82.0/21_billing-lifecycle-at-the-provider`. 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.82.0).
75
+ - **CMS documents have locale variants, with a read-time fallback** — `@voltro/cms`
76
+
77
+ A content row is now one locale variant of a document: `id` names the variant and the new `documentId` column the document its variants share, with at most one variant per site, environment and locale. `saveDraft` with a `documentId` and no `id` adds a variant in the given scope (`ContentIdConflict` if that locale already has one, `ContentNotFound` if the document is not the caller's); a new document gets its own `cdoc_` id. Revisions count per variant, and `listRevisions(store, type, documentId, scope)` reads one variant's history. `cmsContext(...).resolve(type, { key, filter?, fallback? })` finds a published entry in the requested locale, else along the fallback chain, and reports `locale`, `requestedLocale` and `fallback`; a source key whose document is translated answers with the translation. Nothing is copied into the requested locale. Existing rows migrate to their own documents (`documentId = id`); `documentId` is a reserved field name.
78
+
79
+ **`voltro update` carries you across this** — codemod `0.82.0/13_cms-documents-have-locale-variants`. 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.82.0).
80
+ - **CMS drafts keep every save as an immutable revision** — `@voltro/cms`
81
+
82
+ `saveDraft` appends one immutable row to the new `cms_revisions` table on every successful save and points the draft at it through two new columns, `revisionId` and `revision` (a number per document, from 1). A published row carries exactly the revision that went live, so later edits do not reach readers until the next publish. `publish(store, type, id, { revisionId })` publishes only if the draft still shows that revision and throws the new `RevisionMismatch` otherwise; `listRevisions` reads a document's history. Register `cmsRevisionsTable` in your `databaseHandle`; existing rows migrate to revision 0 and get revision 1 on their next save. `revisionId` and `revision` are reserved field names. `<ContentForm>` also starts a literal select on an empty choice instead of showing the first option as chosen while the value is unset.
83
+
84
+ **`voltro update` carries you across this** — codemod `0.82.0/11_cms-drafts-write-revisions`. 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.82.0).
85
+ - **Experiments carry a hypothesis and an optional guardrail** — `@voltro/cms`
86
+
87
+ `startExperiment` requires `hypothesis` (what version B should change, and why; at most 500 characters; `noHypothesis` otherwise) and takes an optional `guardrailMaxDrop` (1–50 percentage points; `badGuardrail` otherwise). `experimentResults` reports `guardrail: { maxDrop, breached }` — breached once both versions have `MIN_READERS` readers and B converts more than `maxDrop` points below A. `Experiment` gains `hypothesis` and `guardrailMaxDrop` (null for experiments started before). Callers of `startExperiment` must now pass `hypothesis`.
88
+
89
+ **`voltro update` carries you across this** — codemod `0.82.0/24_cms-experiments-need-a-hypothesis`. 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.82.0).
90
+ - **The CMS kernel is Effect-native, in one form** — `@voltro/cms`
91
+
92
+ Every async function `@voltro/cms` exports returns an `Effect` whose error channel is its typed failures: the write pipeline (`saveDraft`, `publish`, `publishRevision`, `restoreRevision`, `unpublish`, `archive`, `listRevisions`), review (`requestReview`, `approveReview`, `requestChanges`, `reviewStatus`, …), releases (`createRelease`, `addToRelease`, `publishRelease`, …), scheduling, redirects, search, availability, preview shares, stewardship, bulk, environments and promotion, series, blueprints, experiments, extensions, team, AI operations and the glossary. The Promise-returning exports and the `*Effect` siblings (`saveDraftEffect`, `addToReleaseEffect`, `schedulePublishEffect`, …) are removed. In `Effect.gen`, `yield*` them; in async code, `await runProvidedEffect(…)` from `@voltro/runtime` rejects with the typed failure itself. `handleCmsRest` stays Promise-based: it is the HTTP edge.
93
+
94
+ **`voltro update` carries you across this** — codemod `0.82.0/25_cms-kernel-is-effect-native`. 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.82.0).
95
+ - **CMS publishing records reference edges, and cms.usedBy answers from what is live** — `@voltro/cms`, `@voltro/cli`
96
+
97
+ Publishing now writes the published variant's references to the new `cms_references` table — every declared `Schema.Reference`, also inside arrays, structs and component props, with the `path` where it sits — and unpublishing or archiving removes them, in the same transaction as the content change. Releases publish through the same path. `cms.usedBy(type, documentId)` (and the standalone `usedBy(store, target, scope)`) lists the live uses of a document across locales; a draft reference is not a use. `referencesOf(type, values)` derives the references from the declared fields. Register `cmsReferencesTable` in your `databaseHandle` beside `cmsRevisionsTable`; content published before that has no edges until it is published again.
98
+
99
+ **`voltro update` carries you across this** — codemod `0.82.0/14_cms-publish-writes-reference-edges`. 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.82.0).
100
+ - **CMS rich text is a structured document with typed images and links** — `@voltro/cms`, `@voltro/cli`
101
+
102
+ A `Schema.RichText()` value is now a `RichDocument` — paragraph, heading, list, table and image blocks with stable ids, and inline text with bold / italic marks and links — instead of any JSON. The options are `{ images, links }`, the content types an image block and a link reference (`allowImages` / `allowEmbeds` are gone; there are no embed nodes). A plain string is still saved, as paragraphs; JSON in another editor's shape is refused on save. Image and link references become edges on publish, so `cms.usedBy` finds them. `<ContentForm>` edits rich text with the new block editor (`RichDocumentEditor`) as a named group; pass `renderPicker` to offer images and links. `RichDocumentView` renders a document as React elements only, resolving links and images through the site. `parseInlineMarkup` / `inlineMarkupOf`, `normalizeRichDocument`, `richDocumentProblems`, `richDocumentReferences` and `plainTextOf` are exported from both entries.
103
+
104
+ **`voltro update` carries you across this** — codemod `0.82.0/15_cms-rich-text-is-a-document`. 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.82.0).
105
+ - **CMS content names its site, environment and locale on every write and read** — `@voltro/cms`
106
+
107
+ Every draft, published row and revision now stores a `CmsScope` — `{ site, environment, locale }` — beside its tenant, and every call takes it explicitly: `saveDraft(store, type, values, scope)`, `publish(tx, type, id, scope, options?)`, `unpublish` / `archive(tx, type, id, scope)`, `schedulePublish(store, type, id, at, scope)`, the `…Effect` siblings, `cmsContext(store, types, { scope })` and `createCmsClient({ store, types, scope })`. A row in another scope reads as absent, like another tenant's, and a pinned id is never moved across scopes. `Schema.unique()` fields are unique per tenant and scope. `publishDue` publishes each due row in its own scope. Existing rows migrate with an empty scope; re-save them or set the three columns before deploying. `site`, `environment` and `locale` are reserved field names.
108
+
109
+ **`voltro update` carries you across this** — codemod `0.82.0/12_cms-scope-on-every-write-and-read`. 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.82.0).
110
+ - **CMS publish, unpublish and archive run in the caller's transaction** — `@voltro/cms`
111
+
112
+ `publish`, `unpublish` and `archive` opened a transaction of their own, which every store refuses inside another one, so a publish from a mutation failed on postgres. They now run in the transaction they are handed: inside a mutation `ctx.store` already is one, and the published copy commits or rolls back with the rest of the mutation. Outside a mutation, wrap the call in `store.transactional((tx) => publish(tx, type, id))`. A content type's declared `revalidate` paths and tags no longer fire from inside these ops, because a purge has to follow the commit: call the new `revalidateContent(type)` after it, for example from a subscriber on `<type>_published`. `publishDue` gives each due row its own transaction and fires the revalidations after its commits.
113
+
114
+ **`voltro update` carries you across this** — codemod `0.82.0/10_cms-writes-run-in-the-caller-transaction`. 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.82.0).
115
+ - **A declared `utf8mb4` reaches every table the framework creates** — `@voltro/database`, `@voltro/cli`
116
+
117
+ With `store: { charset: 'utf8mb4' }` declared on MySQL or MariaDB, `_voltro_schema_owner` and — when a file migration ran first — `_voltro_migration_ops` were created in the database default and converted afterwards by `voltro db apply`, which then listed a table it had just created among its conversions. Every bookkeeping table is now created in the declared charset by whichever step creates it first, and `voltro migrate` creates the declared tables with it as well.
118
+
119
+ For code that calls the `@voltro/database/sql` helpers itself: `ensureLedgerTable`, `ensurePlanLedgerTable`, `ensureSchemaOwnerTable`, `ensureDeployLedgerTable`, `ensureReplaceMarkerTable` and `transferSchemaOwner` take the charset as a required argument, `checkSchemaOwner` and `runFileBasedMigrations` in their options — the declared charset, or `undefined`. An optional parameter is how two of the five tables came to be created without it.
120
+
121
+ **`voltro update` carries you across this** — codemod `0.82.0/01_bootstrap-helpers-take-the-store-charset`. 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.82.0).
122
+ - **Deferred media polls, retries and expired leases no longer wait for a write** — `@voltro/runtime`, `@voltro/protocol`, `@voltro/ai`, `@voltro/cli`, `@voltro/voltro`
123
+
124
+ On a deployment where remote writes are visible (Postgres change data capture, the MariaDB binlog reader, or a broadcast broker), the offloaded-inference dispatcher stops ticking between arrivals. It stopped too early: a `mediaStep` through a registered media task provider that was still running at its first poll went back to the queue with its next poll a few seconds ahead, the dispatcher went idle, and nothing ever polled it again — the row stayed `pending` until an unrelated write to the queue table happened to wake it. A backed-off retry, a row whose worker was lost (its lease running out), and a row an earlier process left behind when the first ticks ran before the workflow runtime was up had the same gap. Each dispatcher tick now reports when its next clock-driven work is due, and the dispatcher is armed for that instant. The outbox worker does the same for a claim whose deliverer was lost, and is armed for the moment that claim's lease runs out.
125
+
126
+ The fix is in the coordinated schedule itself, so it covers the next task too. With `disarmWhenIdle`, a tick disarms only when it returns `{ idle: true, nextDueInMs: null }`, meaning "nothing is scheduled; new work can only arrive as a write". A plain `{ idle: true }` now backs off toward the ceiling instead of disarming. A task that has not said what is coming can get slower, but it can no longer stop for good. A known `nextDueInMs` under `disarmWhenIdle` arms the task for exactly that instant. A plugin's `ctx.scheduleCoordinated(name, intervalMs, effect, { disarmWhenIdle: true })` now reaches the schedule with that option; before, both `voltro dev` and `voltro serve` dropped the fourth argument. Return `nextDueInMs: null` from an idle tick for the task to actually stop. Breaking for code that passes `disarmWhenIdle` (it keeps working but ticks at the ceiling until it returns `null`) and for a reader of `inspectCoordinatedSchedule(...).lastOutcome.nextDueInMs`, which can now be `null`; `voltro update` lists the lines.
127
+ - **defineExecutor checks the failure channel against the procedure's error union** — `@voltro/runtime`, `@voltro/protocol`, `@voltro/cli`, `@voltro/voltro`
128
+
129
+ `defineExecutor(descriptor, fn)` checked only the success value, so an executor whose Effect could fail with a tagged error the procedure never declared compiled — and the caller received `InternalError` (mutation, action) or a tag its typed union did not know (query). Every tagged failure of `fn` must now be in the procedure's wire union: its `error:`, the framework errors merged for its kind (`FRAMEWORK_WIRE_ERRORS`), or an error a plugin registers through `errorSchemas`. The codegen declares those plugin errors to the compiler by augmenting `VoltroPluginErrors` in `rpcGroup.generated.ts`. A violation is a compile error at the handler naming the tag and the three fixes: declare it, register it app-wide, or map it away (`Effect.catchTag('X', Effect.die)` for a defect). Untagged failures (`Error`, `unknown`, `Effect.tryPromise` without `catch`), the store's own refusals (`StoreError` — undeclared, they still reach the caller as `InternalError` carrying their own sentence) and Promise-returning executors are not checked; measured on a real app, that leaves the handlers' own tags — 17 findings instead of 564. A hand-built descriptor passed to `defineExecutor` now needs an `error` schema.
130
+
131
+ **`voltro update` carries you across this** — codemod `0.82.0/09_define-executor-checks-the-failure-channel`. 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.82.0).
132
+ - **`voltro db encrypt-column` reads the key the app runs with** — `@voltro/cli`
133
+
134
+ `voltro db encrypt-column` read `FIELD_ENCRYPTION_KEY` by default, a name nothing else reads, so following the docs ended in "FIELD_ENCRYPTION_KEY is not set". It now reads `VOLTRO_FIELD_ENCRYPTION_KEY`, and `VOLTRO_FIELD_ENCRYPTION_KEY_PREVIOUS` during a rotation (`--previous-key-env` names another variable). Messages and docs that recommended a `secret` or a `key` field inside `fieldEncryption` — neither exists; the option is `secretKey` — now name the variable, and a test fails if a message names an option that does not exist.
135
+
136
+ **`voltro update` carries you across this** — codemod `0.82.0/03_encrypt-column-reads-the-framework-key`. 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.82.0).
137
+ - **An encrypted workflow's step journal is encrypted — it was not since 0.71.0** — `@voltro/workflow`, `@voltro/cli`
138
+
139
+ Since 0.71.0, `encryptSteps: true` refused to boot without a key but did not encrypt `_voltro_workflow_run_steps`: both boot paths handed the cipher to an option of the run wrapper, and the recorder they passed alongside it was built without it, so step inputs, outputs and error causes were stored in plaintext. The step's own body now tells the recorder to seal — `encrypted: true` (the renamed option) covers the step journal on every boot path, measured against a real `voltro dev` and the database file. `encryptStepPayload` is gone from the recorder options. Existing plaintext step rows stay as they are.
140
+
141
+ **`voltro update` carries you across this** — codemod `0.82.0/04_recorder-seals-from-the-workflow`. 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.82.0).
142
+ - **Inference jobs retain their original enqueue timestamp** — `@voltro/ai`, `@voltro/cli`, `@voltro/devtools-ui`
143
+
144
+ Persist the original enqueue timestamp separately from the mutable next-due timestamp. Provider polling, retries and lease reclaim preserve the original value. Existing jobs whose original timestamp was not recorded retain an unknown value instead of receiving the schema-update time.
145
+
146
+ Inference Inspect and shared dashboard rows now expose `waitingMs: number | null`. Custom consumers must render null as unknown. Queue order and long-wait counts use original enqueue time; jobs with unknown timing follow known ages and are reported separately in `counts.unknownTiming`. Completed jobs show total elapsed time rather than the duration of the last provider call.
147
+
148
+ **`voltro update` carries you across this** — codemod `0.82.0/18_inference-elapsed-time-can-be-unknown`. 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.82.0).
149
+ - **Inspect proxies lend their tokens only to this machine or a signed-in session** — `@voltro/cli`
150
+
151
+ The web dev server's `/_voltro/api/<name>` route — the one the in-page devtools overlay reads its api through — attached the api's inspect bearer to every request that reached it whenever the api was on loopback. The dev server listens on every interface, so a LAN peer, or anyone behind an ingress in front of a dev pod, read the api's traces (SQL, logs, workflow payloads) with no session. The dashboard proxy, its upload/download relays and its live stream did the same for anyone who reached an unlocked dashboard. All of them now attach a token they hold only for a requester they trust: this machine over a direct loopback connection that no proxy relayed and addressed as `localhost` (a request carrying `X-Forwarded-For`, `Forwarded`, `X-Real-IP` or a similar header is not, and neither is one whose `Host` is not a loopback name — what a header-less forwarder such as socat or `ssh -R` passes through), or a session signed in under `VOLTRO_DASHBOARD_TOKEN` — and once that lock is set, only a session, loopback included. Anyone else gets `401 inspect proxy refused` with a `reason` and a `fix` instead of the data; a request that brings its own `Authorization` is forwarded as before, and the page's own `/rpc` traffic on that prefix is neither refused nor handed the inspect token any more. Migration: if you open the overlay or a local dashboard through a reverse proxy, a dev gateway or a hosted tunnel, set `VOLTRO_DASHBOARD_TOKEN` (16+ characters, `voltro secret generate dashboard`) on that server and sign in once — from the overlay's refusal line, or the dashboard's login page. `localhost`, an SSH tunnel and `kubectl port-forward` need nothing.
152
+
153
+ **No codemod** — this break touches no user-authored code.
154
+ - **Restore only the exact snapshots recorded by a migration plan** — `@voltro/database`, `@voltro/cli`
155
+
156
+ `voltro db restore-snapshot` now uses recorded soft-drop object identities and refuses missing identities instead of choosing the newest matching snapshot. The shared restoration operation holds the migration lock, validates every source and destination before DDL, uses dialect-aware rename operations and preserves Timeline capture on preflight failure.
157
+
158
+ Migration: histories without `softDropSnapshot` require explicit database recovery after verifying retained objects against the intended migration and backups; automatic ownership inference is unsafe. No user-authored source code needs rewriting. Transactional-DDL dialects restore atomically; implicit-commit dialects can retain completed renames after an unexpected execution failure.
159
+
160
+ **No codemod** — this break touches no user-authored code.
161
+ - **Snapshot restore acts on confirmed, uniquely owned snapshot sets** — `@voltro/database`, `@voltro/cli`, `@voltro/devtools-ui`
162
+
163
+ The local migrations dashboard restores explicitly confirmed, uniquely owned snapshot sets, including snapshots outside recent history; duplicate historical ownership refuses restoration. Custom `softDropSnapshots` consumers must use `kind`, `originalName` and `snapshotName` instead of `column` and `sidecarColumn`, and restore providers return both `restoredColumns` and `restoredTables`. The upgrade codemod identifies custom consumers for manual review; ordinary schema declarations need no changes.
164
+
165
+ **`voltro update` carries you across this** — codemod `0.82.0/19_migration-snapshot-objects`. 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.82.0).
166
+ - **Migration squashing previews the selection and requires its confirmation token** — `@voltro/database`, `@voltro/cli`, `@voltro/devtools-ui`
167
+
168
+ SQL migration squashing now offers a complete read-only selection preview and an optional confirmation token. A changed eligible history set refuses the confirmed write under the migration lock without altering history. SQL dev and serve hosts expose an authenticated read preview and require its confirmation token for standard-host squash writes.
169
+
170
+ The local dashboard now previews and confirms a complete selection on one replica. Editing the cutoff, note, or target discards confirmation; failed writes require another preview. Custom MigrationsPage providers must supply usePreviewSquash alongside useSquashHistory and forward expectedPreview to the host. The confirmation count no longer comes from capped displayed history.
171
+
172
+ Applied history includes source squash-snapshot and preserves the recorded environment string. Shared history types and the read-only RPC contract accept these entries instead of rejecting a dashboard-created snapshot.
173
+
174
+ The preview is a process-scoped Inspect read with the selected replica's origin, including cross-process forwarding. Synthetic history metadata reports zero schema operations instead of omitting the operation count.
175
+
176
+ **`voltro update` carries you across this** — codemod `0.82.0/20_migration-squash-confirmation`. 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.82.0).
177
+ - **A resource guard can answer "no such resource" with a typed NoSuchResource** — `@voltro/protocol`, `@voltro/runtime`, `@voltro/voltro`
178
+
179
+ A relationship guard (`{ action, resourceType, resource }`) on an id that does not exist could only refuse with `ScopeError`, so an id-route answered "not permitted" before its executor could say "not found". A tuple source may now return `'missing'` instead of a tuple list — and `setPolicyGuardResolver` / `setResourceScopeResolver` resolvers may answer `'missing'` — and the guard refuses with the typed `NoSuchResource` (`{ resourceType, resourceId, message }`, from `@voltro/protocol`; `resourceType` is `null` for a scope guard with a `resource` extractor). It is merged into the wire union of every query, mutation, action, stream, event and workflow start whose guards name a resource, and a `publicApi` REST projection answers it with `404`. Disclosure stays the app's decision: an empty tuple list is still "no relation", an anonymous caller never receives `NoSuchResource`, and `admin:full` bypasses before the source is asked. Only a verdict of exactly `true` allows now; a resolver returning any other truthy value refuses. Breaking for code that consumes a guard's answer: `checkGuardsEffect` resolves `ScopeError | Unauthenticated | NoSuchResource | null`, and the resolvers/source returned by `getPolicyGuardResolver`, `getResourceScopeResolver` and `getTupleSource` may yield `'missing'`. Registering a source or resolver needs no change.
180
+
181
+ **`voltro update` carries you across this** — codemod `0.82.0/08_resource-guards-report-a-missing-resource`. 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.82.0).
182
+ - **A failed subscription or loader read hands over the declared error itself** — `@voltro/client`, `@voltro/protocol`, `@voltro/web`, `@voltro/cli`, `@voltro/devtools`
183
+
184
+ A subscription refused with the error its descriptor declares reached the app in two shapes: `useOnRpcError` received the error itself, while `useSubscription`'s `error` was an Effect `Cause` wrapping it (`{ _tag: 'Fail', error }`), so every `_tag` switch on the hook read `Fail` and a missing permission rendered as an internal failure. Every client surface now hands over the same value the error bus carries: `useSubscription` (with or without `fallback` / `initialSnapshot`), `useRecord`, `useDataTable`, `useWindowedSubscription`, the headless cache snapshot and `ctx.apis.<name>.subscribe`'s `onError`, next to `useMutation` / `useAction` (`error`, the rejected promise, `onError`), which already did. A declared failure is the declared error (match on `_tag`, or `errorTag(err)`), a transport failure is an `RpcClientError`, a defect is the value the handler died with. A loader's `ctx.query` follows the same rule: a refused query no longer rejects with an Error carrying the typed error on `voltroError` but with the typed error itself, and on the server a failed call rejects with its declared error instead of an untagged Error with the cause as JSON — which also gives a no-JS form post the declared error's message instead of a generic one. The dev overlay's subscription panel names the error's tag and message. `voltro update` lists every `voltroError` read and every hand-written `Fail` / `Die` unwrap.
185
+ - **`workflow({ encrypted })` seals every durable copy of a run's data** — `@voltro/workflow`, `@voltro/cli`
186
+
187
+ `encryptSteps: true` encrypted the step journal and nothing else, while the payload sat in plaintext in the engine journal (`cluster_messages`), every step result in `cluster_replies`, and the payload and result again in `_voltro_workflow_runs`. The option is now `encrypted: true` and seals all of them: each payload field, the result and a typed failure in the engine journal, each step's result and typed failure there, the run row's payload and result, the step journal, and the payload or result a queued start, a failure report or a control result copies — on every SQL dialect, with no database change. The body, the live run rows, `listRuns` and the dashboard receive the values opened; a value written in plaintext before the declaration is still read; an encode without a registered cipher fails instead of writing plaintext. An encrypted workflow's `onFailure` handler must be encrypted too, or the boot refuses and names both. Keys your code derives (`debounce.key`, `batch.key`, `concurrency.key`) are not covered. `voltro update` renames `encryptSteps` to `encrypted` — on each `workflow()` declaration and on each read of the resolved declaration (`getWorkflowFlowControl(w)?.encryptSteps`) in a file that imports `getWorkflowFlowControl`.
188
+ - **Add durable workflow restart commands and read-only receipts** — `@voltro/cli`, `@voltro/workflow`, `@voltro/protocol`, `@voltro/devtools-ui`
189
+
190
+ Durable workflow hosts expose an Inspect restart command with a persisted request ID and explicit failed-step or from-step selection. It reserves the resident generation and shared capacity before resetting the engine journal. The existing recovery worker also revisits pending restart requests, including after host restart or capacity waiting.
191
+
192
+ Read-only receipts distinguish missing, pending, and recorder-entered requests. A dispatch response does not claim workflow success. Repeated requests retain their original generation and selection, and receipts omit internal ownership tokens and journal reply IDs. The run detail and workflow-list panels persist the immutable command before sending and retains it across navigation and lost replies. It distinguishes waiting for capacity from confirmed engine entry and keeps read-only receipts visible after the run changes status or leaves a list filter. Unsent step selections survive live refreshes in the same inspection scope. Custom `WorkflowRunPage` and `WorkflowsPage` providers replace `useResumeFromStep` with `useRestartRun` and `useRestartReceipt`; the step-resume input/result types are replaced by the protocol restart command/result types.
193
+
194
+ Version and step metrics remain horizontally scrollable inside keyboard-accessible regions on narrow screens instead of overflowing the dashboard.
195
+
196
+ Replace `voltro workflows resume-from-step RUN STEP` with `voltro workflows restart RUN --request-id ID --step STEP`; save ID before sending and use `--observe` to read its receipt. The reset-only Inspect endpoint and serve handle are removed.
197
+
198
+ Single-run `voltro workflows redrive` and the direct Inspect redrive endpoint are replaced by `restart --request-id ID --failed-steps`. The caller persists the request identity and observes engine entry instead of interpreting journal resets as completion.
199
+
200
+ Restart identity preparation validates complete selections before storage writes, retains partially saved identities on quota failure without sending any operation, and bounds retained identities at 2,048 without eviction.
201
+
202
+ Bulk redrive now submits durable failed-step restart commands with saved per-run identities. Dashboard reload recovery and CLI plan-file observation read the same receipts; reservation and engine entry are never counted as completed workflows. The shared bulk result carries `restarts`, custom panels persist through `submitRestarts`, and the admin dashboard now exposes the same bulk panel.
203
+
204
+ Production `voltro serve` now forwards both restart mutation and read-only observation into its Inspect manifest. A real precompiled-bundle crash/restart test verifies the same receipt and execution generation survive a hard process termination without a second restart command.
205
+
206
+ Run details retain the open restart form and confirmed step snapshot during live refresh and connection errors. Receipt recovery stays mounted during an outage, without carrying stale run or step data across inspected scopes.
207
+
208
+ **`voltro update` carries you across this** — codemod `0.82.0/16_workflow-run-restart-receipts`. 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.82.0).
209
+ - **Distinguish permanently withdrawn restart requests** — `@voltro/protocol`, `@voltro/workflow`, `@voltro/cli`, `@voltro/devtools-ui`
210
+
211
+ Restart observations include `withdrawn` for permanently fenced request identities. It carries the original workflow, selection and withdrawal timestamp, without an execution or generation. Custom readers must handle this variant and validate its workflow and selection as well as run and request IDs.
212
+
213
+ The shared dashboard retains confirmed withdrawal over delayed nonterminal reads, allows dismissing that saved identity, and refuses contradictory reservation evidence. Bulk adapters validate and display withdrawal without claiming success. Inspect exposes `POST /_voltro/inspect/workflows/runs/:id/withdraw-restart` with the saved request ID and original selection under write authorization. Repeated commands reconcile against retained ownership; already reserved or entered work is returned unchanged. Both dev and production hosts use the same resident transaction adapter. The shared dashboard and local, customer, and admin providers submit withdrawal with the saved identity, retain uncertain outcomes across reloads, and require confirmation before choosing new intent. Already accepted work keeps running.
214
+
215
+ **`voltro update` carries you across this** — codemod `0.82.0/16_workflow-run-restart-receipts`. 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.82.0).
216
+ - **Use durable restart requests from workflow contexts** — `@voltro/runtime`, `@voltro/cli`
217
+
218
+ Replace `ctx.workflows.redrive(runId)` and `WorkflowRedriveResult` with `restart(runId, { requestId, selection })` and `WorkflowRestartResult` from `@voltro/protocol`. Save the identity and selection before sending. Use `readRestart(runId, requestId)` after a lost reply; `withdrawRestart` permanently fences an unreserved identity and preserves already accepted work.
219
+
220
+ Development and production use the same resident reservation and engine-entry receipts as the dashboard. No journal reset is reported as engine entry. These calls refuse mutation transactions; invoke them after application commit. Custom facade adapters provide restart, readRestart and withdrawRestart.
221
+
222
+ **`voltro update` carries you across this** — codemod `0.82.0/16_workflow-run-restart-receipts`. 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.82.0).
223
+
224
+ ### Added
225
+
226
+ - **`voltro build api --target executable` — the api as one Node binary** — `@voltro/cli`
227
+
228
+ After `voltro build`, `voltro build api --target executable` packages the serve bundle as a Node single-executable (SEA): one binary with a CommonJS launcher injected, the bundle copied beside it, and a `manifest.json` with the binary's sha256, Node version, platform and the native leaves the app reaches (those still resolve at runtime from the app's `node_modules`). `--out`, `--name`, `--no-strip`, `--json`. Built for the platform it runs on; needs `postject` (new optional dependency) and `codesign` on macOS. This is the desktop Local Stack sidecar.
229
+ - **Release artefacts go into the api's own storage** — `@voltro/cli`
230
+
231
+ `voltro desktop publish` without `--base-url` and `voltro release upload --file <path>` put the artefact's bytes into the api's `@voltro/plugin-storage` (public) and record the served url, hash and size on the release feed — the generated desktop and mobile workflows do this by default, so a release needs no host of your own. `--base-url` / `--url` keep working for a host you run. An api without the storage plugin is named, with the way out.
232
+ - **authRoutesPlugin({ admit }) decides who may join a tenant** — `@voltro/plugin-auth`
233
+
234
+ `admit({ email, tenantId, via })` is asked before a sign-up creates an account and before an invitation is redeemed — with a new account or an existing one. Returning `{ reason, message }` answers 403 `{ error: 'not_admitted', reason, message }` with nothing written: no account, no membership, and the invitation stays good. It may return an `Effect` or a `Promise`, and gets `{ store }` — the app database the plugin is bound to — to count what the tenant holds; the seam for seat limits, invite-only tenants and address allow-lists. `AdmitJoining`, `Joining`, `AdmissionContext` and `AdmissionRefusal` are exported.
235
+ - **authRoutesPlugin keeps users in the app's own database** — `@voltro/plugin-auth`
236
+
237
+ `authRoutesPlugin({ store })` also accepts a resolver the plugin calls once the framework has opened the app's database. `appUserStore()` is the one to use: `postgresUserStore` over the framework's pool on postgres, the in-process store on `store: 'memory'`, and a boot failure on any other dialect instead of accounts that vanish on restart. Register `authTables` from `@voltro/plugin-auth/schema` in your `databaseHandle` so auto-migrate creates the tables. Every session-granting path also writes the user's `actors` row (`kind: 'user'`) through the bound store before the session is issued, so a new user's first write to an audited table no longer fails its foreign key on postgres; the `ensureActor` option replaces that step.
238
+
239
+ The option's type only widens (`UserStore` to `UserStore | UserStoreResolver`), so every call that compiled still compiles; the other report lines are renamed type imports.
240
+ - **voltro transfer: a signed bundle for a network with no route out** — `@voltro/cli`
241
+
242
+ `voltro transfer seal <dir> --key <private.pem> --scope <installation> --sequence <n>` signs every file of a directory for one installation: `voltro-transfer.json` lists each file's size and SHA-256 with a bundle id, the sequence, when it was made and until when it holds (`--valid-days`, default 30), and `voltro-transfer.sig` is an Ed25519 signature over the manifest's bytes (the key pair from `voltro secret keypair`). `voltro transfer verify <dir> --trust <public.pem>` checks it where it lands before anything is used: a signature from a trusted key (repeatable `--trust`), the time window, the installation (`--scope`), a sequence newer than the last bundle taken (`--after`), and every file present with its digest, nothing added, no path leaving the directory. Every problem is named; exit 1 on any. `--json` for the manifest or the verdict.
243
+ - **voltro update --channel** — `@voltro/cli`
244
+
245
+ `voltro update --channel <tag>` takes the target version from a release channel — the npm dist-tag `@voltro/cli@<tag>` points at — instead of `latest`, resolved through the project's package manager like `latest` (a private registry holds). A channel with no version on it, a value that is not a dist-tag name, or `--channel` together with `--to` is refused before anything is written.
246
+ - **CMS: addresses outside a tree** — `@voltro/cms`
247
+
248
+ `defineContentType({ address: 'slug' })` gives documents outside a tree an address of their own: every publish records it, and a changed address leaves a redirect to the document (`redirectTarget`), as moving a page in a tree does. `hadAddress` then tells a document taken down from one that never was.
249
+ - **A monthly AI budget per workspace** — `@voltro/cms`
250
+
251
+ `setAiBudget` sets how many tokens a workspace's AI calls may use per calendar month (UTC); `aiTokensUsed` sums this month's recorded calls and `aiBudgetLeft` what is left, so a call over the budget is refused before it is made. `AiSettings` gains `monthlyTokens`, and its `provider`/`model` are null when only a budget is set; going back to the deployment's provider keeps the budget.
252
+ - **AI operations and per-workspace AI settings** — `@voltro/cms`
253
+
254
+ `recordAiOperation` records every model call on a document — operation and version, provider and model, the variant and revision it read, the field, a `promptHash` (never the prompt), tokens, `suggested` or `failed`; `settleAiOperation` records the person's choice (`applied` / `discarded`) once; `aiOperationsFor` lists a variant's history. `setAiSettings` / `aiSettings` keep how a workspace reaches a model — provider, model and its own key (encrypted, `serverOnly`), or the deployment's. Register `cmsAiOperationsTable` and `cmsAiSettingsTable`.
255
+ - **Audiences: who a page variant is for, decided on the server** — `@voltro/cms`
256
+
257
+ `defineAudience({ id, version, displayName, rule })` declares an audience as a rule over request signals — `languages` (the reader's first preference, prefix match), `countries`, `returning`; a rule without a condition is refused. `matchesAudience(rule, signals)` evaluates it, `pickVariant(variants, audiences, signals)` returns the first variant whose audience matches (or null for the original), and `languagesOf(acceptLanguage)` orders an `Accept-Language` header. Pure; exported from the main and `/web` entries.
258
+ - **CMS assets can be blocked or expire, and nothing referencing them goes live** — `@voltro/cms`
259
+
260
+ `defineContentType({ availability: { until, blocked } })` names a date/datetime field and a boolean field that say when a type's documents may be used — typically image rights. Publishing such a document records it in the new `cms_availability` table; publishing a revision that references a blocked or expired document throws `ReferenceUnavailable`, naming each target, its path and the reason. The gate sits in `publishRevision`, so single publishes, releases and rollbacks refuse it, and `publishDue` keeps such a row scheduled. `unavailableAmong(store, documentIds, scope, now?)` lets readers leave out assets whose rights ran out after a page went live. Register `cmsAvailabilityTable` when a type sets `availability`.
261
+ - **Blueprints bring a team** — `@voltro/cms`
262
+
263
+ A blueprint's `team` names the role a new editor gets (`defaultRole`) and people invited with a role before they have an account (`members`). `defineBlueprint` checks it (`teamProblems`); `planBlueprintTeam` and `applyBlueprintTeam` provision it idempotently — a default role only where none is set, an invitation only for a new address; `teamDefaultRole`, `invitedRole` and `teamInvitations` read it back per tenant for the app's role resolver. Register `cmsTeamTable` and `cmsTeamInvitationsTable`.
264
+ - **CMS project blueprints** — `@voltro/cms`
265
+
266
+ `defineBlueprint`, `planBlueprint` and `applyBlueprint` declare the content a new workspace starts with — pages, header, footer, site document, in several languages, linked with `blueprintRef` — and provision it into any site and environment. Provisioning is idempotent: a document the workspace already has (matched by the entry's `match` field, or any of its type) is left as it is, and a second run creates nothing. Nothing ties the provisioned content to the blueprint.
267
+ - **CMS bulk operations with a dry run and a result per item** — `@voltro/cms`
268
+
269
+ `planBulk(store, types, place, action, items)` is a dry run over many drafts. It reports for each item whether it is `ready`, `unchanged` or `blocked`, and why a blocked item is blocked: not found, review required, an unusable reference, not published or an unknown type. For a publish it also pins the revision the draft shows. `runBulk` executes that plan. Each ready item runs in its own transaction through the single-item write path, a publish uses the pinned revision, and the result says per item what happened. Actions: `publish`, `unpublish`, `setOwner`.
270
+ - **Bulk: set one field on many drafts** — `@voltro/cms`
271
+
272
+ `planBulk`/`runBulk` take `{ kind: 'setField', field, value }`: the dry run validates the value per draft (derived, rich-text and component fields are refused as `invalid`), and the run saves a new draft revision of each against the revision the dry run saw — a draft edited in between fails as `revisionMoved` instead of being overwritten.
273
+ - **A CMS change feed a consumer can resume** — `@voltro/cms`
274
+
275
+ A content type with `changeFeed: true` appends each publish and take-down to `cms_changes` in the same transaction as the change (register `cmsChangesTable`). The feed therefore never shows a rolled-back publish and never misses a committed one, and a release's entries carry its `releaseId`. `readChanges(store, place, { after, limit, settleMs, filter })` returns the changes after a cursor in order, together with the next cursor. It leaves out the last `settleMs` (default 2 s), so a transaction that commits out of id order is read later instead of skipped. `publishRevision` and `unpublish` take an optional `{ releaseId }` origin. Each entry's `at` is the write time in epoch milliseconds (stored as a timestamp).
276
+ - **CMS pages can be composed from typed components** — `@voltro/cms`
277
+
278
+ `defineComponent({ id, displayName, props })` declares a reusable block with typed props from the field DSL, and `Schema.Components([...])` declares a field that holds an ordered list of placed components (`{ instanceId, componentId, fingerprint, props }`, stored as JSON). A save gives each new placement a stable `instanceId` and stamps the definition's `fingerprint`, which follows the prop contract rather than the display name. Validation reports unknown components, duplicate instance ids, undeclared props and every prop rule with a path such as `sections[1].props.heading`. `<ContentForm>` renders a components field as a named group with one fieldset per placement, keyboard-operable move/remove buttons named after the component, and an "Add component" picker. `ComponentDefinition` and `ComponentInstance` are exported from both entries.
279
+ - **CMS components that need consent are withheld until the reader grants it** — `@voltro/cms`
280
+
281
+ `defineComponent({ consent: 'purposeId' })` marks a component that loads a third party. `gateConsent(type, values, granted)` replaces each such component whose purpose the reader has not granted with `{ instanceId, componentId, consentRequired, props: {} }`, so none of its props, and no URL, reach the reader. Purposes are versioned: `grantedPurposes(purposes, choices)` counts a choice only for the purpose's current version. `purposesUsedBy(type)` lists the purposes a type needs. All three are also exported from `@voltro/cms/web`.
282
+ - **ContentForm renderBlockAside — something inside each rich-text block** — `@voltro/cms`
283
+
284
+ `<ContentForm renderBlockAside={(field, blockId, label) => …}>` (and `renderBlockAside` on `RichDocumentEditor`) renders an app's element inside each block of a rich-text field, after the block's actions. `blockId` is the block's stable id, so a comment thread anchored to it stays with that paragraph when blocks move and across revisions; `label` is the block's legend.
285
+ - **ContentForm renderFieldAside — something beside each field** — `@voltro/cms`
286
+
287
+ `<ContentForm renderFieldAside={(name) => …}>` renders an app's element beside each field, inside the field's container and after its control — for plain fields, rich text and component lists alike. For comment threads on one field, a hint, or who else is editing it; `null` renders nothing for that field.
288
+ - **ContentForm fieldLabel — fields labelled for people, not by their code names** — `@voltro/cms`
289
+
290
+ `<ContentForm fieldLabel={(path) => …}>` supplies the label of each field — `path` is the field's name, or `<componentId>.<prop>` for a component's prop — for plain fields, rich text and component lists. Returning `undefined` keeps the name in code, as before. An app answers from its message catalog so labels follow the editor's language.
291
+ - **ContentForm strings — the form's own controls in the editor's language** — `@voltro/cms`
292
+
293
+ `<ContentForm strings={…}>` (and `strings` on `RichDocumentEditor`) replaces the words of the form's own controls: block names in legends and the add picker, Move up / Move down / Remove and their accessible names (functions of the block or component they act on), Add block, Add component, the block editor's labels and the link inserter. Any subset may be passed; the rest are the English defaults, exported as `CONTENT_FORM_STRINGS` with `contentFormStrings(input)` and the types `ContentFormStrings` / `ContentFormStringsInput`, from the main entry and `/web`.
294
+ - **richDocumentFromContentful: import a Contentful rich-text document** — `@voltro/cms`
295
+
296
+ `richDocumentFromContentful(node, { resolveEntry, resolveAsset })` (main and `/web` entry) translates a Contentful rich-text document into a rich document — paragraphs, headings, lists, tables, bold and italic, entry links and embedded assets the import resolved — and reports the rest as `findings` (URL hyperlinks keep their text, embedded entries, quotes and unknown nodes keep their text).
297
+ - **Two editors type in the same field** — `@voltro/cms`
298
+
299
+ `openDraftTexts` gives each plain text field of a draft a shared CRDT text in the new `cms_draft_texts` table (`cmsDraftTextsTable`); `typeDraftText` merges one editor's encoded update into it, so edits made from the same text both survive. `saveDraft(…, { collaborative: true })` takes those fields from the merged texts and accepts a draft that moved on since `expectedRevision` only in them or in a field derived from them (a slug from its title, see `movedTogether`); a change to any other field still throws `RevisionMismatch`. A draft that changed without the texts leaves them behind, and the next `openDraftTexts` starts them afresh. Also new: `collaborativeFields`, `readDraftTexts`, `liveDraftTexts`, `rebaseDraftTexts`.
300
+ - **Undo and redo in the editor, with named steps** — `@voltro/cms`
301
+
302
+ `@voltro/cms/web` names a change of the form (`describeEdit`: a component or rich-text block added, removed, moved or edited, or a field edited, in the editor's words) and keeps an undo/redo history of the form (`recordEdit`, `undoEdit`, `redoEdit`, `EMPTY_EDIT_HISTORY`): typing within a short pause is one step, a new step drops the redo branch, at most 100 steps are kept. Pure; the history is the editor's own and is not stored.
303
+ - **Copy a site's content into an empty environment, and compare two** — `@voltro/cms`
304
+
305
+ `copyEnvironment(tx, types, { site, from, to })` fills an environment that has no variant of these types yet from another one — every draft with its own revision history, every live copy published, references, availability, page paths and the search index written as a publish would; only the caller's tenant; never into an environment that already has content (`targetNotEmpty`). `compareEnvironments(store, types, { site, from, to })` returns, per type, the documents only on one side and the live documents whose published values differ. `newVariantId`, `pickDeclared` and `writeRevision` are exported for pipelines built on the write path.
306
+ - **Experiments: two versions of a live page, readers split and counted** — `@voltro/cms`
307
+
308
+ `startExperiment` / `stopExperiment` run one experiment per live page, language and environment: version B overrides chosen text fields, `share` is the percent of readers shown it. `assignVariant` gives a reader the same version on every visit from a hash of experiment and the app's pseudonymous reader id; `applyVariant` lays B over the published values. `recordExperimentEvent` counts a reader once per kind — exposure, then conversion — on a unique index, storing the reader hashed with the experiment. `experimentResults` reports readers, conversions and rate per version, the lift of B, a two-proportion p-value, and `conclusive` only past `MIN_READERS` per version and p < 0.05. Tables `cms_experiments` and `cms_experiment_events`.
309
+ - **CMS: agent-tool exposure along the page tree** — `@voltro/cms`
310
+
311
+ `effectiveExposure({ ceiling, defaults, chain })` resolves which agent tools a page offers: the app's ceiling and defaults, then each step from the root to the page turning tools on, off, or locking them off for everything below. Pure and browser-safe (`@voltro/cms` and `@voltro/cms/web`); `explained` names the step that decided each tool.
312
+ - **Field widgets name their fields** — `@voltro/cms`
313
+
314
+ A manifest with the `fieldWidget` slot names the `fields` it shows beside; `manifestProblems` refuses a field widget without fields, fields without the slot, and a field that is not a field name. `ExtensionManifestSchema` carries them.
315
+ - **Extensions can list a type's drafts** — `@voltro/cms`
316
+
317
+ `ExtensionHost.listContent(type)` lends a type's drafts (the host decides how many) and `countContent(type)` how many there are in all; the sandbox passes both on only with `content:read` — what a dashboard extension needs to count or rank content.
318
+ - **Extensions can read a revision** — `@voltro/cms`
319
+
320
+ `ExtensionHost.readRevision(revisionId)` lends the values a revision holds — what a release publishes, which can differ from the draft — and the sandbox passes it on only with `content:read`.
321
+ - **Extensions: rollback, revoke and bound secrets** — `@voltro/cms`
322
+
323
+ `rollbackExtension` returns an extension to the version that ran before its last move, with the grant it had then. `revokeExtension` takes every capability away and keeps the install and its data; `regrantExtension` grants again only with consent to everything the version asks for. `bindExtensionSecret` sets a secret the running or waiting version asks for, stored encrypted and `serverOnly` in `cms_extension_secrets` (register `cmsExtensionSecretsTable`); `extensionSecret` reads it for the host. `ExtensionHost.secret` and the sandbox's `secret` now answer a promise.
324
+ - **Extension settings from the manifest** — `@voltro/cms`
325
+
326
+ A manifest with the `settings` slot lists its `settings` — a `number` with optional bounds, a `boolean` or a `choice`, each with a default; `manifestProblems` refuses a default the setting does not allow. `setExtensionSettings` checks every value against the manifest (`settingsProblems`), `extensionSettings` reads them with defaults filled in (`settingsWithDefaults`), and the sandbox's `settings()` gives them to the extension's code, which cannot write them.
327
+ - **Extensions: manifest, consent, upgrade, uninstall and a sandbox** — `@voltro/cms`
328
+
329
+ An extension is described by a manifest (id, version, publisher, capabilities, editor slots); `manifestProblems`, `compareVersions`, `newCapabilities`, `ExtensionManifestSchema` and `ExtensionAccessDenied` are browser-safe. `installExtension` installs only with consent to every capability; `upgradeExtension` applies a version that asks for nothing new and holds one that asks for more until `consentToUpgrade`; `uninstallExtension` removes the install and its data and returns that data. `extensionSandbox` checks each reach for content, the network (HTTPS to a granted host) or a secret against the grant and gives each extension its own data. Register `cmsExtensionsTable` and `cmsExtensionDataTable`.
330
+ - **fieldDiffs — how each changed field changed, as a word diff** — `@voltro/cms`
331
+
332
+ `fieldDiffs(fields, before, after)` returns, for every declared field that differs between two revisions, its text before and after as a word diff (`{ field, segments: [{ op: 'same' | 'removed' | 'added', text }] }`). A rich document compares by its text, a component list by its components' text props, other structures as compact JSON. `diffWords(before, after)` and `valueText(value)` are exported alongside. All pure and in `@voltro/cms/web`, so an editor shows a history with old and new values side by side.
333
+ - **fieldVersions lists the values one CMS field held across revisions** — `@voltro/cms`
334
+
335
+ `fieldVersions(revisions, field)` returns the values a field held across a document's revisions, newest first, adding an entry wherever the value changed. Each entry names the revision that introduced it. For an image asset's `file` this is the file history, and a revision that only fixed the alt text adds no version. It is pure and also exported from `@voltro/cms/web`.
336
+ - **Content form: lists of values and per-field widgets** — `@voltro/cms`
337
+
338
+ `<ContentForm>` edits a `Schema.Array(...)` of single values — strings, numbers, references — with one control per entry through the element's widget, move and remove per entry, and **Add entry**; it was a placeholder. `fieldWidgets` replaces one field's widget by its name. `ContentFormStrings` gains `item` and `addItem`.
339
+ - **A glossary per workspace** — `@voltro/cms`
340
+
341
+ `setGlossaryTerm` / `removeGlossaryTerm` / `glossary` keep, per term of the source language and target language, how it is written there and which translations must not be used (`cmsGlossaryTable`). `termsIn` picks the entries a source text contains, to tell a model that translates it; `glossaryProblems` finds forbidden words in a translation.
342
+ - **CMS: gone addresses and removable redirects** — `@voltro/cms`
343
+
344
+ `cms.gone(type, path, options)` is true when no live page answers a path that a published page had — its address before it was taken down or archived, or a former address pointing at it — so a site can answer 410 (`ContentGone`) instead of 404. `removeRedirect(tx, type, scope, fromPath)` deletes a redirect; `hadAddress` is the per-locale lookup behind `gone`.
345
+ - **CMS saves can pin the revision the editor saw, and restore an earlier one** — `@voltro/cms`
346
+
347
+ `saveDraft(store, type, row, scope, { expectedRevision })` refuses to overwrite a draft that moved on since the editor saw it: it throws `RevisionMismatch` and writes nothing. `restoreRevision(store, type, id, scope, revisionId)` (and `restoreRevisionEffect`) brings an earlier revision back as the newest one — the history only grows; fields the old revision lacked are cleared and today's validation applies. `changedFields(fields, before, after)` names the fields that differ between two revisions, structurally, and is exported from both entries. `saveDraftEffect` now carries `RevisionMismatch` on its error channel.
348
+ - **richDocumentFromHtml imports HTML as a rich document and reports what won't fit** — `@voltro/cms`
349
+
350
+ `richDocumentFromHtml(html, { resolveLink })` (main and `/web` entry) translates paragraphs — including blank-line paragraphs — headings, lists, tables, bold, italic and links that `resolveLink` maps to a document into rich-document nodes, and returns `findings` for the rest: `externalLink` (text kept), `image` (for a separate asset import), `unsupportedTag` (script, style, iframe and forms dropped with their content; other tags keep their text). `decodeEntities` is exported alongside.
351
+ - **addRedirect keeps pre-import addresses; richDocumentFromHtml places images** — `@voltro/cms`
352
+
353
+ `addRedirect(tx, type, scope, fromPath, documentId, tenantId)` points a former address — a page's address on the site it was imported from, a vanity path — at a document; it replaces an earlier redirect from the same path and yields to a live page at that path. `richDocumentFromHtml` takes `resolveImage(src)`: an image whose source the import brought in as an asset document becomes an image block, its alt text the caption, instead of an `image` finding.
354
+ - **CMS page-tree fields a page inherits from its ancestors, optionally locked** — `@voltro/cms`
355
+
356
+ `tree.inherit` names optional fields, such as a header or footer binding, that a page takes from the nearest ancestor that sets them. `inheritedValue(tree, node, field)` returns the value and the document that set it. With `{ ancestorsOnly: true }` it returns what the page shows once its own value is cleared. `locked: true` refuses the field on any page (`saveDraft` fails with the rule `locked`), so only a site-wide default applies. `inheritedValue` is also exported from `@voltro/cms/web`.
357
+ - **CMS content queries page by id with a cursor** — `@voltro/cms`
358
+
359
+ `contentType(type).page(limit, after?)` returns `{ items, next }`: up to `limit` published rows in `id` order after the given id, and the id for the following page (`null` on the last). Because it pages by key rather than offset, a row published or taken down between two requests neither repeats nor skips another. It keeps the query's `where` filters and replaces `orderBy`, `limit` and `offset`.
360
+ - **Nested lists: a kind per depth; Contentful lists nest** — `@voltro/cms`
361
+
362
+ A list block's optional `sublists` gives the lists nested one, two and three levels in their own kind (`'ordered'` / `'unordered'`), so numbered steps can sit under bullets; absent, every depth is numbered like the list itself. `listNumberedAt(block, depth)` reads it, `RichDocumentView` renders it, the editor offers a "Numbered at level n" box per depth (`ContentFormStrings.numberedAt`), and the HTML import keeps the kind of the first list met at a depth. The Contentful import no longer flattens a list inside a list item: it nests with levels and kinds as in HTML.
363
+ - **CMS releases publish several pinned revisions together, on a schedule or now** — `@voltro/cms`
364
+
365
+ A release pins exact revisions of several variants — any content type, any locale — for one site and environment: `createRelease`, `addToRelease`, `publishRelease`, `scheduleRelease`, `publishDueReleases` and `rollbackRelease` (with `…Effect` siblings), plus `listReleases` / `listReleaseItems`. `publishRelease` runs in the caller's transaction, so every entry goes live or none does; it publishes the revision each entry pinned, even when the draft moved on. `publishDueReleases` claims a due release with a conditional update and a fencing token, so a release is never published twice, and marks one that fails as `failed` with its reason. Rolling back publishes a new release of the previous revisions and leaves the old one as it was. `publish` of a single variant now copies its current stored revision through the same path (`publishRevision`), so a draft still at revision 0 needs one save before it can be published. Register `cmsReleasesTable` and `cmsReleaseItemsTable` to use releases; `ReleaseNotEditable` is the new typed error.
366
+ - **Rich text: nested lists** — `@voltro/cms`
367
+
368
+ A rich-text list block may carry `levels` — each item's depth, 0 to `MAX_LIST_LEVEL` (3), at most one deeper than the item before. The block editor nests an item by two spaces of indent, `RichDocumentView` renders nested lists, and `richDocumentFromHtml` turns a list inside a list item into levels. A list without `levels` is unchanged.
369
+ - **CMS page templates with required components, a fixed first one and limits** — `@voltro/cms`
370
+
371
+ `defineContentType({ templates: { field, components, list } })` lets a content type offer templates. Each template has a starting layout (`values` and `sections`) and rules: `required` components, the component that must come `first`, and `max` counts. A document stores its template id, and every `saveDraft` checks that template's rules, failing with the rule `required`, `position` or `max` and a message naming the template and the component. `templateValues` returns a new document's starting values. `templateViolations` and `templateOf` check a row without saving, and all three are also exported from `@voltro/cms/web`.
372
+ - **CMS content types can form a page tree with routes resolved per locale** — `@voltro/cms`
373
+
374
+ `defineContentType({ tree: { parent, order, segment } })` arranges a type's documents under each other: `parent` is an optional `Schema.Reference` to the same type holding the parent's `documentId`, `order` an optional number field ordering siblings, `segment` the string field forming the path. The parent is versioned and goes live with the page's publish. `saveDraft` refuses a parent that is the page itself or a descendant (`cycle`) or does not exist (`unknown`). `cms.tree(type, { fallback, filter })` builds `{ nodes, orphans }` from the published variants — one per document, in the surface's locale or along the fallback chain — and reports chains that never reach the root as orphans instead of routing them. `cms.route(type, path, options)` finds a path and answers a fallback locale's path with the requested locale's canonical node. `buildTree` and the node types are exported from both entries.
375
+ - **CMS preview links share a draft until they expire or are revoked** — `@voltro/cms`
376
+
377
+ `createPreviewShare(store, type, id, scope, { revision: 'pinned' | 'latest', expiresAt, label? })` shares one draft with someone who has no editor account and returns the link token once — only its SHA-256 hash is stored in the new `cms_preview_shares` table. `resolvePreviewShare(store, token)` is the one cross-tenant read (the token is the authorization) and answers the revision's values, or `unknown` / `expired` / `revoked`. `revokePreviewShare` and `listPreviewShares` are tenant-scoped. The expiry must lie ahead and at most `MAX_PREVIEW_SHARE_DAYS` (30) away, else `PreviewShareRejected`. `ContentNotFound` gains the stage `share`. Register `cmsPreviewSharesTable` to use shares.
378
+ - **Promote chosen documents from one environment to another as one release** — `@voltro/cms`
379
+
380
+ `planPromotion(store, types, { site, from, to })` lists the documents live in `from` that are new or changed in `to`, and marks as `blocked` those whose `to` variant has unpublished draft edits. `promote(tx, types, pair, selections, { name })` saves each chosen document's `from` values as the `to` variant's newest revision and publishes them together as one release in `to`, with every publish check, all or nothing; it refuses, writing nothing, for a blocked item or one not in the plan.
381
+ - **Repeating live windows** — `@voltro/cms`
382
+
383
+ A live window can repeat: `occurrencesOf(rule)` turns a bounded series written as a wall time of an IANA zone (every day, week or month, at most 100 times, each window shorter than a step, optional excepted dates) into plain instants, keeping the wall time across daylight-saving changes and marking a moved occurrence (`gap`, `fold`, `clamped`). `scheduleSeries` stores the rule in the new `cms_publish_series` table (`cmsPublishSeriesTable`) and arms one window at a time as the draft's `publishAt` / `unpublishAt`; `advanceSeries` arms the next once the last is over, `cancelSeries` stops it, `seriesOf` reads it. Also new: `recurrenceProblems`, `zonedInstant`, `isKnownZone`, `MAX_OCCURRENCES` (the pure parts also from `@voltro/cms/web`).
384
+ - **CMS: quality exceptions, duplicates, vocabularies, adopting a winner** — `@voltro/cms`
385
+
386
+ A content type declares `quality` rules (field, message, `blocking`): publishing refuses a blocking finding as a `ContentValidationFailed` violation `quality.<id>` unless an exception — one rule, one variant, a reason and an end date at most 90 days out, approved by someone else (`requestQualityException`, `decideQualityException`; register `cmsQualityExceptionsTable`) — stands. A `taxonomy` binds a list-of-text field to a vocabulary: saves normalise spelling and synonyms and refuse unknown terms (`normalizeTerms`, also in `@voltro/cms/web`). `duplicateGroups` / `retireDuplicate` find and retire documents holding the same file by a caller-supplied fingerprint. `adoptWinner` takes an experiment's version B live through a normal draft release, and `experimentLineage` traces every counted reader to the revision delivered.
387
+ - **A failed CMS release says what to fix and can be retried** — `@voltro/cms`
388
+
389
+ `publishDueReleases` stored `err.message` as a failed release's reason, which is empty for the CMS's typed errors. The reason now names the tag and what it concerns — `ReferenceUnavailable: page … uses asset … (blocked)`, `ReviewRequired: …` — via the exported `describeFailure`. `retryRelease(store, releaseId)` (and `retryReleaseEffect`) takes a failed release back to `draft` with its entries unchanged and the failure cleared, so it can be published or scheduled again once the cause is fixed; any other status throws `ReleaseNotEditable`.
390
+ - **A field can be left empty in a draft and still be required to publish** — `@voltro/cms`
391
+
392
+ `Schema.String.requiredToPublish()` (on any field) lets a draft be saved without the field and refuses every publish until it is set — a single publish, a release, and a bulk run, which plans the item as `incomplete`. The error is `ContentValidationFailed` with the rule `requiredToPublish`; `publishViolations(type, values)` lists the empty fields without writing, from the browser-safe entry too. The column is nullable, as with `.optional()`.
393
+ - **CMS content types can require an approved revision before publishing** — `@voltro/cms`
394
+
395
+ `defineContentType({ review: { required: true } })` publishes only revisions another editor approved. `requestReview`, `approveReview` (refuses the requester's own approval unless `allowSelfApproval`) and `requestChanges` append steps to the new `cms_reviews` log; `reviewStatus` reports `none` / `inReview` / `approved` / `changesRequested` and whether the draft moved on since. An approval covers exactly one revision: a later save needs a new one. The gate sits in `publishRevision`, so `publish`, releases, rollbacks and scheduled publishing all refuse an unapproved revision with `ReviewRequired`; `publishDue` skips such a row and keeps it scheduled. `ReviewStepRefused` is the other new typed error. Types without `review` are unchanged; register `cmsReviewsTable` when a type sets it.
396
+ - **Review left to the workspace** — `@voltro/cms`
397
+
398
+ `review: { required: 'workspace' }` lets each workspace decide whether a type needs an approved revision to go live: `setReviewRequired` turns it on or off, a blueprint's `team.reviewRequired` sets it (applied before the blueprint's entries), `workspaceReviewTypes` lists it and `reviewRequiredFor` answers it for a variant — the publish gate and `applyBlueprint` use the same answer. Register `cmsTeamTable` for such a type.
399
+ - **A published CMS page that moves keeps answering at its old address** — `@voltro/cms`
400
+
401
+ Publishing a tree type now records each document's path per site, environment and locale (new table `cms_paths`). When a path changed (a new slug, a new parent, or a moved ancestor), the old path is stored in `cms_redirects`, pointing at the document. `cms.route` answers a path that no live page has with the document at its current address; the new `RouteMatch.via` (`path`, `fallback` or `redirect`) tells the caller when to redirect permanently. Redirects point at documents, so there are no chains or loops, and a live page always wins over a redirect. Register `cmsPathsTable` and `cmsRedirectsTable` in the app schema. `listRedirects` and `redirectTarget` are exported.
402
+ - **A collaborative save carries the editor's own texts** — `@voltro/cms`
403
+
404
+ `saveDraft(…, { collaborative: true, texts })` merges the editor's own states of the shared texts (`{ id, update }`, encoded CRDT updates; ids of another draft are ignored) before it materializes them. Without it, a save that overtook the editor's last `typeDraftText` wrote the server's older text: a field replaced just before saving kept its old value.
405
+ - **schedulePublish with null cancels a CMS schedule** — `@voltro/cms`
406
+
407
+ `schedulePublish(store, type, id, null, scope)` (and `schedulePublishEffect`) clears the draft's `publishAt`, so `publishDue` leaves it alone.
408
+ - **Scheduled take-down — scheduleUnpublish and unpublishDue** — `@voltro/cms`
409
+
410
+ `scheduleUnpublish(store, type, id, at, scope)` stamps a draft's new `unpublishAt` column (nullable, draft table only; `null` cancels), and `unpublishDue(store, type, now)` takes every live entry whose time has passed down — each in its own transaction, as `unpublish` does, with the marker cleared — and returns their ids. An entry not live when its time comes only has the marker cleared. Effect forms `scheduleUnpublishEffect` / `unpublishDueEffect`. The column is added to every content type's draft table by the planner (a nullable column, no data change); `unpublishAt` becomes a reserved field name.
411
+ - **Content types can be searched through the database's own full-text index** — `@voltro/cms`
412
+
413
+ A content type that declares `search: { title }` keeps its drafts and published copies in `cms_search`, written in the same transaction as every save, publish, take-down and archive. `searchContent(store, place, query, { stage, locale, types, limit, filter })` returns the best hits first — a title hit before a body hit — each with a passage of its text and the offsets of the matched words. `rebuildSearch(store, type)` indexes content saved before the type became searchable. Register `cmsSearchTable` when a type sets `search`.
414
+ - **CMS sitemap and language alternates from the published page trees** — `@voltro/cms`
415
+
416
+ A CMS translates slugs, so a page's addresses differ per language. `alternatesOf(trees, documentId)` returns a document's address in every language where it has its own variant. `sitemapUrls(trees, include)` lists every such page with its `lastmod` and alternates, and `include` can leave pages out, such as members-only ones. `sitemapXml(urls, href, xDefault)` renders the sitemap document with absolute URLs, reciprocal `xhtml:link` alternates and an `x-default`. All three are pure and also exported from `@voltro/cms/web`.
417
+ - **CMS documents get an owner, a review cycle and a content health report** — `@voltro/cms`
418
+
419
+ `setOwner` makes an account responsible for a document, per site and environment (new table `cms_stewardship`, register `cmsStewardshipTable`). A type can declare `lifecycle: { reviewEveryDays }`: its documents' first publish sets a review date, and `recertify` records who checked which revision and moves the date on. `stewardshipOf` reports the owner and whether a review is `notRequired`, `scheduled` or `due`. `contentHealth` lists published documents with no owner, a due review, links to content that is not live in their language, or missing and outdated translations.
420
+ - **Invite to a workspace's team by hand** — `@voltro/cms`
421
+
422
+ `inviteToTeam` invites an address with a role in the caller's transaction (`invited`, `changed` for a new role, `present` when nothing changed); `withdrawInvitation` removes an invitation. Both use the tables blueprint teams already write, so `invitedRole` reads them the same way.
423
+ - **CMS translations record their source revision; translationStatus flags outdated** — `@voltro/cms`
424
+
425
+ `saveDraft(…, { basedOn })` records on a translation's revision the source-language revision it was written from (new nullable `cms_revisions.basedOn`); an update without it keeps the previous basis, so a typo fix does not claim the translation caught up. `translationStatus(store, type, documentId, scope, sourceLocale, targetLocales)` reports each target language as `missing`, `upToDate`, `outdated` (with the source fields changed since its basis) or `unknown`.
426
+ - **Rich text underline** — `@voltro/cms`
427
+
428
+ `RichMark` gains `'underline'`: `RichDocumentView` renders it as `<u>`, the visual text surface toggles it with an **Underline** button and Ctrl/Cmd+U (`ContentFormStrings.underline`), the inline markup writes it `++text++` (`inlineMarkupOf` escapes every `+`), and the HTML (`<u>`) and Contentful imports keep it instead of dropping it.
429
+ - **Count what a tenant holds, and refuse a create past a limit** — `@voltro/cms`
430
+
431
+ `contentUsage(store, types, site)` counts a tenant's current content from the drafts tables: distinct documents in total and per type (one per document, whatever its languages and environments; archived ones not counted) and the environments of the site that hold content. `QuotaExceeded` (`{ quota, limit, used }`, exported from both `@voltro/cms` and `@voltro/cms/web`) is the typed refusal for a create that would go past a limit the app sets.
432
+ - **Visual editing: the page beside the form** — `@voltro/cms`
433
+
434
+ `@voltro/cms/web` ships the protocol an editor and a preview frame of the same origin speak over `postMessage`: the editor sends the unsaved values (`values`), the preview answers `ready`, reports a click on a marked part (`select`, a field or `sections.2`) and a moved component (`move`). `readPreviewMessage` / `readEditorMessage` read a message only from the given origin with the session's nonce (`newVisualNonce`) and in a known shape. Helpers: `visualMessage`, `sourceAttributes` and `sourcePathOf` for marking and finding the field behind a click, `formPartOf` for the matching part of a `ContentForm`, `moveEntry`, `parseSourcePath`.
435
+ - **Rich text edited as it looks** — `@voltro/cms`
436
+
437
+ `<ContentForm richTextSurface="visual">` edits a rich-text field's paragraphs and headings in a text box that shows bold as bold and a link as a link, with Bold and Italic buttons (Ctrl/Cmd+B, +I) toggling the mark on the selection. The box is read back into the document's inline nodes on every input, so the stored value only holds what the model allows; a paste arrives as plain text. New exports: `RichTextSurface` and the inline helpers `toggleMark`, `hasMark`, `replaceText`, `mergeInline`, `inlineText`, `inlineHtml`, `inlineFromDom`. `ContentFormStrings` gains `bold` and `italic`.
438
+ - **XLIFF 2.1 translation exchange** — `@voltro/cms`
439
+
440
+ `translationUnits(fields, values)` lists a document's translatable text (plain text fields without a pattern, rich-text blocks in the editor's inline markup, list items, table cells, captions, component props); `toXliff` writes them as XLIFF 2.1 core; `parseXliff` reads XLIFF 2.x back (refusing DOCTYPE and entity declarations, input over 5 MB and unsupported inline elements with `XliffInvalid`); `applyTranslation(fields, source, units)` puts the translated text into the source values, keeping structure, references and link targets; `unitsHash` and `matchUnits` let an import refuse a stale file and report what came back. Pure, also in `@voltro/cms/web`.
441
+ - **Comments on a text passage** — `@voltro/plugin-comments`, `@voltro/ui`
442
+
443
+ A new comment thread can carry the passage it is about: `create(body, { quote: textQuote(text, start, end) })` stores the selected text with a little context (the W3C text-quote shape) in the new nullable `quote` column of `_voltro_comment_threads`, and `comments.list` returns it (`null` otherwise). `findQuote(text, quote)` finds the passage again after edits elsewhere, or answers null once the text no longer holds it. `<CommentsThread>` shows a thread's quote as `[data-thread-quote]`.
444
+ - **`voltro compliance generate` and the plugin `privacy` declaration** — `@voltro/protocol`, `@voltro/cli`, `@voltro/plugin-auth`, `@voltro/plugin-notifications`, `@voltro/plugin-releases`
445
+
446
+ `definePlugin({ privacy })` declares what a plugin collects (category, linked, tracking, purposes) and which required-reason APIs it uses; `voltro compliance generate [app] [--out] [--strict] [--json]` folds every installed plugin's declaration with `app.config.ts` `privacy` into `PrivacyInfo.xcprivacy` and `play-data-safety.json`, naming who declared each row and listing plugins that move data and declare nothing. auth, notifications and releases ship declarations.
447
+ - **Full-text search by word beginnings — matching(…, { prefix: true })** — `@voltro/database`
448
+
449
+ `queryFor(t).matching(index, query, { prefix: true })` matches each query word as a word beginning and requires every word — `tomato` finds `tomatoes` — on postgres (`to_tsquery` with `:*`), mysql/mariadb (`BOOLEAN MODE` `+word*`) and sqlite (FTS5 `"word"*`), the way mssql's LIKE and the in-memory store already match. Only the letters and digits of the query reach the database, so no dialect's query syntax can be injected through it. Without the option nothing changes.
450
+ - **voltro db deploy runs before-schema files, the plan and after-schema files** — `@voltro/cli`
451
+
452
+ `voltro db deploy .` is the production migrate step in one command: the pending `before-schema` file migrations, then the schema plan (printed and recorded), the blocked / lossy / rolling-deploy gates, the apply, and the pending `after-schema` file migrations — allowed under `NODE_ENV=production`, in the deploy transaction or under the deploy journal, non-zero on any failure. Until now no documented sequence could deploy a new table together with an `after-schema` backfill that reads it: `db apply --plan` refused over the pending before-schema file, and bare `db apply` refused production. A job passes `--yes` for the question MySQL, MariaDB, SQLite and Turso ask before a deploy they can only undo. The `voltro serve` refusal, the `db apply` refusals and the docs name this step now.
453
+ - **`defineNotification` — one declaration rendered into every channel** — `@voltro/plugin-notifications`, `@voltro/react-native`
454
+
455
+ `defineNotification({ name, audience, payload, render })` (browser-safe at `@voltro/plugin-notifications/define`) is the typed descriptor over `NotificationService.send`; `sendNotification` resolves recipients, renders per locale and fans out through the plugin's channels — inbox, web push, mobile push, mail — with `data.deepLink` as the tap target. `expoPushTransport` is the first mobile sender for `pushChannel` (one HTTPS call, an Expo access token, no certificates), and `@voltro/react-native/schema` gains `deviceTokensFor`, `pruneDeviceToken` and `deviceLocaleFor` over `_voltro_devices`.
456
+ - **`desktop-app` template and `voltro desktop` — a Tauri window around the web app** — `@voltro/cli`
457
+
458
+ `create-project --desktop` / `add-app --template desktop-app` scaffold a Tauri 2 project that mounts the sibling web app with the platform adapter installed first (keychain token store, updater, deep links, tray/menu command pairs). `voltro desktop capabilities` derives the Tauri capability file from `voltro.desktop.ts`, `voltro desktop build` runs the web build, the capabilities and `tauri build` and writes an artefact manifest with every bundle's sha256, `voltro desktop ci` writes the three-OS release workflow (signing from secrets), and `voltro desktop publish` uploads a manifest into the api's release feed. Unverified in a real Tauri build until the runner proof lands.
459
+ - **The desktop workflow signs Windows installers on Linux with jsign** — `@voltro/cli`
460
+
461
+ `voltro desktop ci` signs the cross-compiled Windows installer on the Linux runner with jsign, through either Azure Trusted Signing (`TRUSTED_SIGNING_ENDPOINT`, `TRUSTED_SIGNING_ACCOUNT`, `TRUSTED_SIGNING_PROFILE`) or a CA certificate held in Azure Key Vault (`AZURE_KEYVAULT_NAME`, `AZURE_KEYVAULT_CERTIFICATE`), with the Azure client `AZURE_TENANT_ID` / `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET`. The signing script is handed to Tauri as a per-run `bundle.windows.signCommand` override; `tauri.conf.json` stays untouched, and without those secrets the installer is built unsigned as before. The former `WINDOWS_CERTIFICATE` names, which nothing read, are gone from the header.
462
+ - **The devtools overlay's Desktop panel** — `@voltro/devtools`
463
+
464
+ A **Desktop** tab in the in-page devtools when a desktop adapter is installed: shell kind, OS/arch/version/theme, window state, app version, the updater port with **Check now**, the Local Stack (sidecar port, the api url and name from the pairing record) and the capabilities the adapter reports. Read structurally from `extensions.desktop`; no shell package is imported.
465
+ - **`@voltro/desktop/electron` — the Electron adapter on the shell core** — `@voltro/desktop`
466
+
467
+ `createElectronPlatform({ bridge })` fills the `DesktopShell` core from one structural preload bridge (window, OS, deep links, `safeStorage` token store, notifications, `electron-updater` as the update channel, shortcuts, autostart, tray/menu, sidecar, second instance) — no `electron` import — and carries Electron-only `invoke`/`on` under `extensions.electron` for `useShellExtension('electron')`. Passes the platform conformance suite over fakes.
468
+ - **The desktop Local Stack — the api as the window's sidecar** — `@voltro/desktop`, `@voltro/cli`
469
+
470
+ `voltro.desktop.ts` takes `localStack: { api, binary }`; `voltro desktop build` builds that api's executable (`voltro build api --target executable`), places it as `src-tauri/binaries/<binary>-<host triple>` with the serve bundle under `src-tauri/resources/serveBundle/`, and `voltro desktop capabilities` scopes the shell permission to that one sidecar. `startLocalStack(shell, { binary })` from `@voltro/desktop` starts it, reads the port from the sidecar's first log line, waits for `/internal/readiness` and returns the api url; a sidecar that never reports is killed. The SEA launcher also looks for the bundle under a macOS bundle's `Resources`.
471
+ - **`@voltro/desktop` — the desktop shell contract, Tauri 2 adapter first** — `@voltro/desktop`
472
+
473
+ The `DesktopShell` core (window, tray, menu, global shortcuts, single instance, autostart, sidecar, OS theme) rides under the platform adapter's extensions with hooks `useDesktop`, `useWindowState`, `useTray`, `useAppMenu`, `useGlobalShortcut`, `useSystemTheme` and `useDesktopUpdate`; `@voltro/desktop/tauri` builds it from injected Tauri plugin modules and passes the platform conformance suite. Unverified in a real Tauri window until the runner proof lands.
474
+ - **`<UpdatePrompt />` — the desktop app updates itself** — `@voltro/desktop`
475
+
476
+ `UpdatePrompt` from `@voltro/desktop` checks the platform's update channel at mount and on an interval (`checkEveryMs`, default six hours), and when a newer version is on the app's channel shows a panel — version, release notes, "Install and restart", "Later" — and applies it through the channel: download, signature check, install, relaunch. Unstyled, with `data-voltro-update` hooks, `className` per part and `strings`. Renders nothing without a channel, so the same layout serves the browser, an installed PWA and the desktop window; the `frontend-app` template mounts it. `useDesktopUpdate` moved to its own module with no change to its shape.
477
+ - **Desktop windows get the shell adapter — platform script and boot report** — `@voltro/desktop`, `@voltro/cli`, `@voltro/web`
478
+
479
+ `voltro desktop platform` bundles the desktop app's `src/main.ts` into `src-tauri/gen/platform.js`, the window's platform script the shell injects before the web app's own scripts; `voltro desktop build` runs it before `tauri build`, the templates run it as `beforeDevCommand`/`beforeBuildCommand`. `@voltro/web`'s `mount` now keeps an adapter that is already installed instead of replacing it with the browser one (the pwa update channel only replaces a browser adapter). `@voltro/desktop` adds `awaitWindowBoot()` / `windowBootReport()` — shell, kind, installed, app version and whether the app mounted — which the templates hand to a `window_booted` command; under `VOLTRO_DESKTOP_PROOF=<file>` the shell writes that report and quits, which is the framework's window-boot proof.
480
+ - **The dev inspect proxy reads an api at the origin VOLTRO_API_ORIGIN_<NAME> names** — `@voltro/cli`
481
+
482
+ In a split dev deployment — web and api in separate pods, the web pod proxying the api through `VOLTRO_API_ORIGIN_<NAME>` — every inspect read through `/_voltro/api/<name>` answered `401 missing Authorization header`, because a non-loopback target never received a token. An origin an operator named in `VOLTRO_API_ORIGIN[_<NAME>]` or listed in `VOLTRO_DASHBOARD_APPS` now counts as configured: the proxy sends it the web pod's `VOLTRO_INSPECT_TOKEN` (or the listed entry's own token), for a trusted requester. A remote origin that only comes from `app.config.ts` still never gets one. The web dev server also serves the dashboard's door at `/_voltro/devtools/{session,login,logout}`: with `VOLTRO_DASHBOARD_TOKEN` set on the web pod, a developer who reaches the app through an ingress signs in once from the overlay and reads the api from there.
483
+ - **`voltro doctor --pwa` — the installability preflight as findings** — `@voltro/cli`
484
+
485
+ `voltro doctor --pwa` reports the `pwa` block's problems, the icon source and, once `dist/` exists, the static audit `voltro pwa audit` runs (manifest, icons, worker, offline page, head tags) — a list of findings with a check name each, exit 1 on a blocking one, `--json` for machines.
486
+ - **voltro e2e — e2e.readyTimeoutMs for apis that boot cold in more than a minute** — `@voltro/cli`
487
+
488
+ `voltro e2e` waited a fixed 60 seconds for the api and the web app to answer `/internal/readiness`. An api with many plugins boots cold in more and the run failed before a single test ran. `e2e.readyTimeoutMs` in the web app's `app.config.ts` sets the budget; the default stays 60 seconds.
489
+ - **errorMessage() gives the text of a failure value; web Sentry names it by its tag** — `@voltro/protocol`, `@voltro/client`, `@voltro/cli`, `@voltro/plugin-sentry`
490
+
491
+ A declared error reaches client code as the plain object decoded from the wire, and `String()` of a plain object is `[object Object]` — so a banner or an `error.tsx` that rendered `String(error)` showed that once a failed subscription stopped handing over an Effect `Cause`. `errorMessage(err)` returns a failure value's `message`, else its `_tag`, else `String(err)`; it and `errorTag` are exported by `@voltro/client` as well as `@voltro/protocol`. `reportableError(err)` turns such a value into an `Error` named by its `_tag`, with the value as its `cause`; the web server's SSR error reporter and the browser Sentry integration use it, so a declared failure a loader did not catch reaches Sentry with a name to group by instead of as "Object captured as exception". The templates and the starter render `errorMessage(error)`.
492
+ - **On-device SQL for local-first on React Native** — `@voltro/react-native`
493
+
494
+ `createExpoSqliteLocalFirst(db)` implements both of `@voltro/local-first`'s durable contracts — `PersistenceAdapter` (CRDT state, sync queue, pins) and `KvStore` (the query mirror) — over one injected `expo-sqlite` database, as a KV/blob backing rather than a query engine.
495
+ - **The field-encryption key can be rotated without losing data** — `@voltro/runtime`, `@voltro/database`, `@voltro/cli`, `@voltro/plugin-governance`, `@voltro/workflow`
496
+
497
+ Set the new key in `VOLTRO_FIELD_ENCRYPTION_KEY` and the old one in `VOLTRO_FIELD_ENCRYPTION_KEY_PREVIOUS`: new writes use the new key, and every value written under the old one stays readable — `.encrypted()` columns, the step journal, and an `encrypted: true` workflow's engine journal, so a run sleeping or suspended across the change resumes. Before, a key change made all of it unreadable; a run of an encrypted workflow journaled under one key failed its replay with `MalformedMessage` under the next. Measured end to end with a run killed mid-sleep under `voltro dev` and finished by a production `voltro serve` holding the next key, on SQLite, PostgreSQL and MariaDB. With more than one replica, rotate in two deploys — the new key first as the previous one, then swapped — because a replica holding only the old key cannot open what a new one wrote. `voltro db encrypt-column` finishes a rotation: it rewrites every value only the old key opens under the new one. `governancePlugin({ fieldEncryption: { previousSecretKeys } })` names retired keys under other secret names; `makeFieldCipher(key, previous)` takes them in code.
498
+ - **`voltro desktop ci` and `voltro mobile ci` workflows accept a `runner` input** — `@voltro/cli`
499
+
500
+ Both generated release workflows take two new `workflow_dispatch` inputs, `runner` and `run_scope`, set by Voltro Deploy when it dispatches a release: every job's `runs-on` then resolves to the ephemeral Voltro runner of its OS plus the run's scope label. A manual dispatch or a tag push outside Voltro Deploy keeps GitHub-hosted runners. The desktop matrix builds macOS as ONE universal binary (`universal-apple-darwin`) instead of two jobs, and on Voltro runners the Windows target is cross-compiled on Linux (cargo-xwin + NSIS; sign it through `windows.signCommand`), so Apple hardware is the only non-Linux minute. Regenerate the workflow with the same command to pick the inputs up; a workflow without them is dispatched with the version only and runs on GitHub-hosted minutes.
501
+
502
+ **codemod:** none — regenerate `.github/workflows/desktop-release.yml` / `mobile-release.yml` with `voltro desktop ci` / `voltro mobile ci`.
503
+ - **LAN dev pairing — `voltro mobile pair` and the `voltro-dev://pair` link** — `@voltro/client`, `@voltro/cli`
504
+
505
+ `voltro mobile pair` prints, per running api, the `voltro-dev://pair?api=…&name=…&token=…` link a dev shell opens to take the api over: its LAN address (loopback swapped for the first non-internal IPv4, or `--host`), its name and the dev inspect token from the runtime registry. `@voltro/client` gains `pairingLink`, `parsePairingLink` (null for anything else, loopback included), `applyPairing`, `readPairing` and `clearPairing` over the platform's storage.
506
+ - **Libsql read transactions** — `@voltro/sql-turso`, `@voltro/cli`
507
+
508
+ LibsqlClient exposes withReadTransaction for explicit read-mode snapshots with scoped cleanup and nesting refusal. Timeline source reads use this capability when present, avoiding an unnecessary write transaction. The mode is not an authorization guard, and this change does not enable Turso source capture during normal application boot.
509
+ - **licenseStatus() says where the installation stands with its license** — `@voltro/plugin-licensing`
510
+
511
+ `licenseStatus()` (and `LicenseService.status`) report `none`, the installed license as `active`, `grace` or `expired` with its edition, expiry and tenant, or `rejected` with the reason when the license offered did not verify — the last good license, if any, beside it. A refused license used to be visible only as a boot warning in the log. `LicenseSnapshot.state()` gives the same active/grace/expired reading for a snapshot.
512
+ - **The Local Stack sidecar takes an env — secrets travel as env, not files** — `@voltro/desktop`
513
+
514
+ `DesktopShell.sidecar.start(name, args, { env })` and `startLocalStack({ env })` hand an environment to the api process; the Tauri adapter passes it to the shell plugin. The `desktop-local-app` template mints `VOLTRO_SESSION_SECRET` once into the keychain and passes it this way.
515
+ - **Mail and billing declare where their data leaves the installation** — `@voltro/plugin-mail`, `@voltro/plugin-billing`
516
+
517
+ `mailPlugin` now declares its egress for `voltro compliance generate` (`egress.json`): the provider's API host (`api.lettermint.co`, `api.resend.com`, `api.postmarkapp.com`, `api.sendgrid.com`, `email.<AWS_REGION>.amazonaws.com`, the Mailgun base URL's host) or the SMTP server's host, never the user and password an SMTP URL carries; nothing on `console` or `memory`. `mailEgressOf({ provider, env })` returns the same list.
518
+
519
+ `billingPlugin` declares `api.stripe.com` on the stripe provider, nothing on the mock, and `runtime:the '<name>' billing provider` for a custom one (`billingEgressOf(provider)`).
520
+ - **Lettermint, broadcast mail and a webhook the mail plugin mounts** — `@voltro/plugin-mail`
521
+
522
+ `provider: 'lettermint'` (or `lettermintProvider({ projectToken, routes? })`) sends through Lettermint, an EU provider on its own infrastructure, with native scheduling, idempotency and 500-message batches. Set `LETTERMINT_PROJECT_TOKEN`.
523
+
524
+ A message now says which kind of mail it is: `stream: 'transactional'` (the default) or `'broadcast'`. Lettermint sends them on separate routes, Postmark on separate message streams. A broadcast must be leavable in one click: unless the provider unsubscribes recipients itself (Lettermint's broadcast route does), it carries `unsubscribe: { oneClickUrl }` and the service writes the RFC 8058 `List-Unsubscribe` headers — a broadcast with neither fails instead of sending. `metadata` rides with a message and comes back on its delivery events. `mail.provider.managesUnsubscribe` tells a broadcast sender whether the body needs an unsubscribe link of its own.
525
+
526
+ `mailPlugin({ webhook: { onEvent } })` mounts the provider's delivery-event endpoint (`/webhooks/mail`, secret `MAIL_WEBHOOK_SECRET`), verifies its signature, suppresses bounced and complaining addresses in the plugin's own suppression store, lifts a suppression the provider lifted, and hands every event to `onEvent`. `MailEvent` gains the `unsubscribed` and `unsuppressed` types, `eventId` and `metadata`.
527
+
528
+ Postmark now receives a message's custom `headers`, which it silently dropped before.
529
+ - **Record exact soft-drop snapshot identities in migration operations** — `@voltro/database`
530
+
531
+ New soft-drop operations retain the exact renamed column or table name in executed operation history and non-transactional resume payloads. The applier emits the recorded rename target instead of generating a different timestamp during execution. Existing history without this metadata remains distinguishable.
532
+ - **The mobile workflow uploads store builds with `eas submit`** — `@voltro/cli`
533
+
534
+ `voltro mobile ci` generates a `store` job that waits for EAS Build and, when the repository holds the store credentials (`APPLE_API_KEY_CONTENT`, `APPLE_API_KEY`, `APPLE_API_ISSUER`, `VOLTRO_STORE_IDENTIFIER` for App Store Connect; `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` for Google Play), uploads the binaries with `eas submit` — to TestFlight and to the Play internal track. The credentials become files the runner alone holds and the submit profile in `eas.json` is pointed at them for that run only; nothing is committed. Without those secrets the job builds as before.
535
+ - **`voltro mobile ci` — OTA into the release feed, store builds on EAS** — `@voltro/cli`
536
+
537
+ `voltro mobile ci` writes `.github/workflows/mobile-release.yml`: an `ota` job that exports the bundle and publishes it into the api's release feed with `voltro release`, and a `store` job that builds the native binaries on EAS Build (`EXPO_TOKEN`; `--app`, `--channel`, `--profile`). `voltro mobile doctor` warns when `updates.url` is not a `/releases/<app>/expo/manifest` url. The mobile template ships `expo-updates` on the feed with `runtimeVersion: { policy: 'appVersion' }` and an `eas.json` with development/preview/production profiles.
538
+ - **Native login for cookie-less clients through the system browser** — `@voltro/plugin-auth`
539
+
540
+ `authRoutesPlugin({ native: { redirectUris } })` mounts `/auth/native/authorize`, `/native/complete` and `/native/exchange`: a React Native app or desktop shell signs in on the ordinary login page in the system browser and exchanges a PKCE-bound one-time code for the session value. The browser-safe client half ships at `@voltro/plugin-auth/native` (`beginNativeLogin`, `parseNativeCallback`, `exchangeNativeCode`, `probeNativeSession`, `runNativeLogin`).
541
+ - **Durable notification dispatch through the outbox** — `@voltro/plugin-notifications`
542
+
543
+ `enqueueNotification(ctx.outbox, descriptor, options)` writes one outbox row per recipient inside the caller's transaction (per-recipient idempotency key, `maxAttempts`, `delayMs`), and `notificationOutboxHandler({ service, notifications })` — exported from an app `*.outbox.ts` — renders each row for its recipient and locale at delivery and sends through the service. Unknown descriptors and a missing service fail the attempt loudly. `sendNotification` is unchanged and shares the same render mapping.
544
+ - **voltro plan close | impact** — `@voltro/plan`, `@voltro/cli`
545
+
546
+ `voltro plan close <item>` says whether an item is verified and what is missing. `voltro plan close <NN> --pass contract|adversarial --reviewer <who> --context <where>` records a closure pass in `closureDir` (new manifest field): refused while a mandatory item is unverified or a finding is open; the adversarial pass needs a passed contract pass on the same plan revision by another reviewer in another context, and then issues the certificate. The revision fingerprints the plan's instance, evidence and findings, so any later change reopens the plan (`closure/stale-certificate`); `closed` and `complete` are derived from a current certificate, and a plan recorded `complete` without one is an error. `voltro plan impact --tag … --from <ref>` maps tags and changed files to work items, their dependents with paths, plans and certificates to renew, and lists changed files that name no item.
547
+ - **voltro plan lint | status | next** — `@voltro/plan`, `@voltro/cli`
548
+
549
+ A new browser-safe package, `@voltro/plan`, holds the executable plan contract: ids from a project prefix, the v1 plan-instance Schema, lint with stable rule ids, `verified` derived from passing evidence, status per plan and a deterministic next item. `voltro plan lint`, `status` and `next` read the files `voltro.plan.json` names (inside the project root only) and print the result for people or as a versioned JSON envelope, with exit codes 0/1/2/3/6.
550
+ - **voltro plan evidence add** — `@voltro/plan`, `@voltro/cli`
551
+
552
+ `voltro plan evidence add <item>` records evidence beside the generated plan instances, in `evidenceDir` (new manifest field): it names what it proves (`--proves <check>,<requirement>`), must be of a kind the requirement names, carries a portable URI without credentials, and never a secret (exit 6). A `commit` must exist in the named repository. The same idempotency key and payload is recorded once; a changed file while writing is a conflict (exit 4). Evidence now proves a check or requirement by naming it — no edit of the instance — and counts only when observed after the item last changed.
553
+ - **One platform contract for browser, PWA, React Native and desktop shells** — `@voltro/client`, `@voltro/web`, `@voltro/react-native`, `@voltro/testing`
554
+
555
+ `@voltro/client` gains the `PlatformAdapter` contract — WebSocket, store backing, secure token store, reachability, lifecycle, inbound links, and optional notifications/updates/biometrics — with `installPlatform`, `usePlatform` (capabilities, not platform names) and `useShellExtension`. `@voltro/web` installs the browser adapter at mount; `@voltro/react-native` ships `createExpoPlatform` over injected Expo modules; `@voltro/testing/platform` runs the conformance suite every adapter passes. `defineDeepLink` and its matcher now live in `@voltro/client` and stay re-exported from `@voltro/react-native`.
556
+ - **Plugins declare where data leaves; voltro compliance generate writes egress.json** — `@voltro/protocol`, `@voltro/cli`, `@voltro/plugin-storage`, `@voltro/plugin-webhooks`
557
+
558
+ A plugin may declare `egress: [{ destination, purpose, data, disable }]` — each place it sends data outside the installation, what leaves, and how an operator turns it off; `destination` is a host or URL, or `runtime:<what decides it>` when the address is data. `voltro compliance generate` folds every plugin's declaration and `app.config.ts` `egress` into `egress.json` beside the store artefacts (one row per destination and purpose, naming who declared it). `storagePlugin` declares the object store it is configured for and nothing on `database`, `filesystem` or `memory` (`storageEgressOf(options)`); `webhooksPlugin` declares the registered targets (`WEBHOOKS_EGRESS`), and the command adds that row whenever the app emits webhooks, with or without the entry.
559
+ - **mailPlugin() hands out its service** — `@voltro/plugin-mail`
560
+
561
+ `mailPlugin()` returns the plugin with the service it built as `service` — the same one handlers yield as `MailService`. Wiring in `app.config.ts`, before any handler runs, uses it: `authRoutesPlugin({ sendEmail: mailSender(mail.service) })`.
562
+ - **`@voltro/plugin-releases` — the self-hosted update server for your apps** — `@voltro/plugin-releases`, `@voltro/cli`, `@voltro/devtools-ui`
563
+
564
+ Release channels, artefacts and deterministic rollouts with promote and rollback as pointer moves; feeds for the Tauri updater, expo-updates, electron-updater and direct downloads rendered from one release model; and a table-sourced `releases.policy` query (minimum supported version, kill switches) that `ForcedUpdateGate`, `useReleasePolicy` and `useKillSwitch` evaluate on the client with the same rule the server uses. `voltro release` (status, create, upload, promote, rollout, rollback, policy) operates it on a running api; rollout and rollback are dry runs until `--commit`. The Releases panel (channels, rollouts, rollback, policy, events) ships in `@voltro/devtools-ui` for both dashboards.
565
+ - **`apnsTransport` and `fcmTransport` — the credential-gated last hop** — `@voltro/plugin-notifications`
566
+
567
+ Two built-in `pushChannel` transports: `apnsTransport({ keyId, teamId, privateKey, bundleId })` signs an ES256 provider token and posts the `aps` alert over HTTP/2; `fcmTransport({ serviceAccount })` signs the RS256 assertion, exchanges it for an access token (cached) and posts the HTTP v1 message. Both refuse to be built without credentials, naming the variable, and map the provider's dead-token answers to `PushTokenRejected`. The network is an injected seam; the pipeline is tested without Apple or Google.
568
+ - **PWA Background Sync — the outbox drained by the worker with the tab closed** — `@voltro/client`, `@voltro/web`, `@voltro/cli`
569
+
570
+ `pwa.backgroundSync: { rpcUrl }` ships `/voltro-sw-outbox.js` and a worker `sync` handler for the `voltro:outbox` tag. `@voltro/client` gains `drainOutboxOverHttp` (each replayable entry as the rpc `Request` envelope over HTTP, socket frames read for the outcome), `indexedDbOutboxPersistence` (one queue the page and the worker share) and `drainOutboxInWorker`; `@voltro/web` gains `requestBackgroundSync()` and `useBackgroundSync(outbox)`. Browsers without Background Sync keep replaying on the next open.
571
+ - **The devtools overlay's PWA panel** — `@voltro/devtools`
572
+
573
+ A **PWA** tab in the in-page devtools: the worker's installing / waiting / active slots with scope and script, Background Sync availability, the `voltro-*` caches with entry counts, current vs. waiting build with **Check now** and **Force update**, installability and install state, push permission and the subscription's push-service host (never the endpoint path). Read from the tab's own browser globals and the `@voltro/web` hooks.
574
+ - **`voltro pwa twa|audit` — the Play wrapper manifest and a static audit** — `@voltro/cli`, `@voltro/plugin-releases`
575
+
576
+ `voltro pwa twa` writes Bubblewrap's `twa-manifest.json` from the app's `pwa` block, the host and the signing fingerprint (the matching `assetlinks.json` comes from `voltro mobile links`); `voltro pwa audit` checks a built `dist/` for the manifest, the icon set, the worker, the offline page and the head tags without a browser. The `frontend-app` and `frontend-spa` templates ship with `pwa` enabled. `useUpdateImpulse` from `@voltro/plugin-releases/web` asks the platform's update channel to check when the release policy rolls a newer version to this install.
577
+ - **Any web app becomes installable with a `pwa` block** — `@voltro/cli`, `@voltro/web`
578
+
579
+ `pwa: { name, icon, offline, update, … }` in a web app's `app.config.ts` makes `voltro build` write `manifest.webmanifest`, the icon set (192/512/maskable/apple, rasterised from one source) and a service worker whose caching follows each route's `renderMode` — shell and assets only, never data. `mount()` registers it outside dev and exposes it as the platform's update channel; `@voltro/web` gains `useInstallPrompt`, `useAppUpdate`, `useOfflineStatus` and `useBadge`. The generated worker carries the notifications plugin's push handlers when the plugin is installed.
580
+ - **rbac role resolvers read the store the plugin is bound to at boot** — `@voltro/plugin-rbac`
581
+
582
+ `dataStoreRoleResolver(userRoleStore(dataStore))` needed a store inside `app.config.ts`, which is evaluated before any store exists, so runtime-assigned roles had no way in short of a module cell filled from a startup hook. `rbacPlugin` is now bound to the app's store at boot (`bindDataStore`) and hands it to the resolver as its second argument (`RoleResolverContext.store`); `dataStoreRoleResolver()` without a store reads assignments from it. A resolver that ignores the context keeps working, and an explicit `UserRoleStore` still takes precedence.
583
+ - **Release retention — `keep` newest releases per channel, a TTL for the event log** — `@voltro/plugin-releases`
584
+
585
+ `releasesPlugin({ retention: { keep, perChannel } })`: after every `create`, the channel keeps its newest `keep` releases by version (default 10) and the rest go with their artefact rows — never a release any channel points to. One `prune` event per run names what went. `_voltro_release_events` is swept after `VOLTRO_RELEASE_EVENTS_TTL_HOURS` (default one year); the plugin declares the variable.
586
+ - **`releases.registerApp` — an app target registered at runtime** — `@voltro/plugin-releases`
587
+
588
+ `releases.registerApp` (`POST /apps` on the inspect surface) creates an app target with its channels while the api runs — `{ app, platform, shell, channels? }`, idempotent — for a feed that hosts apps its `apps:` config cannot know. Targets from `apps:` are created exactly as before.
589
+ - **REST routes and publicApi projections declare how a CDN may cache them** — `@voltro/protocol`, `@voltro/plugin-openapi`
590
+
591
+ `defineRestRoute({ cache })` and `publicApi: { cache }` take `{ maxAge, staleWhileRevalidate?, tags? }` for GET routes. A `200` to a request without a credential is `public, max-age, s-maxage` (plus `stale-while-revalidate`) and carries its purge tags as `Cache-Tag` and `Surrogate-Key`; a request with `Authorization` or a cookie is always answered `private, no-cache` and untagged, so what a key or a session may see never lands in a shared cache. Answers say `Vary: Authorization, Cookie`; a `304` keeps the headers; errors are untouched. `@voltro/plugin-openapi` documents the headers.
592
+ - **Rich push — `image` and `actions` from `render`, mapped by every push transport** — `@voltro/plugin-notifications`
593
+
594
+ `render` may return `image` and `actions: [{ id, title }]`; they travel in the message data (`richPushOf`) and reach the transports: Expo `richContent` + `categoryId`, APNs `mutable-content` + `category`, FCM `notification.image` + `fcm_options.image`.
595
+ - **`defineBackgroundSyncTask` — background sync bound to the OS** — `@voltro/react-native`
596
+
597
+ `defineBackgroundSyncTask({ taskManager, backgroundFetch, run, minimumIntervalSeconds, onOutcome })` defines the task at module scope, registers it idempotently with the platform floor and Android's termination/boot flags, maps `run`'s outcome to the fetch result, turns a throw into `'failed'` (never a crash) and reports every outcome; `availability()` reads the OS status. Both Expo modules are passed in structurally. Best effort and OS-throttled by nature; the foreground `useBackgroundSync` stays the reliable path.
598
+ - **seo.sitemap: 'external' — robots.txt names a sitemap another app serves** — `@voltro/cli`
599
+
600
+ A web app whose `/sitemap.xml` comes from elsewhere (an api route it proxies) sets `seo: { siteUrl, sitemap: 'external' }`. `voltro build` then writes no `sitemap.xml` of its own, so none shadows the proxied one, and the production `robots.txt` still carries `Sitemap: <siteUrl>/sitemap.xml`. Before, an app had to choose between a generated sitemap that hid the real one and a `robots.txt` naming none. `'generate'` stays the default.
601
+ - **A session can be presented on the `x-voltro-session` header** — `@voltro/protocol`, `@voltro/plugin-auth`, `@voltro/cli`
602
+
603
+ A client without a cookie jar presents its signed session value on the `x-voltro-session` header (`SESSION_HEADER_NAME` from `@voltro/protocol`). `readSessionValue` and `readSessionFromHeaders` read header or cookie, header first; the built-in password strategy, the `/auth/*` routes and `voltro dev`'s default all go through them. A header client past the renewal threshold receives the renewed value in the `GET /auth/session` body.
604
+ - **Sessions an app mints itself can be revoked** — `@voltro/plugin-auth`
605
+
606
+ An app that signs people in through an IdP callback and calls `issueSession` by hand had purely stateless cookies: a stolen one authenticated until it expired, and signing out only cleared the browser. Now:
607
+
608
+ - `issueRegisteredSession(registry, subject, secret, options)` writes a `sessions` row (register `sessionsTable` from `@voltro/plugin-auth/schema`), stamps its id into the cookie and signs — in place of `issueSession`. - `dataStoreSessionRegistry(store)` keeps those rows in the app's own database, through the store the framework opened. - `voltroPasswordStrategy({ sessionRegistry: dataStoreSessionRegistry, requireSessionId: true })` checks every request's cookie against the rows and refuses a cookie without one, so nothing minted outside the registry is a session. Carry it as a plugin's `auth.sessionStrategy`. - `revokeAllSessions(userId)` on every session store — sign out everywhere: a deactivation, an account deletion, a security event. A password reset now uses it.
609
+ - **Storage image transforms crop around a focal point** — `@voltro/plugin-storage`
610
+
611
+ The serve route's image transforms take `fp=x,y`, a focal point given as fractions (0–1) of width and height. With `w`, `h` and `fit=cover`, the crop keeps the largest region of the target aspect ratio with the point as near its centre as the image edges allow, instead of cutting around the middle. `TransformParams` gains `fp`. Without it the crop stays centred, and an out-of-range value is ignored.
612
+ - **ingestUrl takes an access policy, like put** — `@voltro/plugin-storage`
613
+
614
+ `storage.ingestUrl(url, { access })` stores a private ingested object with the given access policy, exactly as `put({ access })` does — an import that keeps its images private can still let the tenant's editors read them.
615
+ - **StorageService.setVisibility — an object goes public or private after the fact** — `@voltro/plugin-storage`
616
+
617
+ `storage.setVisibility(id, 'public' | 'private', { tenantId })` changes a stored object's visibility after `put` (the tenant is checked like `delete`). On database, filesystem and memory the ref decides every read; on s3/minio with per-object ACLs the object follows its refs and stays public while any ref to the same bytes is (new optional provider capability `setObjectPublic`). Where objects are served from a public base (`cdnBaseUrl`), or the ref store has no `setVisibility` (new optional `RefStore.setVisibility` / `countPublicByKey`), making an object private is refused with `StorageRejected` reason `visibility-unsupported`.
618
+ - **A transform that serves the original says why** — `@voltro/plugin-storage`
619
+
620
+ When a requested image transform (resize, format, quality) cannot happen, the serve route still answers with the original, and now adds `x-voltro-transform: original; reason=not-an-image | no-sharp | failed`, so a quality reduction is never silent. `transformFallbackReason` is exported for custom routes.
621
+ - **storagePlugin uploadAccess and StorageService.signedUrl** — `@voltro/plugin-storage`
622
+
623
+ `storagePlugin({ uploadAccess })` is the access policy every private browser upload (an upload ticket) is stored with — declared by the app, never chosen by the client; `[{ tenant: true }]` lets everyone in the uploader's tenant read it. Without it, as before, only the uploader can.
624
+
625
+ `storage.signedUrl(id, { expiresInSec, tenantId })` returns a short-lived URL that delivers the bytes without an access check, for server code that has already decided the reader may see them (a verified shared preview): the public URL of a public object, else a presigned GET or the serve URL with a signed grant token.
626
+ - **`voltro deploy screenshots` and the store screenshot presets** — `@voltro/protocol`, `@voltro/cli`
627
+
628
+ `STORE_SCREENSHOT_PRESETS` in `@voltro/protocol` names the device sizes the app stores accept (iPhone 6.7″ and 6.5″, iPad 12.9″, Android phone and 10″ tablet, Mac, Windows) as a CSS viewport and scale factor each, and `voltro deploy screenshots <webApp> --base-url <url> --routes … --locales …` shoots a running web app at those sizes with the app's own Playwright into `dist-screenshots/<locale>/<preset>/`, with a manifest naming every file's pixel size. Serve the files from the app and enter their urls in the app's store listing in Voltro Deploy.
629
+ - **Full-text search runs on turso (LIKE fallback)** — `@voltro/database`, `@voltro/sql-sqlite`, `@voltro/sql-turso`
630
+
631
+ A `.fullTextIndex()` no longer stops the migration on turso: its MVCC mode has no FTS5 virtual tables, so the migrator creates none and the turso store runs `.matching()` as the case-insensitive LIKE fallback mssql uses, with `.rankBy()` counting the columns that hit. `makeSqliteStore` takes `fullText: 'fts5' | 'like'` (default `'fts5'`); the turso dialect passes `'like'`. The CMS search table now applies on turso as on every other dialect.
632
+ - **Turso read transactions hold a snapshot without the write lock** — `@voltro/sql-turso`, `@voltro/cli`
633
+
634
+ The local Turso client gains `withReadTransaction`: a deferred `BEGIN` that reads one snapshot and takes neither the write lock nor a turn in the pool's one-at-a-time queue, so a writer on another connection commits beside it. The Timeline capture reader uses it, so recording an app on Turso no longer makes the app's own writes fail with `database is locked` while a frame is read. It refuses to nest inside another transaction, and a write inside it fails rather than upgrading.
635
+ - **The server names an undeclared failure tag and how to declare it** — `@voltro/runtime`
636
+
637
+ When a mutation or action fails with a tagged error its wire union cannot carry, the caller still receives `InternalError` — the union is the contract — but the server now logs, once per procedure and tag, that the tag is undeclared and the ways to fix it: add it to `error:`, register it through a plugin's `errorSchemas`, or map it to a declared error (`Effect.orDie` for a defect). Before, the only trace was the generic "failed" line every error produces.
638
+ - **Loaders can answer 410 Gone** — `@voltro/web`, `@voltro/cli`
639
+
640
+ `gone(detail)` — or `new NotFoundError(detail, { gone: true })` — tells both boot paths to answer **410 Gone** instead of 404, with the same nearest `not-found.tsx`. For something removed on purpose: search engines drop a 410 address instead of asking again. `NotFoundError.status` is `404` or `410`.
641
+ - **A web app can proxy a path to one of its bound apis by name** — `@voltro/cli`
642
+
643
+ `proxies: { '/auth': { api: 'app' } }` forwards the prefix to the same upstream as that api's WebSocket, in `voltro dev` and `voltro start`, and keeps the browser's Host, so a plugin's HTTP routes such as `/auth/*` share the web origin and its session cookie without an origin or port in the config. A name missing from `apis`, or an external api the browser reaches directly, refuses the boot. `voltro dev` also stops rendering a page for a GET under any declared proxy prefix: it reaches the upstream, as it already did under `voltro start`.
644
+ - **useHydrated** — `@voltro/web`
645
+
646
+ `useHydrated()` is false on the server and while hydrating, true once the page is interactive — for server-rendered controls that should be disabled until their handler is attached, instead of swallowing the first click.
647
+ - **useWebMcpTool — a page offers an agent in the browser its actions (WebMCP)** — `@voltro/web`
648
+
649
+ `useWebMcpTool({ name, description, input, annotations?, execute })` registers a WebMCP tool (`document.modelContext.registerTool`) for as long as the component is mounted and unregisters it on unmount. The agent is shown the JSON Schema derived from the `effect/Schema` `input`, and every call is decoded with it before `execute` runs; a call that does not decode is answered with the reason. Without WebMCP in the browser nothing registers. `registerWebMcpTool(tool)` does the same outside React and returns the unregister function; `webMcpAvailable()` and `webMcpInputSchema(schema)` are exported alongside.
650
+ - **listTargets names each target's filter** — `@voltro/plugin-webhooks`
651
+
652
+ `TargetSummary` (from `listTargets`) now carries `filter` — the filter the target was subscribed with, verbatim, or `null` when it receives every emit of its event — so a list of addresses can say what each one receives.
653
+ - **Keep accepted workflow starts visible before run recording** — `@voltro/devtools-ui`
654
+
655
+ The shared workflow dashboard retains accepted execution identities across reload and replica inspection changes. Read-only status observations expose delivery failures even before a run row exists, without submitting another start. Read and browser-storage failures remain visible, and completed observations can be dismissed.
656
+ - **Observe workflow delivery failures before run recording** — `@voltro/cli`, `@voltro/protocol`
657
+
658
+ Add the authorized read-only workflow execution Inspect endpoint and browser-safe observation schemas. Validate retained execution placement before reading journal evidence; report early delivery failure without returning payloads, outputs, raw causes or internal IDs. Storage failures remain unavailable reads. Both development and compiled serve paths use the same projection. The endpoint never dispatches or retries execution; the shared dashboard consumes this projection for accepted-start recovery.
659
+ - **Inspect can observe durable queued workflow start receipts** — `@voltro/protocol`, `@voltro/cli`, `@voltro/devtools-ui`
660
+
661
+ A read-only queued-start endpoint resolves the original caller receipt through the runtime admission owner. It distinguishes pending, accepted execution and terminal admission outcomes, preserves tenant identity and never repeats the start. The shared dashboard retains queued receipts across reload and replica selection, follows their actual execution and keeps unresolved receipts visible.
662
+ - **Read persisted workflow delivery replies without replaying execution** — `@voltro/workflow`
663
+
664
+ Add the server-side `readWorkflowRunReply` diagnostic to `@voltro/workflow/cluster`. It reads encoded engine evidence with string request/reply identities, preserves failures before recorder entry, and distinguishes missing requests from unavailable storage. It never acknowledges, resets or dispatches messages. Dashboard integration is separate.
665
+ - **WorkosProfile says whether WorkOS verified the email** — `@voltro/plugin-auth-workos`
666
+
667
+ `workosAuthenticateWithCode` and `workosAuthenticateWithMagicAuth` return `emailVerified` on the profile, from WorkOS's `email_verified`; an absent field counts as unverified. An app that finds or links an existing account by the profile's email must refuse when it is false — an identity provider handing out unverified addresses would otherwise let anyone claim another person's account.
668
+ - **WorkOS sign-in returns its session, workosLogoutUrl ends it** — `@voltro/plugin-auth-workos`
669
+
670
+ `WorkosProfile` carries `sessionId` — the WorkOS session the sign-in opened, read from the `sid` claim of the access token WorkOS returns with the code exchange and the Magic Auth verification. `workosLogoutUrl({ sessionId, returnTo })` builds the URL that ends that session. Clearing only your own session left the WorkOS session open, so the next sign-in, in this app or any other app sharing the WorkOS environment, went through without asking.
671
+
672
+ ### Changed
673
+
674
+ - **CMS dialect-parity suite covers turso; the search table needs FTS** — `@voltro/cms`
675
+
676
+ The CMS kernel's dialect-parity suite also runs on turso (in process). All kernel tables apply there except `cms_search`, whose full-text index the framework refuses on turso's MVCC mode (no FTS5) — an app on turso cannot declare the CMS search table yet.
677
+ - **CMS search finds word beginnings** — `@voltro/cms`
678
+
679
+ `searchContent` matches every query word as a word beginning on every store — `tomato` finds `tomatoes`, `comp hea` finds a page with "compost" and "heap". Before, postgres and sqlite matched whole words only and mysql any word.
680
+ - **An encrypted workflow's error text is sealed and stays in the process** — `@voltro/workflow`, `@voltro/protocol`, `@voltro/plugin-sentry`, `@voltro/runtime`, `@voltro/cli`
681
+
682
+ `workflow({ encrypted: true })` now also seals error text — a schema error prints the value it rejected, an HTTP client quotes the body it was refused with. Sealed: a thrown error (a defect) in the engine journal, for the workflow and each step; the error message on the run, the step, the run event and the failure report; a queued start's and a submission's `lastError`; a signal's or update's value and an update's result in the run events; the value `awaitSignal`, `awaitUpdate`, `awaitEvent` and `awaitSignalSuspending` journal. The error's tag stays readable. Readers the access rules let in — live run, step and event rows, the client's workflow hooks, `listRuns`, the dashboard — receive the values opened, and a caller awaiting a run receives the error as it was thrown. Outside the database: log lines about an encrypted workflow's failure carry the error's class instead of its text, server errors are published with `contentSensitive: true`, and the Sentry plugin sends the class, stack frames and run identity with the message withheld (tag `voltro.contentWithheld`). `withheldServerError(event)` from `@voltro/protocol` does the same for a reporter of your own. `durableQueue({ encrypted: true })` seals a queue's payload, result and failure, and an encrypted workflow that enqueues into an unencrypted queue fails, naming it.
683
+ - **The notifications inbox is live** — `@voltro/plugin-notifications`
684
+
685
+ `notifications.inbox` and `notifications.unreadCount` now declare the plugin's `notifications-inbox` reactivity channel as their `source:`, and every in-app delivery, read, unread and archive change publishes it. `useInbox()` and `useUnreadCount()` update without a reload; before, they re-read only on reconnect.
686
+ - **Stale sources block plan closure; passes record the tool version** — `@voltro/plan`, `@voltro/cli`
687
+
688
+ `voltro plan` hashes each plan's `source` file on every run: a plan whose source changed since its decomposition reports `source/stale` (a missing one `source/missing`), and `close` refuses any plan with a blocking diagnostic as a closure candidate. Closure runs and certificates record `toolVersion`. The plan revision includes the state of dependencies in other plans (closed counts as verified), so a dependency falling back reopens a certified plan. No closure records existed before this change. A new manifest field, `adrDir`, names the decision records: a link to a missing one is `graph/missing-decision`, and a changed record moves the revision of every plan whose items link it.
689
+ - **A port variable Kubernetes set is named as such** — `@voltro/cli`
690
+
691
+ A `*_PORT` variable holding `tcp://<ip>:<port>` is what Kubernetes injects for every Service in the namespace — a Service named `voltro-api` sets `VOLTRO_API_PORT`. The warning for such a value now names the Service and the pod setting (`enableServiceLinks: false`) instead of only saying it is not a port. `VOLTRO_WORKFLOW_RUNNER_PORT` is checked like every other port variable: a value that is not a port was passed to the workflow runner as `NaN` without a word, and is now ignored with the same warning.
692
+ - **`UpdatePrompt` installs a found update by itself** — `@voltro/desktop`, `@voltro/cli`
693
+
694
+ `<UpdatePrompt />` now defaults to `mode: 'auto'`: it says "Checking for updates…" when the window opens, and a newer version on the app's channel is installed at once — download, signature check, install, relaunch — with "Updating to X…" shown meanwhile and "Try again" on a failure. `mode: 'ask'` keeps the panel with "Install and restart" / "Later". The generated desktop and mobile workflows point a build at the update feed Voltro Deploy hands them (`VOLTRO_UPDATE_FEED` as the Tauri updater endpoint with passive Windows installs; `EXPO_PUBLIC_UPDATES_URL` in the EAS build profile's env).
695
+
696
+ ### Fixed
697
+
698
+ - **Clarify agent tool call limit scope** — `@voltro/devtools-ui`
699
+
700
+ Clarify that an agent tool’s declared per-run call limit is not enforced by the MCP transport, avoiding an implied server-side limit in tool inventory details.
701
+ - **Aggregate interval replica coordination** — `@voltro/cli`
702
+
703
+ Align scheduled aggregate intervals across replicas so the SQL coordinator claims the same firing instant instead of independently shifted process-relative times. Losing replicas no longer overwrite the winner's persisted metadata with stale local values.
704
+
705
+ The first interval refresh may run before one full interval has elapsed after startup. Complete the rollout across replicas to avoid mixed old/new interval alignment.
706
+
707
+ Reject zero and non-finite aggregate interval values before scheduling.
708
+ - **Aggregate sql commit atomicity** — `@voltro/cli`
709
+
710
+ Keep the previous SQL aggregate generation intact when refresh persistence fails. Commit rows and metadata transactionally, and publish the local mirror only after the database commit succeeds.
711
+ - **Aggregate sql replica reads** — `@voltro/cli`
712
+
713
+ Reload SQL aggregate generations for handle reads, metadata and dashboard Inspect snapshots so successful refreshes become visible on other replicas without a local rebuild. Read metadata and rows in one SQL statement; report storage failures instead of returning a successful stale or empty snapshot.
714
+ - **Ai flow code inspect catalog** — `@voltro/plugin-ai-flows`
715
+
716
+ Include registered code-first flows in the replica-scoped inspect definition catalog. Code names precede stored IDs using the executor's resolution order; the combined result remains bounded and identifies each definition's source.
717
+ - **Ai flow definition source labels** — `@voltro/devtools-ui`
718
+
719
+ Identify code and database flow definitions in the shared AI Flows catalog. Unknown sources from older endpoints remain unspecified rather than guessed.
720
+ - **Ai flow replica detail retention** — `@voltro/devtools-ui`
721
+
722
+ Keep opened AI flow details attached to their original replica when that replica leaves a single-replica or all-replicas view. Mark the source unavailable and hide stale detail payloads. Users can explicitly open the run on an available replica, then dismiss the old observation without losing its identity silently.
723
+ - **Analytics replica call labels** — `@voltro/devtools-ui`
724
+
725
+ Clarify that analytics activity counts sink calls executed by each replica, including queries and failures, rather than delivered events.
726
+ - **Analytics sqlite timestamps** — `@voltro/plugin-analytics-postgres`
727
+
728
+ Fix SQLite analytics event writes and time-range queries by binding timestamps as UTC ISO strings. Return UTC time-series bucket starts independently of the server timezone, and keep Monday events in the correct weekly bucket.
729
+ - **Api http overview telemetry** — `@voltro/cli`
730
+
731
+ Publish completed API HTTP responses to the dashboard inspect stream in development and production. Requests now contribute status and duration to overview telemetry; inspect endpoints and query parameters are excluded. Streaming responses count once when complete, without counting individual WebSocket RPC messages as HTTP requests.
732
+ - **Migration squashing updates history atomically under the migration lock** — `@voltro/database`, `@voltro/devtools-ui`
733
+
734
+ Migration squashing now validates and updates history under the migration lock in one transaction, preserving original history on failure. Synthetic baseline rows use database timestamps and collision-resistant IDs. Shared dashboard copy and documentation now distinguish history consolidation from schema provisioning; declarative boot does not replay old auto-diff plans. Cutoff mismatch guidance no longer falsely asserts unapplied changes when a later migration is already applied.
735
+ - **The audit docs say what the interceptor records — mutations and actions** — `@voltro/plugin-audit`
736
+
737
+ The audit guide's summary and its "What's recorded" section still said the interceptor records mutation invocations only, while it has recorded mutations and actions by default (queries with `recordQueries`) since actions were added. An app reading the trail for "what was written" therefore met rows for actions it had not expected. The docs and the package description now say what is recorded.
738
+ - **Sign-up no longer joins a tenant the request names** — `@voltro/plugin-auth`
739
+
740
+ `POST /auth/sign-up` took a `tenantId` from the request body and made the new account an owner member of it — so anyone could sign up into any tenant, against the documented contract (new accounts join `defaultTenantId`). A named tenant is now accepted only when it is `defaultTenantId` or listed in the new `signUpTenants` option; any other is refused with `400 { error: 'tenant_not_open' }` before an account exists. An app that relied on the body choosing the tenant lists those tenants in `signUpTenants`, or moves the flow to invitations.
741
+ - **Automatic seed inspection targets** — `@voltro/cli`
742
+
743
+ Report seed outcomes from the shared runner, including automatic boot, tenant-creation, schema-change and cron execution. Preserve the execution namespace, expose automatic failures and clear stale errors after successful retries. Skipped boot seeds report the shared database explicitly.
744
+ - **A `batch:` workflow accepts its payload in the field form** — `@voltro/workflow`
745
+
746
+ `workflow({ payload: { items: Schema.Array(Item) }, batch: { item: Item, … } })` was refused at definition time with a message asking for exactly that shape, because the check read the fields object as if it were a schema. The field form and `Schema.Struct({ items: … })` are one contract everywhere else a workflow takes a payload, and now here too.
747
+ - **Billing routes answer only members and keep provider text off the wire** — `@voltro/plugin-billing`
748
+
749
+ Every `billing.*` route now refuses an anonymous subject — including one that names a tenant through the `x-tenant` header under the framework's development fallback — and a signed-in subject without a tenant, with a `BillingError`. The two `openAccess` reads (`billing.subscription`, `billing.entitlementStatus`) were answering for whatever tenant the header named. A failed Stripe call now carries only the operation, the SDK's error class and Stripe's enumerated code (`StripeCardError (card_declined, insufficient_funds)`); Stripe's text, which quotes object ids and the masked key, goes to the server log. A failed store call carries only the operation. `reportUsage` validates its key (1–100 of `[A-Za-z0-9_.:-]`, a letter or digit first) before it names a counter or a meter event.
750
+ - **Hand-reported usage is flushed, and subscription invoices resolve their tenant** — `@voltro/plugin-billing`
751
+
752
+ The self-scheduled flush tick now pushes usage reported through `billing.reportUsage` even without a `metering` config; before, such counters stayed pending and were never billed. `invoice.paid` and `invoice.payment_failed` now read the tenant from the subscription snapshot Stripe stores under `parent.subscription_details.metadata`, so subscription invoices are mirrored and a failed payment reaches dunning.
753
+ - **Billing inspector usage counter meaning** — `@voltro/devtools-ui`
754
+
755
+ Clarify billing inspector scope and usage counters: used is cumulative, reportedToProvider is already reported, and only their difference remains pending. Flushing does not reset usage. Replica snapshots of shared storage are compared rather than summed.
756
+ - **Metered Stripe usage reaches the invoice through the customer's meter events** — `@voltro/plugin-billing`
757
+
758
+ Stripe Billing Meter events now carry the tenant's linked Stripe customer in `stripe_customer_id` instead of the tenant id, so usage is attributed and billed. Each flushed delta gets its own `identifier`, so later deltas in a period are no longer dropped as duplicates. A tenant without a linked customer keeps its usage pending instead. `checkout.session.completed` now links the customer Checkout created. A plan's new `meteredPriceIds` are added to checkout as quantity-less line items, and plan and seat handling uses the licensed subscription item rather than the first item.
759
+ - **Budget hold unique conflict key** — `@voltro/workflow`
760
+
761
+ Declare the budget hold identity as unique so SQL insert-or-ignore can create and replay durable holds. Previously SQLite rejected the conflict target, preventing the workflow from parking. Repeated waits reuse one row; fresh hold generations remain distinct. Include the updated framework table schema in the normal SQL migration before upgrading.
762
+ - **Validate budget panel snapshots and allow read recovery**
763
+
764
+ The shared dashboard validates budget limits, timestamps, and cost attribution before rendering a snapshot. Invalid responses show a recoverable error instead of crashing, appearing empty, or retaining stale totals. Retrying the read preserves the selected replica and tenant filter without changing accounting state.
765
+ - **Budget suspension policy clarity** — `@voltro/devtools-ui`
766
+
767
+ Clarify that a compute budget's suspension policy requires enforcement by workflow code before spending. The inspector's usage counters alone do not pause arbitrary work or confirm that a durable hold was applied.
768
+ - **voltro check reads the app's scope vocabulary and leaves plugin routes alone** — `@voltro/cli`, `@voltro/mcp`
769
+
770
+ `voltro check` compared guards against the scopes plugins declare only, while `voltro doctor` also read the app's own `doctor.scopeVocabulary`. An app granting its own scopes through `auth.resolveScopes` and installing `rbacPlugin` with a role map therefore passed doctor and failed check with an `rbac/unknown-scope` error on every guard naming one of its own scopes. Both now read the same vocabulary — plugin-declared plus app-declared. A plugin's own routes are exempt from the rule: their scopes (storage's `storage:manage`) are the plugin's contract, not a typo the app can fix. The capability manifest marks such a procedure with `plugin: '<name>'` (also on `@voltro/mcp`'s `ManifestProcedure`).
771
+ - **voltro data export records its run, as the admin endpoint's export does** — `@voltro/cli`
772
+
773
+ The documentation says every transfer leaves one `_voltro_data_transfers` row, in either direction; a direct `voltro data export` left none, so `voltro data transfers` could not say whether last night's backup finished. It now opens the row before it runs (`via: direct`) and closes it with the tables, rows and source fingerprint — or with the failure. The admin endpoint and the CLI share one pair of functions (`exportRunRecord.ts`).
774
+ - **The gRPC transport requires a patched @grpc/grpc-js** — `@voltro/cli`
775
+
776
+ `@voltro/cli`'s optional `@grpc/grpc-js` dependency now requires `^1.14.5`, the first release that closes GHSA-m9gg-hp2v-232j and GHSA-f596-whhp-79r4. Apps that serve gRPC pick it up on their next install; nothing else changes.
777
+ - **voltro cloud scan registers mobile and desktop apps** — `@voltro/cli`
778
+
779
+ The scan only recognised directories with an `app.config.ts`, so a mobile app (whose `app.config.ts` is Expo's) was registered as an `api` and a desktop app not at all. A directory with `voltro.mobile.ts` or `voltro.desktop.ts` is now an app of kind `mobile` or `desktop`, named after its package, so the cloud can offer release targets per app.
780
+ - **The CMS kernel installs and runs on mysql and mariadb too** — `@voltro/cms`
781
+
782
+ On mysql the CMS tables did not apply (`ER_TOO_LONG_KEY`: the draft/published variant key and `cms_paths_by_document` exceeded the 3072-byte index limit), and on mariadb `cms_paths` did not. The kernel's identity and scope columns — content type, document, site, environment, locale and the variant/type references — are now `text().maxLength(64)` (`cms_glossary.termKey` 100), so every table applies on sqlite, postgres, mysql, mariadb and mssql. An existing database migrates the columns from unbounded text to `varchar(64)` (a narrowing: the plan names the check query; the values are ids and scope keys well below the bound). A new dialect-parity suite runs the kernel's drafts, locale variants, immutable revisions, approvals, scheduled publishing and releases on all five.
783
+ - **ContentForm applies a widget's late change to the current form value** — `@voltro/cms`
784
+
785
+ A widget's `onChange` copied the form value from the render it was created in, so a change that landed later — an upload finishing after the editor typed in another field, a picker resolving — overwrote the edits made in between. `<ContentForm>` and the components editor now apply every change to the value as it is when the change lands.
786
+ - **CMS Schema.Number refuses fractions at save; Schema.Fraction takes them** — `@voltro/cms`
787
+
788
+ `Schema.Number` has always been an integer column. A fractional value passed validation and then failed in the database (Postgres) or was rounded silently (MySQL in non-strict mode). It is now refused at save time with the rule `integer` and a message pointing at `Schema.Fraction`. The new `Schema.Fraction` is a `real` column whose editor input takes any step, so it can hold values such as a focal point's `0.25`.
789
+ - **slugify keeps umlauts and accented letters** — `@voltro/cms`
790
+
791
+ `slugify` dropped every letter outside a–z, so `Über uns` became `ber-uns` and `Café` became `caf`. Umlauts are spelled out now (`ueber-uns`, `ß` as `ss`) and accented letters fold to their base letter (`cafe`). A document whose slug is derived gets the new slug on its next save; a published page's old address keeps redirecting.
792
+ - **Content health and translation status read in batches** — `@voltro/cms`
793
+
794
+ `contentHealth` read the translation status of every live document one by one — three reads per document — and took longer than a request may on a site with thousands of pages. `translationStatuses(store, type, documentIds, scope, sourceLocale, targetLocales)` reads many at once (three reads per 500 documents) and `contentHealth` uses it; `translationStatus` for one document is unchanged.
795
+ - **CMS unique fields no longer stop a postgres or SQLite boot** — `@voltro/cms`
796
+
797
+ A field marked `Schema.unique()` gave the drafts and the published table the same constraint name, `uq_<field>`, and postgres and SQLite keep index names per schema, so auto-migrate refused to boot any app with a unique field there. The constraints are named `<type>_<drafts|published>_uq_<field>` now, and a name past the 63-byte identifier limit fails at definition time, naming the field and the fix.
798
+ - **ContentForm renders custom widgets as components, so they may use hooks** — `@voltro/cms`
799
+
800
+ `<ContentForm>` called each widget as a plain function, so a custom widget that used a hook (an upload, a subscription to pick a referenced document from) shared the form's hook list and crashed with "Rendered more hooks than during the previous render" as soon as the set of fields changed. Widgets are now rendered as components; the `Widget` signature is unchanged.
801
+ - **Comment inspector detail metadata** — `@voltro/devtools-ui`
802
+
803
+ Identify the selected thread and source observation in comment details. Format message creation times for the dashboard locale and show edit times when present, while withholding stale message contents after read failures.
804
+ - **plugin-comments no longer makes voltro check flag an app's own scopes** — `@voltro/plugin-comments`
805
+
806
+ `commentsPlugin` declared `comments:moderate` as its `declaredScopes`. That list is read as the app's exhaustive scope registry, so installing the plugin turned every guard on a scope the app grants elsewhere (through `auth.resolveScopes`, say) into an `rbac/unknown-scope` error in `voltro check`. The plugin now declares no scope list; `comments:moderate` still guards deleting others' comments.
807
+ - **Comments inspector navigation state** — `@voltro/devtools-ui`
808
+
809
+ Restore the comments inspector's search, selected thread and independent thread/message page positions through page reloads and browser history. Local and Cloud app hosts keep this state in the route while preserving replica selection.
810
+ - **A compressed response keeps the route's own Vary** — `@voltro/runtime`, `@voltro/cli`
811
+
812
+ Response compression merged its `vary: Accept-Encoding` over the route's headers, and since header names are case-insensitive it replaced a route's own `Vary` (`Vary: Authorization, Cookie` on an answer that differs per credential). A shared cache in front could then serve one caller's answer to another. `Vary` is now merged as a list (`Authorization, Cookie, Accept-Encoding`) on the api listener and in `voltro start` (`mergeCompressionHeaders`).
813
+ - **Connected process inspection without fleet discovery** — `@voltro/devtools-ui`
814
+
815
+ The local dashboard replica selector can explicitly select the connected process from an authenticated app observation when fleet discovery is unavailable. It preserves existing selections and never presents that process as a complete fleet inventory. Production web apps without shared discovery can now be inspected through this explicit selection.
816
+ - **ContentForm labels are bound to their controls** — `@voltro/cms`
817
+
818
+ `<ContentForm>` rendered each field's `<label for>` without an `id` on the control, so screen readers announced unnamed fields and label clicks focused nothing. Every control carries an id unique on the page now, and widgets receive it as `WidgetProps.id`. A custom widget passed through `widgets` should put that `id` on the control it renders so its label names it.
819
+ - **CRDT increments fold at every stage — no reader keeps only the last one** — `@voltro/client`, `@voltro/local-first`, `@voltro/protocol`, `@voltro/api-client`, `@voltro/runtime`
820
+
821
+ A live query over a CRDT column streams each changed cell as an increment (`mergeCells`), which only means something folded into the cell held before. Three places did not fold it, and each could leave a reader holding only the newest increment — another editor's typing missing until the next snapshot: the web client's cache had no merger (importing `@voltro/local-first` now registers `crdtMergeCell` through `registerCellMerger`, which `@voltro/client` reads); `applyRowPatch` let a `replace` beside a `mergeCells` drop the held cell (it now keeps the columns the merge folds); and a subscription's backpressure outbox kept increments as whole cells and collapsed a slow reader's deltas into a `replace` carrying one (it now folds and re-diffs CRDT columns incrementally).
822
+ - **useCrdtText setText takes the text the field showed** — `@voltro/local-first`
823
+
824
+ `setText(next, base)` diffs a keystroke against `base` — the text the field showed when the key was pressed — and moves it over an edit that arrived in between, instead of diffing it against the current text and reading the other editor's characters as deleted. An uncontrolled field (`defaultValue`) passes what it showed; a controlled field needs no `base`. The rebase is exported as `rebaseEdit(base, next, current)`.
825
+ - **Dashboard cluster coordination counts** — `@voltro/cli`, `@voltro/devtools-ui`
826
+
827
+ Count running schedules independently of recent history and include all retained schedule claims in cluster inspection. Propagate SQL read failures instead of reporting false zero counts. Label schedule-history replica IDs as recent activity rather than active membership.
828
+
829
+ Keep absent optional tables empty, but inspect the underlying SQL error rather than treating every statement-prepare failure as a missing table across inspect endpoints.
830
+ - **Dashboard cluster read states** — `@voltro/devtools-ui`
831
+
832
+ Separate cluster loading, failed reads and successful empty snapshots. Hide cached instances and coordination counts during pending or failed reads, expose accessible status/error announcements, and retain string error details.
833
+ - **Dashboard dev configured apps** — `@voltro/cli`
834
+
835
+ The local dashboard in dev mode now exposes configured app registrations through the authenticated dashboard config endpoint, matching start mode. Public registration data includes credential-presence flags only; inspect credentials remain on the server.
836
+ - **Dashboard events read states** — `@voltro/devtools-ui`
837
+
838
+ Distinguish loading, failed reads and empty event inventories. Hide stale routes, counters and live indicators during failed or pending reads, preserve transport error messages and restore the expanded event after recovery.
839
+ - **Dashboard fleet read states** — `@voltro/devtools-ui`
840
+
841
+ Keep cached fleet observations out of pending and failed reads, including explicit falsy error values. Announce loading as a status and render replica completeness again only from a successful read.
842
+ - **Keep dashboard inspection readable after an entry replica fails** — `@voltro/cli`
843
+
844
+ The local dashboard remembers recently observed, proxy-allowed fleet addresses and retries reads through them after a connection failure. App identity, replica selection and saved operation IDs remain stable across handover and page reload. Routes are isolated by app mount and read credentials and expire after two minutes without a fresh fleet observation.
845
+
846
+ HTTP refusals are preserved. Failed writes are never automatically replayed; subsequent explicit writes use the reachable entry learned by successful reads. Both development and production dashboard proxy paths share the same transport.
847
+ - **Dashboard logs read states** — `@voltro/devtools-ui`
848
+
849
+ Distinguish loading, failed and empty log reads. Hide stale records while a new filter loads or fails, preserve transport error messages, and retain selected filters during recovery.
850
+ - **Dashboard migration action feedback** — `@voltro/devtools-ui`
851
+
852
+ Gate soft-drop restoration by migration-write capabilities. Prevent duplicate and overlapping migration writes, catch rejected actions, show outcome feedback and refresh status after success where supported.
853
+ - **Dashboard migration comparison stale results** — `@voltro/devtools-ui`
854
+
855
+ Remove a previous environment comparison while retrying and after a failed request, including cached results loaded from the server. Keep other environments visible and display the fresh result after a successful retry.
856
+ - **Dashboard migration comparison url** — `@voltro/cli`
857
+
858
+ Fix migration environment comparison for root app URLs with or without a trailing slash. Resolve the inspect endpoint once while retaining path prefixes and complete endpoint URLs.
859
+ - **Dashboard numeric filter validation** — `@voltro/devtools-ui`, `@voltro/cli`
860
+
861
+ Validate numeric data filters before applying them. Decimal strings retain their exact digits, while integer filters reject fractional and unsafe values. Inspect rejects nonnumeric coercions such as booleans or empty strings. Null-only columns start with a supported filter operator.
862
+ - **Dashboard overview replay cursors** — `@voltro/cli`
863
+
864
+ Give inspect events replay-stable stream cursors so dashboard overview counters can ignore replayed requests and cache events without losing simultaneous identical requests or events after a process restart.
865
+ - **The dashboard registry endpoint no longer hands inspect tokens to the browser** — `@voltro/cli`
866
+
867
+ `/api/dashboard/registry`, which the dashboard's browser polls to list the apps running on this machine, served `~/.voltro/runtime-registry.json` as it was — every local app's `VOLTRO_INSPECT_TOKEN` included — while the dashboard's proxy attaches those tokens server-side and the browser never needed them. Each entry now carries `hasToken` instead, under `voltro dev` and `voltro start` alike. The runtime registry file itself is unchanged, so `voltro logs`, `inspect` and `traces` still authenticate from any directory.
868
+ - **Dashboard schema choice filters** — `@voltro/devtools-ui`
869
+
870
+ Use declared schema choices in data explorer filters, including searchable multi-selection and exact values containing commas or empty strings. Keep filter value shapes compatible when changing operators.
871
+ - **Dashboard soft drop inventory** — `@voltro/cli`, `@voltro/devtools-ui`
872
+
873
+ Discover retained soft-drop tables and columns from the live schema and show their names and timestamps in the migrations dashboard, including read-only sessions. Keep observed inventory separate from plan-based restoration rather than guessing plan ownership.
874
+ - **Dashboard timeline expired selection** — `@voltro/devtools-ui`
875
+
876
+ Stop replaying pinned timeline positions outside the retained process history. Show an explicit unavailable-position message and let users return to the current state without presenting stale rows as valid history.
877
+ - **Dashboard timeline read states** — `@voltro/devtools-ui`
878
+
879
+ Distinguish loading and failed timeline reads from disabled recording. Hide stale replay rows unless their table and sequence match the selected historical state, and avoid replay requests while the timeline is unavailable.
880
+ - **Dashboard timeline replay window** — `@voltro/cli`
881
+
882
+ Reject timeline replays outside retained history, while recording is disabled, or when new events arrive during the current-row read. Validate integer sequence positions and redact sensitive fields in live row images as well as recorded changes.
883
+ - **Dashboard trace deep link query** — `@voltro/cli`, `@voltro/devtools-ui`
884
+
885
+ Resolve trace deep links by trace ID before limiting the dashboard list. Focused reads include retained background traces and return grouped snapshots to dashboard consumers.
886
+ - **Dashboard traces read states** — `@voltro/devtools-ui`
887
+
888
+ Separate pending, failed and empty trace reads. Hide retained traces and counters while the current filter is loading or has failed, preserve transport error messages, and retain the selected error filter through recovery.
889
+ - **Retry, cancel and resume reach every local app — from the dashboard and the CLI** — `@voltro/cli`
890
+
891
+ `voltro dev` mints an inspect write token per app, but the dashboard proxy, the in-page overlay's channel to its api and the storage upload handoff forwarded their OWN process's `VOLTRO_INSPECT_WRITE_TOKEN` to every target, so each mutating action against another app — a workflow retry, cancel or resume, a storage share, a governance erase — was refused with `401`. Each app now publishes its write token on its runtime-registry entry beside the read token (the file is written 0600, like the `.env.local` that already holds both), and the forwarders attach the target's own, matched on origin, with no fallback to their own. Mutating CLI commands (`voltro workflows retry`, `schedule run`, `inspect invoke`, …) read it from the entry too, so they work from any directory, as reads always have. The dashboard's registry endpoint tells the browser `hasWriteToken`, so a registry-discovered app's panels offer those actions; no token value reaches the browser.
892
+ - **Data explorer search clear focus** — `@voltro/devtools-ui`
893
+
894
+ Show one clear-search control in the shared data explorer instead of combining native and custom clear icons. Return keyboard focus to the search input after clearing so users can immediately enter another query.
895
+ - **Data filter list drafts** — `@voltro/devtools-ui`
896
+
897
+ Preserve comma-separated filter drafts while typing or pasting values for in/not-in conditions. Incomplete numeric input remains readable instead of being replaced by NaN, while invalid numeric lists still prevent applying the filter. External filter replacements reset the displayed draft.
898
+ - **Data reference keyboard focus** — `@voltro/devtools-ui`
899
+
900
+ Return keyboard focus to the reference selector after choosing a linked row in the shared data editor, so keyboard users can continue editing without losing their position.
901
+ - **Keep derived observations and restart fences in their source environment** — `@voltro/data-transfer`, `@voltro/cli`
902
+
903
+ Default data exports omit materialized aggregate caches, timeline capture storage and restart withdrawal fences. Imports keep these deployment-local observations out of another environment, and aggregate cache tables no longer prevent ordinary exports through their composite key.
904
+ - **A logical backup restores into a freshly deployed database** — `@voltro/cli`, `@voltro/data-transfer`, `@voltro/sql-postgres`
905
+
906
+ Found by restoring a real app end to end (encrypted export with assets → `voltro db deploy` on an empty database → `voltro data import`), each fixed at its source:
907
+
908
+ - `voltro db plan/apply/deploy` now see the tables a schema file registers through `databaseHandle({ … })` without exporting them (a builder such as `contentTypeToEntities(...)` returns `{ draft, published }`). `voltro dev` already did; a production deploy created none of them. Both read one function now. - `voltro data export/import/backup/restore --assets` bind a `storagePlugin({ provider: 'database' })` provider to the command's store; every such run failed with "the 'database' storage provider isn't bound to a DataStore yet". - The live-import guard reads the instance's `databaseId` where the inspect answer puts it (at the top, not under `meta`), so a restore into another database on the same server is no longer refused while a dev server runs. - A `json` column holding a scalar (a string, a boolean) survives the import on postgres — through the row path (the importing process registers no table, so nothing encoded it) and the COPY fast path (a string was written bare, a boolean as `t`). - `@effect/cluster`'s `cluster_*` tables and `_voltro_plugin_migrations` are environment-local: left out of a whole-database export and skipped on import, like the other per-deployment tables. - With `storagePlugin({ provider: 'database' })` a logical export/import runs no separate asset phase: the files are `_voltro_storage_blobs` rows and travel with the tables. The asset phase used to put each file again under a new row id, so a restored database held every file twice.
909
+ - **Data viewer page size links** — `@voltro/devtools-ui`
910
+
911
+ Preserve data-explorer page size in the URL state so reloading a later page does not silently change its offset and displayed records. The optional DataViewerUrlState.pageSize field uses supported sizes only and defaults to 50.
912
+ - **sqlite: full-text indexes are created — trigger bodies are no longer split** — `@voltro/database`
913
+
914
+ `applySchema` split DDL at every semicolon, including those inside a `CREATE TRIGGER … BEGIN …; END` body, so the FTS5 sync triggers of a `.fullTextIndex(...)` failed on sqlite with "incomplete input" and the table could not be created. Trigger bodies are kept whole now, like postgres `$$` bodies.
915
+ - **voltro db branch leaves Timeline capture storage out of the branch** — `@voltro/cli`
916
+
917
+ `voltro db branch` copied the Timeline capture tables of a PostgreSQL database into the branch without their metadata row, so the rehearsal's first schema change was refused with `Timeline capture retirement failed`. Capture storage describes the source database's own epoch; the branch no longer copies it.
918
+ - **db files runs after-schema files only once the plan is empty; db migrate is loud** — `@voltro/cli`
919
+
920
+ `voltro db files` ran both phases back to back, so in a deploy job of `db files` → `db plan --json` → `db apply --plan` it started an `after-schema` backfill before the plan had created the table it reads, and every retry failed in the same place. While a schema plan is pending it now runs the `before-schema` files, reports the `after-schema` ones as waiting and exits 0; `db apply --plan` runs them after the plan, so that job works as written. `--phase before-schema|after-schema` runs one pass, and asking for the after-schema pass over a pending plan is refused. `voltro db migrate` — which runs `defineMigration` `*.migration.ts` steps only — printed `no migrations found` and exited 0 over pending `migrations/<id>.ts` files; it now exits 1 and names them, and `db rollback` points to `db rollback-file` the same way.
921
+ - **`voltro db plan` / `db apply` work for an app with no tables of its own** — `@voltro/cli`
922
+
923
+ An api app whose data lives only in workflows, schedules or the key-value store declares no table of its own, and `voltro db plan` refused it with `no schema files found` — while `voltro serve` refuses a database no plan was applied to, so no production path created the framework tables it needed. When the root has an `app.config.ts`, `db plan`, `db apply`, `db branch`, `db squash` and `db drift` now plan the framework's tables for it. A directory with neither an `app.config.ts` nor a schema file is still refused, now as `no app found`, naming both.
924
+ - **voltro deploy screenshots reads the app directory after valued flags** — `@voltro/cli`
925
+
926
+ `voltro deploy screenshots --out shots apps/web` took `shots` as the app directory, because the positional reader did not know which flags carry a value. `--base-url`, `--locales`, `--out`, `--presets`, `--routes` and `--settle` are declared now, so the app directory is found wherever it stands on the command line.
927
+ - **The desktop workflow notarizes from the App Store Connect key's content** — `@voltro/cli`
928
+
929
+ `voltro desktop ci` reads `APPLE_API_KEY_CONTENT` (the .p8 as text, the form a vault hands over) beside `APPLE_API_KEY_PATH`: when only the content is set, the build step writes it to a file the runner alone holds and points notarization at it. Before, a run whose secrets carried the content and no path built a signed but unnotarized macOS app.
930
+ - **voltro desktop says when a build step was killed rather than failing** — `@voltro/cli`
931
+
932
+ When a step of `voltro desktop` (the web build, `tauri build`, the api build) is ended by a signal — an out-of-memory kill, a cancelled CI job — the command now prints which step was killed and by which signal, instead of reporting only a non-zero exit.
933
+ - **voltro dev: a restarted dev server no longer outlives its terminal** — `@voltro/cli`
934
+
935
+ After a `.env` or `app.config.ts` hard restart, the first process supervises the replacement. It forwarded SIGINT and SIGTERM only: closing the terminal or tmux pane (SIGHUP) ended the supervisor and left the replacement reparented to init, still bound to the port — the next `voltro dev` refused the busy port, and the orphan's own `.env` watch kept starting servers. The supervisor now forwards SIGHUP too, stops the replacement when it loses its own parent, and both restart paths share one implementation.
936
+ - **The devtools overlay stops asking a refusing inspect surface and says why** — `@voltro/devtools`
937
+
938
+ An api whose inspect surface refused the overlay (`401`/`403`) was polled and re-streamed at full cadence for as long as the console stayed open — a request every one to four seconds, and nothing in the panel explaining it. A refusal is now recorded per api: the console shows which api refused, the status and the `reason`/`fix` the server sent, offers a sign-in form when the server has a devtools lock, and asks that api nothing more until the console is closed and reopened. A failure with no readable status — a network error, or an api on another origin whose refusal the browser hides — backs off exponentially and is reported after three in a row; a `5xx` only backs off. A refused action (a missing write factor) is shown on the action and does not stop the reads. The strings are overridable under the new `inspect` section of `DevtoolsStrings`.
939
+ - **voltro doctor in a web app checks voltro start's build, not api service edges** — `@voltro/cli`
940
+
941
+ `voltro doctor` in a web app exited 1 on two findings that cannot apply to one. It asked for the api's serve bundle (`.framework/dist-api/serveBundle/serveEntry.js`), which no command a web app runs writes, and it told the app to be added to its api's `serviceCallers` because of the web app's `apis:` binding — which is the browser binding, whose calls carry the user's session, not a service credential; following that advice opened a credential the same report then flagged as opening no door. In a web app the preflight now checks what `voltro start` needs (`.framework/dist/index.html`, `.framework/dist/server/ssrEntry.js`), blocking under `NODE_ENV=production` or `--strict`, and the caller-side service-edge check runs for api apps only.
942
+ - **Environment inspection preserves diagnostic text** — `@voltro/devtools-ui`
943
+
944
+ Preserve diagnostic text from string-valued Environment inspection failures instead of replacing it with an unknown-error message.
945
+ - **Environment unset state label** — `@voltro/devtools-ui`
946
+
947
+ Fix: Label unset but permitted environment variables accurately, without implying that every optional variable has a configured default. Environment values remain absent from Inspect responses and the dashboard.
948
+ - **Keep invalid event observations out of live dashboard state**
949
+
950
+ The shared Events panel reports malformed snapshots as read errors instead of crashing, waiting indefinitely, or presenting misleading empty/live state. Hosts with refresh support offer an explicit retry.
951
+ - **Keep event inspection readable across languages and narrow screens**
952
+
953
+ Event route ages are localized and update while expanded. Long names and keys wrap without widening the page; wide route tables support local keyboard scrolling. Origin badges retain contrast in light and dark themes.
954
+
955
+ Missing routes are described as absent from the current retained observation, rather than as proof that an event was never used.
956
+ - **The api executable serves from its bundle root, whatever its caller's cwd** — `@voltro/cli`
957
+
958
+ `voltro build api --target executable` produced a launcher that ran `serve .` in the caller's working directory. Started as a desktop sidecar from a shell whose cwd was a repository root, the api discovered that directory's files instead of its own and never reported ready. The launcher now changes into the bundle root before serving.
959
+ - **Prepare file migration resources only when execution is allowed** — `@voltro/database`, `@voltro/cli`
960
+
961
+ Add an optional `beforeExecute` Effect to low-level file migration runner contexts. Forward preparation runs once after the locked pending-work check; rollback preparation runs only after ledger, file and reversibility checks. Failed preparation prevents body execution without changing migration receipts.
962
+
963
+ Use this boundary to retire an installed internal SQLite Timeline capture contract before forward or rollback bodies can reshape captured columns. Empty passes and refused rollbacks preserve active history. Automatic Timeline source capture remains disabled pending complete schema and runtime integration.
964
+ - **Flags durable initialization after store binding** — `@voltro/cli`, `@voltro/plugin-flags`, `@voltro/protocol`
965
+
966
+ Await asynchronous plugin DataStore binding before accepting requests. Development binding now runs after schema preparation. Durable feature flags initialize after both activation and store binding, so overrides and usage tracking work in dev as well as serve and survive process restarts.
967
+ - **Validate flag observations and unconfirmed toggle replies**
968
+
969
+ Malformed flag lists, lifecycle evidence and audit histories now show recoverable read errors instead of crashing or exposing stale controls. Invalid toggle replies trigger state reconciliation before further changes, and failures with empty messages receive explicit guidance.
970
+ - **Flags toggle write scope** — `@voltro/plugin-flags`, `@voltro/devtools-ui`
971
+
972
+ Explain flag toggle scope beside the shared dashboard controls. Inspect snapshots now report whether toggles affect only the serving process or persist in the shared store. Replica selection chooses the writer; it does not isolate a durable override. Older apps show an explicit unknown-scope explanation.
973
+ - **Migrations the 0.78.0 upgrade edited no longer warn on every boot** — `@voltro/database`, `@voltro/cli`
974
+
975
+ A file migration applied before 0.78.0 warned on every boot that `the body on disk is not the one that was applied`, three times per boot. The only difference was the `phase: 'before-schema'` the 0.78.0 upgrade inserted — a required field, so the file could not be put back. The drift check now recognises a migration body as it was before a framework edit, in every layout the upgrade writes, and still reports an edit of yours on top of it. The warning is said once per process instead of once per pass.
976
+ - **Reactive framework queues receive PostgreSQL change triggers**
977
+
978
+ PostgreSQL change-trigger convergence now checks reactive framework tables as well as application tables. Missing inference-queue triggers can otherwise leave offloaded workflow calls pending indefinitely, even on a single instance. Explicit non-reactive declarations remain respected. Inference status badges use readable outlines, and narrow layouts show complete workflow names and failure details.
979
+ - **Governance incomplete erasure feedback** — `@voltro/devtools-ui`
980
+
981
+ Show incomplete governance erasures with their affected tables in action results and history. Distinguish completed anonymization from deletion.
982
+
983
+ Keep long coverage-warning lists in a bounded, keyboard-scrollable area while preserving the visible warning count and every entry.
984
+ - **Hop-mode trusted proxies believe x-forwarded-proto** — `@voltro/runtime`
985
+
986
+ `security.trustedProxies: ['1']` (a hop count) already took the caller's address from the forwarded chain, but `x-forwarded-proto` was believed only for `'*'` or a listed peer — so a hop-mode deployment behind a TLS-terminating edge never sent `Strict-Transport-Security`. Hop mode now believes the protocol on the same basis as the address.
987
+ - **Inference queues expose complete counts and independent pages** — `@voltro/cli`, `@voltro/devtools-ui`
988
+
989
+ Count pending, running, failed, long-waiting and expired-lease inference jobs across the retained queue instead of a bounded row sample. Expose independent waiting/recent pagination through Inspect and both dashboard providers. Deterministic ordering, bounded page sizes and validated offsets keep larger queues navigable without silently hiding rows.
990
+ - **Inference queue exposes loading and inactive states** — `@voltro/devtools-ui`
991
+
992
+ Keep the inference queue visible while loading, when no snapshot is available, and when an app has no registered workflows. An empty active queue without a dispatcher report now shows its missing-health warning instead of disappearing; read errors continue to hide retained queue health.
993
+ - **Inspect access distinguishes server credentials from browser overrides** — `@voltro/devtools-ui`
994
+
995
+ Inspect access can show credentials held by the dashboard server separately from editable browser overrides. Server-held credentials have no browser removal control; removing an override returns to the server credential. Saving credentials for an automatically discovered local app preserves its stable identity and survives reload without storing a selected-replica URL.
996
+
997
+ Registered local apps follow current server-credential availability for the same target. Saved browser credentials synchronize across open dashboard tabs without clearing unfinished form entries; malformed stored registrations are ignored.
998
+
999
+ An explicit browser read token no longer makes server-held write credentials appear usable. Mutation controls stay hidden until the browser also has a write token or the override is removed, matching the proxy's existing authorization policy.
1000
+ - **Inspect access always reports failed credential saves** — `@voltro/devtools-ui`
1001
+
1002
+ Inspect access shows a translated error when saving credentials fails, even if the provider rejects without a diagnostic value. Entered credentials and removal choices remain available for retry; a successful retry clears them and the error.
1003
+ - **Inspect namespace inventory** — `@voltro/cli`, `@voltro/devtools-ui`
1004
+
1005
+ Expose existing SQL namespace inventory through the authenticated inspect endpoint. Distinguish unsupported discovery from an empty inventory and surface discovery failures. Reject seed targets absent from an available namespace inventory before execution.
1006
+
1007
+ Show discovered namespaces in the shared seed confirmation through local and Cloud hosts. Distinguish empty, unsupported and failed inventory states; block stale selections while retaining the rest of the database snapshot.
1008
+ - **Inspect rpc input schema validation** — `@voltro/cli`
1009
+
1010
+ Decode inspect RPC inputs against the procedure schema before invoking mutation or action handlers. Apply schema transformations once and return readable field-level parse errors without executing invalid inputs.
1011
+ - **Inspect seed namespace isolation** — `@voltro/cli`, `@voltro/devtools-ui`
1012
+
1013
+ Inspect seed actions accept an explicit namespace and execute against that namespace's store. Tenant-creation seeds now refuse ID-only execution, and empty namespaces or stores without namespace isolation are rejected before seed steps run.
1014
+
1015
+ The shared dashboard confirmation exposes the target namespace and requires it for tenant-creation seeds before enabling execution.
1016
+
1017
+ Seed results retain and display the reported target namespace. Shared-store runs and legacy results with unknown targets are distinguished explicitly.
1018
+ - **An interrupted `voltro` command no longer exits 0** — `@voltro/cli`
1019
+
1020
+ The `voltro` launcher ended with exit code 0 when the command it ran was killed by SIGTERM, SIGINT or SIGHUP: it re-raised the signal on itself, its own forwarding handler caught it, and the process simply ended. A gate that ran `voltro doctor --json` or `voltro update --dry-run` under a timeout therefore read an unfinished check as a successful one, with empty or partial output. The launcher now ends by the same signal as its command (a shell reads 130 for SIGINT, 143 for SIGTERM), and so do the processes `voltro dev` restarts itself through.
1021
+ - **A loader's notFound() renders the nearest not-found.tsx inside its layouts** — `@voltro/cli`, `@voltro/web`
1022
+
1023
+ A loader that called `notFound()` answered 404 with nine bytes of `text/plain` on `voltro dev` and `voltro start` alike — no layout, no navigation, no way back — although the routing docs promised the subtree's `not-found.tsx`. It now answers 404 with the nearest `not-found.tsx` for the URL (the file an unmatched URL under that path gets) rendered inside that directory's layouts, minus the layout whose loader threw and every layout below it; the layouts above render with their own loader data, and a page loader's `notFound()` keeps them all. The same rule holds on a client-side navigation, and the 404 document hydrates into it. Four defects went with it: a not-found under a dynamic segment (`teams/[teamId]/not-found.tsx`) was never picked, because its prefix was compared as a literal string; `voltro dev` answered an unmatched URL's rendered not-found with status 200; `voltro start` could not load any `not-found.tsx` from its production bundle and answered every 404 in plain text; and on a client navigation a loader error rendered the layouts without their data, so the first layout reading it threw over the real error — the layouts that loaded now keep their data around `error.tsx`. When several loaders of one request fail, the outermost one decides, in the browser and on the server. The rendered not-found reports `x-voltro-rendered-by: ssr-not-found`; `loader-not-found` now marks only the plain-text answer of an app with no `not-found.tsx` for the path.
1024
+ - **Local dashboard rollback availability** — `@voltro/cli`
1025
+
1026
+ Remove the development inspect rollback placeholder that reported success without rolling back a migration. Apps without an implementation now report rollback as unavailable; the local dashboard no longer offers that unsupported action.
1027
+ - **Unsent edits survive a reload** — `@voltro/local-first`
1028
+
1029
+ A sync client with a persistence adapter saved its queue but never read it back, so an edit made offline was lost with the page. The next `createSyncClient` on the adapter now enqueues the saved queue once more. `useCrdtText` and `useCrdtDoc` keep each cell's unsent edits under a key of the cell's own — several fields on one adapter no longer overwrite each other's queue — hand them over once on the next mount, and clear them when every write has landed. A client rebuilt for another option (`adapter`, `online`, …) now starts from the state that already arrived instead of from nothing.
1030
+ - **The Local Stack reads readiness from the sidecar's own "ready" line** — `@voltro/desktop`
1031
+
1032
+ `startLocalStack` waited for `/internal/readiness` over the window's `fetch`, which never answers: the sidecar is another origin and the probe sends no CORS headers, so every Local Stack window timed out after the api had long been serving. The serve boot's own `serve: ready in <n>ms` line on stdout now counts as readiness (the HTTP probe stays as a second path for an injected `fetch`), and a sidecar that stalls or dies fails with its last output lines and the last probe outcome instead of a bare timeout.
1033
+ - **Localized seed cards** — `@voltro/devtools-ui`
1034
+
1035
+ Localize seed actions, lifecycle labels, result states, dates and row counts in English and German. Keep long identifiers and errors inside the card, and show an understandable fallback for invalid timestamps.
1036
+ - **Log cause and structured details** — `@voltro/devtools-ui`
1037
+
1038
+ The shared log page now displays captured error causes and stack traces previously omitted from its record model. Structured fields and complete captured causes are available in expandable, bounded detail areas. Incoming unchanged records preserve their expanded state.
1039
+ - **Log snapshot freshness** — `@voltro/devtools-ui`
1040
+
1041
+ Log views show successful snapshot capture times and can explain their provider's polling interval. The local dashboard declares its actual four-second delay after each completed request. Read failures explicitly indicate automatic retry and do not present stale capture times as current.
1042
+ - **Log snapshot manual refresh** — `@voltro/devtools-ui`
1043
+
1044
+ The shared log page exposes manual refresh when supported by its data provider, preserves active filters, and disables repeated clicks while loading. Read failures now hide the previous buffer count along with stale records.
1045
+ - **Recover safely from malformed dashboard log snapshots**
1046
+
1047
+ The shared log viewer validates snapshot counters, timestamps and record fields before rendering. Malformed responses now show a recoverable read error instead of crashing or displaying stale records and capture times. Refreshing preserves active filters and replica selection.
1048
+
1049
+ Severity labels and error summaries now use readable colors in both themes. Larger record labels and tighter filter/list spacing improve scanning without changing log ordering or filters.
1050
+ - **`--json` keeps stdout for the document on every command** — `@voltro/cli`
1051
+
1052
+ A command asked for machine output now sends every log line to stderr from the moment it starts, whichever command it is. `voltro db plan --json` did not: while a declared `utf8mb4` still had framework tables to convert, its "db apply also converts" notice went to stdout ahead of the plan, so `voltro db plan --json > plan.json` wrote a file that `voltro db apply --plan plan.json` refused (`Unexpected non-whitespace character after JSON`) — on exactly the first deploy after declaring the charset. Four commands had made this decision for themselves; it is now made once for every command that takes `--json` or `--format json`.
1053
+ - **voltro-mcp reads the inspect observation envelope and lists app tools again** — `@voltro/mcp`
1054
+
1055
+ `voltro-mcp` and `voltro-mcp-http` read `/_voltro/inspect/*` answers as the bare payload, while an app sends them inside the observation envelope `{ data, scope, origin, completeness, capturedAt }`. The agent surface then reported no app tools (`tools/list` without any `app_*` tool), a tool call's answer as a failure, and the manifest as empty. The manifest, the agent tool inventory, a tool call and the invariant checks now read the payload inside the envelope; a body without it is still read as the payload.
1056
+ - **Confirm Meilisearch writes before reporting search repair success** — `@voltro/plugin-search`
1057
+
1058
+ Use the current Meilisearch SDK constructor and await asynchronous indexing and deletion tasks before reporting successful writes. Failed tasks now surface as search errors instead of clearing drift or reporting a completed reindex prematurely. Task waits are bounded to 60 seconds; timeouts preserve an error outcome for later idempotent repair.
1059
+ - **Memory decimal extrema use exact numerical ordering** — `@voltro/runtime`
1060
+
1061
+ Compare MIN/MAX values numerically for declared decimal columns in the memory store, preserving exact decimal strings and text-column ordering, including grouped aggregates.
1062
+ - **The in-memory store answers full-text searches instead of returning every row** — `@voltro/runtime`
1063
+
1064
+ `.matching(...)` on the in-memory store (and in every `@voltro/testing` context) used to be ignored, so a full-text query returned every row of the table. It now keeps only rows where every query word begins a word in one of the index's columns, and `.rankBy()` orders them by a weighted hit count, so a test means what the query means on a SQL store.
1065
+ - **Validate invocation and registry metrics before rendering**
1066
+
1067
+ The shared metrics page validates invocation and registry snapshots independently. Malformed records no longer crash filtering or row derivation, failed reads hide retained measurements, and initial missing data stays loading. Provider-backed retries preserve filters and sorting while valid special registry readings remain supported.
1068
+ - **Metric tables support keyboard scrolling**
1069
+
1070
+ Invocation and histogram tables now expose named keyboard focus targets for horizontal scrolling. Error counts use readable colors in both light and dark themes.
1071
+ - **Boot and db apply finalize interrupted migration runs, keeping their ids** — `@voltro/database`, `@voltro/cli`
1072
+
1073
+ Development boot and database apply finalize interrupted operation-ledger runs even when the schema already matches, preserving the original migration ID and snapshot identities and reusing matching history after an interrupted cleanup.
1074
+ - **Dev and serve hosts restore exact-plan snapshots through Inspect** — `@voltro/cli`
1075
+
1076
+ SQL-backed development and serve hosts now execute authenticated exact-plan snapshot restoration through Inspect, reporting restored columns and tables while rejecting malformed requests before any schema or Timeline change.
1077
+ - **Retain completed operations in resumed migration history** — `@voltro/database`
1078
+
1079
+ When a partially applied migration resumes, the final history now retains previously completed operations as well as newly completed work under the original migration ID. Completed soft-drop renames are not repeated, and their exact snapshot identities remain available for restoration.
1080
+ - **Transactional migration batches commit operation records with their DDL** — `@voltro/database`
1081
+
1082
+ Transactional migration batches commit completed operation records with their DDL, preserving exact soft-drop snapshot ownership when a subsequent history write fails and allowing recovery under the original migration ID.
1083
+ - **Moderation retained review pagination** — `@voltro/plugin-moderation`, `@voltro/devtools-ui`
1084
+
1085
+ Expose every retained moderation review instead of hiding entries beyond the newest 100. The shared dashboard paginates the bounded 200-entry queue in pages of 50, allowing older pending items to be reviewed. Explain queue retention, process lifetime counters and restart behavior alongside the controls.
1086
+ - **voltro data restore works on SQL Server, over an existing database** — `@voltro/data-transfer`, `@voltro/cli`
1087
+
1088
+ `sqlpackage /Action:Import` loads only into a new or empty database, so `voltro data restore` on SQL Server failed against every database that already had tables (`SQL71659`). It now imports into a scratch database beside the live one, carries the target's own in-progress marker and transfer history into the copy, and swaps the names; the replaced database is dropped. The live database is untouched until the swap, so a failed import leaves it as it was. The login needs `CREATE DATABASE` and `ALTER ANY DATABASE`. An `mssql://` URL without a port now uses 1433 instead of 3306.
1089
+
1090
+ For code that calls `@voltro/data-transfer` directly: `runNativeStep` takes an optional `NativeSql` runner as its second argument, and `restoreCommand('mssql', …)` returns the new `mssql-replace` step, which needs that runner. Every existing call still compiles.
1091
+ - **Preserve numeric parameters on SQL Server** — `sql-mssql`
1092
+
1093
+ SQL Server now binds JavaScript numbers according to their value: signed 32-bit integers use INT, larger safe integers use BIGINT, and other finite numbers use FLOAT. This prevents fractional values being truncated and long elapsed times being rejected before reaching the database. Integer pagination parameters remain integer typed; non-finite numbers are rejected.
1094
+ - **`voltro serve` starts on SQL Server — its schema check read the ledger via LIMIT** — `@voltro/database`, `@voltro/cli`
1095
+
1096
+ `voltro serve`'s schema check read the newest row of `_voltro_migration_plans` with `LIMIT 1`, which SQL Server does not have, and caught every error as "no plan applied": each production boot on SQL Server refused with `SCHEMA FINGERPRINT MISMATCH … live=<no _voltro_migration_plans row>`, over a table holding the row — including right after `voltro db apply`. The same read made `voltro dev` re-introspect on every boot and record a new plan row each time. `voltro db plans`, `db drift` and `db restore-snapshot` used the same spelling. All of them spell it for the dialect now (`limitClause` from `@voltro/database/sql`), and a failing read of an existing ledger is reported as the query defect it is, not as a missing ledger. Measured: a run journaled under `voltro dev` on SQL Server is finished by a production `voltro serve`.
1097
+ - **A PostgreSQL native restore no longer fails on the tables the backup leaves out** — `@voltro/database`, `@voltro/cli`
1098
+
1099
+ `voltro data backup` leaves the restore marker and the transfer history out of its artefact. Both tables still carried triggers in the target — the marker a change trigger, both a Timeline capture trigger — whose functions the artefact does carry, so `voltro data restore` on PostgreSQL could neither drop nor recreate those functions and failed with `errors ignored on restore`. The marker table is no longer reactive and Timeline capture leaves both tables out; `voltro db apply` removes the old triggers.
1100
+ - **Notification delivery endpoint details** — `@voltro/devtools-ui`
1101
+
1102
+ Keep long notification delivery endpoints behind a keyboard-accessible disclosure. Delivery outcomes, errors and click status remain visible, while the full endpoint can be read and selected on demand.
1103
+ - **Notification inbox selection survives reconnect** — `@voltro/devtools-ui`
1104
+
1105
+ Keep the submitted notification inbox selection in the dashboard route. Local and Cloud app inspectors restore the selected recipient after reconnection, replica replacement, page reload, and browser history navigation instead of returning to an empty lookup.
1106
+ - **Offline seed schema codecs** — `@voltro/cli`
1107
+
1108
+ Load the declared table schema before standalone `voltro db seed` execution. By-name seed writes now use the same schema codecs as normal application boot, fixing SQLite array and JSON writes when a seed does not import its table definitions.
1109
+ - **An outbox handler's maxAttempts is applied** — `@voltro/runtime`, `@voltro/cli`
1110
+
1111
+ `defineOutboxHandler({ maxAttempts })` was validated and then ignored: the drain reads the attempt budget off the queued row, and `ctx.outbox.enqueue` stamped every row with the default of 8. A handler that asked for 50 attempts was dead-lettered after 8. `ctx.outbox.enqueue` now stamps the declared handler's `maxAttempts`; an enqueue's own `maxAttempts` still wins.
1112
+
1113
+ A facade you build yourself with `makeOutboxFacade` takes the same lookup as `maxAttemptsOf`, or pass `maxAttempts` per enqueue.
1114
+ - **Outbox inspection response validation** — `@voltro/devtools-ui`
1115
+
1116
+ Validate outbox queue and attempt-history snapshots before rendering. Malformed responses now show a localized read error with explicit retry instead of crashing the page or appearing as an empty queue.
1117
+ - **Outbox resend replica admission** — `@voltro/devtools-ui`
1118
+
1119
+ Outbox resend requests in the local dashboard now retain the selected replica as their admission target. Combined views keep shared history visible and require selecting one replica before resending. Target failures never trigger a fallback write; delivery still runs through the shared queue.
1120
+ - **Changing a composite unique's columns no longer breaks a postgres migration** — `@voltro/database`
1121
+
1122
+ A composite unique created on a table the planner built exists on postgres as a unique index, not a table constraint. When its columns changed under the same name, the plan dropped it with `DROP CONSTRAINT IF EXISTS`, which left the index in place, and the re-create failed with `relation "…" already exists`. The drop now also removes a unique index of that name, so the constraint is replaced in the same migration.
1123
+ - **A full-text index exists on tables the migration planner creates** — `@voltro/database`, `@voltro/cli`
1124
+
1125
+ `voltro dev`'s auto-migrate and `voltro db apply` never created the objects behind `.fullTextIndex(...)` — the postgres tsvector column and its GIN index, the mysql FULLTEXT index, the sqlite FTS5 table — because the planner skips them and only the full-schema emit wrote them. Every `.matching(...)` on such a table then failed with a missing column. They now converge after every planned migration and every `db apply`, from the same builder the full-schema emit uses (`fullTextIndexSql`, `ensureFullTextIndexes`).
1126
+ - **`@voltro/plugin-auth/native` ships the client's types** — `@voltro/plugin-auth`
1127
+
1128
+ The server's native-login routes module (`src/native.ts`) and the client half (`src/native/index.ts`) both emitted `dist/native.d.ts`, so the published `./native` subpath carried the client's JavaScript with the server's types. The server module is `nativeRoutes.ts` now; the subpath's types and golden are the client's (`runNativeLogin`, `parseNativeCallback`, …).
1129
+ - **Plugin-registered errors reach mutations and actions typed on both boot paths** — `@voltro/protocol`, `@voltro/cli`
1130
+
1131
+ An error registered through a plugin's `errorSchemas` — an interceptor's `RateLimited`, or an app-wide error a local plugin declares — was on every lifted wire union and decoded typed on a query, but a mutation or action failing with it reached the caller as `InternalError` wearing its tag: the server judged the failure against the union without the plugins' errors, under `voltro dev` and `voltro serve` alike. It now judges against the same union it advertises (`wireErrorUnion(descriptor, kind, extraErrors)`). Under `voltro dev` an action was also judged against its raw `error:`, so a handler's own `requireScope` or `ValidationError` collapsed there and arrived typed under `voltro serve`; both paths now agree.
1132
+ - **A plugin's `extendSchema.migrations` run on MySQL and MariaDB** — `@voltro/cli`, `@voltro/database`
1133
+
1134
+ The ledger that records a plugin's migration steps was created as `id TEXT PRIMARY KEY`, which MySQL and MariaDB both refuse (`BLOB/TEXT column 'id' used in key specification without a key length`), so a plugin shipping a migration could not boot `voltro dev` or finish `voltro db apply` on either. The ledger is now created through the same per-dialect bootstrap as the migration ledgers, in the declared store charset; existing ledgers on PostgreSQL and SQLite keep their rows.
1135
+ - **Plugin panel state during read failures** — `@voltro/devtools-ui`
1136
+
1137
+ Preserve mounted plugin-panel state during temporary plugin-inventory read failures. Hide and disable stale panels until discovery recovers, retaining searches, pagination and form input. Remove the panel normally when a successful inventory confirms the plugin is absent.
1138
+ - **Postgres historical date bindings** — `@voltro/sql-postgres`
1139
+
1140
+ Preserve historical timezone offsets with second precision when binding Date parameters to PostgreSQL. This fixes timestamp drift and rejected historical schedule backfills across ordinary and custom connection pools. The correction also applies to date filters, transactions, SQL arrays and streaming queries, while retaining local wall-clock semantics for timestamp-without-time-zone columns. Previously stored values are not rewritten.
1141
+ - **Capture PostgreSQL changes independently of notification delivery** — `@voltro/cli`
1142
+
1143
+ PostgreSQL Timeline captures masked row images in a transactional source journal and reads them together with current rows in one repeatable-read snapshot. Historical replay includes external writes during LISTEN interruptions and tracks transaction visibility rather than treating journal numbers as commit order. Database-time leases, schema validation and retention gaps invalidate unavailable history before recovery. Inactive capture stops storing row images and records compact transaction gaps.
1144
+
1145
+ Development schema setup, successful schema apply/deploy and create-only migration prepare capture in the connected database namespace. Source and compiled production serving perform no capture DDL. Schema maintenance retires the old contract before changes. Sensitive, encrypted and server-only fields are masked before persistence, including in development. Recording remains off by default in production; Timeline remains a bounded debugger rather than an audit log.
1146
+ - **Production data explorer readonly** — `@voltro/cli`
1147
+
1148
+ Fix: Advertise production Inspect tables as read-only so the shared data explorer does not offer row writes that `voltro serve` deliberately does not support. Development-mode editing and production data browsing remain available.
1149
+ - **Production inspect data live updates** — `@voltro/cli`
1150
+
1151
+ Connect production Inspect streams to store change events so the dashboard data explorer refreshes on observed inserts, updates and deletions instead of waiting for snapshot polling. Dispose the change listener during shutdown. Production data access remains read-only. CDC row payloads preserve production schema masking.
1152
+ - **SQL spans remain in production request traces**
1153
+
1154
+ Stores opened before production observability plugins are ready now attach to the same tracer as the RPC server. SQL and transaction spans emitted after startup retain their request context, including stores opened for residency connections. Each boot owns its tracer binding and releases it on shutdown; pools are not reopened. With tracing disabled, the binding keeps the no-export tracer.
1155
+ - **Bytes survive a live delta** — `@voltro/protocol`
1156
+
1157
+ A subscription's first snapshot delivered a bytes column (a `crdtText()` state, a `Schema.Uint8ArrayFromBase64` field) as a `Uint8Array`, but every later delta sent the changed row through plain JSON, so the same column arrived as `{ type: 'Buffer', data: [...] }`. A CRDT text bound with `useCrdtText` then failed to decode as soon as any other column of its row changed, and an incremental `mergeCells` cell arrived the same way. Delta rows and merged cells now carry bytes as `{ $bytes: <base64> }` on the wire and hand the client a `Uint8Array`, the same value the snapshot gave.
1158
+ - **The service worker answers the version question on the message port** — `@voltro/cli`
1159
+
1160
+ `useAppUpdate().version` read `unknown` for every waiting build: the client asked the worker for its build id over a `MessageChannel` port, and the generated worker answered the client instead of the port. The worker now replies on the port it was handed, and the framework's real-browser check pins the reported version to the build id.
1161
+ - **Load queue consumers from compiled app modules**
1162
+
1163
+ Compiled API starts register queue consumers through the app-module loader before activating the Queue plugin. Consumer declarations no longer disappear because startup attempts to import their TypeScript sources outside the compiled module registry.
1164
+ - **Distinguish queue loading and malformed observations from empty state**
1165
+
1166
+ The shared Queue panel validates observations before deriving rows, preserves transport failures when cached data is malformed, and offers explicit refresh after a failed read. Missing initial observations no longer appear as empty queues.
1167
+ - **Keep queue tables readable and keyboard accessible**
1168
+
1169
+ Queue activity and production tables expose named keyboard-focusable scroll regions, readable column widths and wrapping for long topic names and errors. Produced-message counts now have explicit column headers.
1170
+ - **plugin-rbac and plugin-auth can be installed together** — `@voltro/plugin-rbac`
1171
+
1172
+ `rbacPlugin({ tables: true })` declared an index named `byUserTenant`, and so does plugin-auth's `memberships` table. Index names share one namespace across the schema, so an app installing both aborted its boot ("duplicate index name 'byUserTenant'"). The rbac indexes are now named after their tables (`_voltro_rbac_user_roles_by_user_tenant`, `_voltro_rbac_user_roles_by_role_slug`, `_voltro_rbac_roles_by_slug`). Existing databases are migrated by auto-migrate as a name-only index rename; no data moves.
1173
+ - **`--target <url>` names the remote api for release and desktop publish** — `@voltro/cli`
1174
+
1175
+ The generated release workflows call `voltro release … --target <url>` and `voltro desktop publish … --target <url>`; the CLI read only `--url` for a remote api and treated `--target` as the process filter, so a workflow run published to no api at all. An `--target` whose value is an http(s) url is now the remote target, the same as `--url`; a bare name stays the process filter. Where a storage answers with a served path instead of an absolute url, the artefact url is completed against the api's origin.
1176
+ - **Release actions from the dashboard carry a real trace id** — `@voltro/plugin-releases`
1177
+
1178
+ Release actions invoked through the inspect surface (`voltro release` and the dashboard) all ran under the constant trace id `inspect`, so their audit rows and logs merged into one trace. Each call now gets its own trace id, minted the same way as every other entry point.
1179
+ - **The releases plugin seeds its targets once the store is bound, so an api boots** — `@voltro/plugin-releases`
1180
+
1181
+ An api with `releasesPlugin` refused to boot: the plugin seeded its app targets and channels in `onActivate`, which the framework runs before it binds the data store, so activation failed with an unnamed error. Seeding now starts in `bindDataStore` and every read waits for it; `onActivate` names a failed seed. The framework's real-browser check consumes the feed end to end: the policy arrives as a subscription, a promotion reaches the open page as a delta, the update impulse fires once per new version, and a minimum supported version blocks the running client live.
1182
+ - **A defineRestRoute handler may return sse() or bytes() without a cast** — `@voltro/protocol`
1183
+
1184
+ The documented way to stream or serve a non-JSON body from a `defineRestRoute` handler is to return `sse(...)` or `bytes(...)`. The runtime always handled both, but the handler's return type only allowed the output or a `response(...)`, so returning either needed a cast. The handler now returns `RestHandlerResult<O>`, which is the output, `response(...)`, `sse(...)` or `bytes(...)`. The new type is exported from `@voltro/protocol/rest`.
1185
+ - **Rollback rejects unapplied target** — `@voltro/cli`
1186
+
1187
+ Reject unknown or unapplied targets in migration rollback before running any undo step. Previously, a target that was never found could cause all applied migrations to be selected for rollback. The CLI also rejects an explicit target when there are no migration files.
1188
+ - **Route details page links** — `@voltro/devtools-ui`
1189
+
1190
+ Route details can open the inspected app in a new tab. Dynamic routes request required path parameters and support required/optional catch-all paths. Links omit inspect query parameters, encode parameter values and reset drafts when the route or app changes. Local overview/routes and Cloud dashboard/admin routes use the shared component.
1191
+ - **A plugin route's session cookie survives beside a second cookie** — `@voltro/runtime`
1192
+
1193
+ A plugin HTTP route that answered with a `set-cookie` array lost every cookie whose name falls outside the RFC token set — `voltro:session`, the framework's own session cookie, among them — without an error. A login callback that set the session and cleared its state cookie in one response signed nobody in; the social-login callback of `@voltro/plugin-auth-social` did exactly that. Every line of a `set-cookie` array is now written as the route wrote it.
1194
+ - **Route tree search** — `@voltro/devtools-ui`
1195
+
1196
+ Route trees can now be searched by URL pattern or source-file path. Results preserve and expand their parent hierarchy. Clearing the search restores previous branch expansion and keyboard focus; empty searches no longer show unrelated route details.
1197
+ - **RPC inspector isolates loading, search and credential changes** — `@voltro/devtools-ui`
1198
+
1199
+ The RPC catalog no longer reports empty procedure counts before its first snapshot. Workflow searches distinguish no matching results from no discovered workflows, and unsuccessful searches explain how to change or clear the filter instead of suggesting new procedure files.
1200
+
1201
+ Changing saved browser credentials resets a prepared local RPC invocation, including its payload, tenant choice and confirmation. Unchanged observations retain the draft. The execution-context key contains no credential values; requests already sent are neither replayed nor undone.
1202
+
1203
+ Workflow entries also expose the app-reported declaration metadata and full source path in keyboard-accessible disclosures. Missing fields remain explicitly unavailable; the catalog does not execute workflows.
1204
+
1205
+ Procedure details include declared data sources, write targets, public-API and agent-tool metadata without presenting declarations as authorization. Explicitly internal procedures no longer offer an execution form.
1206
+ - **Rpc invocation target isolation** — `@voltro/devtools-ui`
1207
+
1208
+ Fix: Isolate RPC execution form state by app, endpoint and replica target. Switching targets clears drafts and confirmation; late replies from previous targets cannot appear as the new target’s result. Existing server requests are not cancelled or retried. Custom hosts can provide the optional `RpcPage.invocationScope` identity.
1209
+ - **create-project prompts take their defaults without a terminal, and say so** — `@voltro/cli`
1210
+
1211
+ With no terminal on stdin (CI, a pipe), the project-name and port-range prompts ended the scaffold on EOF with exit 0 and no app written. They take the default now and print it; `--no-input` skips them outright.
1212
+ - **Schedule action target guidance** — `@voltro/devtools-ui`
1213
+
1214
+ Allow schedule hosts to show the selected action replica and require a single target before manual starts or backfill admission, while keeping shared run history readable.
1215
+ - **Compare external schedule observations without false overdue warnings**
1216
+
1217
+ External schedules require a known run observation before showing a missed-firing warning, compare timezone-aware timestamps as instants, and avoid treating invalid timestamps as evidence of a missed firing.
1218
+ - **A schedule Effect may require plugin services — its runner provides them** — `@voltro/runtime`
1219
+
1220
+ `ScheduleHandler`'s Effect form was typed to require only `HttpClient`, while `voltro dev` and `voltro serve` both run schedules under the full handler service layer (plugin services such as `MailService`, the app's layers). The type now says so, so a schedule that sends mail type-checks without a cast; the doc comment that claimed other requirements had to be provided by the handler was stale.
1221
+ - **Browse older schedule firings in the dashboard**
1222
+
1223
+ The shared schedule history supports previous and next pages, resets its page on replica changes, and orders equal scheduled timestamps consistently. Both dashboard transports request the selected history window instead of silently limiting access to the first page.
1224
+
1225
+ The memory store now recognizes equal Date values before applying secondary order columns, preserving stable pagination for timestamp ties.
1226
+ - **Keep relative schedule firing times visible and current**
1227
+
1228
+ Schedule history shows localized relative firing times alongside the exact local timestamp, updating automatically while the history is open without depending on network refreshes.
1229
+ - **Preserve recent schedule activity during history retention** — `@voltro/database`, `@voltro/cli`
1230
+
1231
+ Schedule history retention now considers actual start, completion and heartbeat timestamps alongside the planned occurrence time. Fresh historical backfills and live handlers no longer lose their run records during cleanup; old abandoned runs still age out and queued work stays protected. Custom retention policies can declare `activityColumns` for the same protection. Conditional deletion rechecks row eligibility and never falls back to unconditional deletion after a failure.
1232
+ - **Keep manual schedule feedback scoped to its selected replica** — `@voltro/devtools-ui`
1233
+
1234
+ Run now always displays an error when a request cannot be confirmed, including transports that reject without details. The message directs operators to check firing history before retrying. Switching replicas resets transient request feedback and prevents late responses from appearing as acceptance for the new target, while preserving durable backfill receipts.
1235
+ - **Persist SQLite schema ownership with driver-compatible timestamps**
1236
+
1237
+ Schema ownership now uses the existing dialect-aware migration timestamp encoder for initial claims and explicit ownership transfers. SQLite can persist its first migrating application's claim and `voltro db claim --yes` can transfer ownership instead of failing to bind a Date object. Existing claims are enforced against different applications on both migrating and read-only boots.
1238
+ - **Prepare schema resources after locked plan validation** — `@voltro/database`, `@voltro/cli`
1239
+
1240
+ Add optional `beforeExecute` preparation to the schema applier and development migration runner. Run it after locked replanning, refusal checks and interrupted-run reconciliation, before executing the remaining operations. Skip it for plans that are blocked or have already converged, and release coordination when preparation fails.
1241
+
1242
+ Use the boundary to retire installed internal SQLite Timeline capture before approved schema changes. Automatic source capture and final installation remain disabled pending complete lifecycle integration.
1243
+ - **Seed inspection replica targets** — `@voltro/cli`, `@voltro/devtools-ui`
1244
+
1245
+ Classify database snapshots and namespace inventories as process observations. Compare them separately for combined replica selections, and target seed actions at exactly one selected replica through the existing inspect target route.
1246
+ - **An app that calls another boots from a pruned serve bundle** — `@voltro/cli`
1247
+
1248
+ `voltro build` left `apis.generated.ts` out of the serve bundle: the entry pattern named it, but the build collects its entries from the file conventions, and a generated file is not one. `voltro serve` then loaded it from source and imported the called app's package — which `voltro prune-runtime` had removed from the image as unreachable — and refused to boot with `apis: could not load apis.generated.ts (Cannot find package …)`. The file is now an entry of the bundle, with the called app's contract inlined, so a caller boots from a relocated and pruned image.
1249
+ - **The serve bundle is `require()`-able — no top-level await in the inlined graph** — `@voltro/cli`
1250
+
1251
+ `voltro build`'s serve bundle carried one top-level await (the CLI's package-version read in the web dev module, which rides in the bundle), so `require(esm)` refused the whole graph. That is the one way a Node single-executable (SEA) launcher can load the bundle: its CommonJS main may dynamic-import only builtins. The read is synchronous now and `serveBundle.test.ts` pins it, so a desktop Local Stack can ship the API as `node` + the bundle beside it.
1252
+ - **Serve inspect rpc execution** — `@voltro/cli`
1253
+
1254
+ Enable inspect RPC execution in serve with the same guarded mutation/action runners as dev. Preserve subject resolution, plugin interception, write attribution, typed failures, internal-procedure exclusion, and selected-replica routing.
1255
+ - **voltro serve <app> serves the named app from any directory** — `@voltro/cli`
1256
+
1257
+ Without a serve bundle, `voltro serve apps/api` run from another directory booted whatever app the current directory held — its config, its schema and its name — while `voltro db apply apps/api` worked on the named one. `serve` now enters the named app first, as the bundle path already did.
1258
+ - **Server route parameter matching** — `@voltro/cli`
1259
+
1260
+ Fix web server route matching in development and production: decode valid percent-encoded parameters once and match optional catch-all routes at their base URL without requiring a trailing slash. Both boot paths now share the route pattern compiler.
1261
+ - **Shared database seed inspection** — `@voltro/cli`
1262
+
1263
+ Share database and seed inspection between dev and serve. Production processes expose discovered seeds and the authenticated explicit seed action without automatically running boot seeds. Failed seed execution now rejects the action; successful retries clear the previous error in the inspection status.
1264
+ - **Shared inspect framework action routing** — `@voltro/cli`
1265
+
1266
+ Route seed execution and migration actions through the shared authenticated inspect router. Remove the separate development-only dispatch, preserve write-token checks, and report missing implementations explicitly instead of letting supported callbacks fall through to an unknown endpoint.
1267
+ - **Short suspending wait timers** — `@voltro/workflow`
1268
+
1269
+ Use durable timers for short suspending signal waits as well as long waits. Timeouts of 60 seconds or less no longer keep a runner active through the clock's in-memory sleep fallback. Budget holds with short recheck/deadline intervals now suspend, re-park after an early release, and resume only after headroom is available.
1270
+ - **Sqlite bigint related row precision** — `@voltro/database`
1271
+
1272
+ Preserve exact bigint strings in SQLite eager JSON results, including root rows, related rows and many-to-many junction fields. Explicit join projections also retain exact digits under output aliases without changing numeric ordering or left-join nulls.
1273
+ - **Sqlite bigint row precision** — `@voltro/database`, `@voltro/sql-sqlite`
1274
+
1275
+ Preserve declared SQLite bigint columns as exact strings in ordinary row queries and write results. Project bigint values as text before driver conversion, while retaining numeric ordering against the underlying column. Share the row projection between SELECT, RETURNING and conflict rereads.
1276
+ - **Serialize SQLite migration jobs across local processes**
1277
+
1278
+ File-backed SQLite schema-writing CLI jobs, development auto-migration and create-only migration now share a canonical-path SQLite coordinator. Competing processes wait without changing application DDL transaction semantics, and ownership is released on failure, interruption or process termination. Read commands and migrate dry-runs remain uncoordinated. The coordinator uses a persistent `.voltro-migration-lock` sidecar containing no application data; do not remove it while jobs may hold or await the lock. This does not enable clustered SQLite workflows or external reactive change delivery. Newly initialized workspaces ignore the coordinator sidecars in Git.
1279
+ - **Sqlite namespace index qualification** — `@voltro/database`
1280
+
1281
+ Fix index creation while provisioning SQLite-family tenant namespaces. Qualify the index name with the attached database and leave its target table unqualified, as required by SQLite. Identically named indexes in separate attachments are created independently.
1282
+ - **Sqlite numeric affinity introspection** — `@voltro/database`
1283
+
1284
+ Distinguish SQLite NUMERIC/DECIMAL columns from TEXT during schema inspection. Existing matching decimal declarations remain converged, while actual TEXT/NUMERIC changes are no longer silently treated as storage-equivalent and retain the normal lossy-migration gate. This does not change decimal storage precision.
1285
+ - **Sqlite numeric primary key introspection** — `@voltro/database`
1286
+
1287
+ Recognize SQLite INTEGER PRIMARY KEY rowid aliases as non-null during schema introspection. This prevents repeated nullability migrations and boot failures for numeric-ID tables, including billing flush claims. Nullable text and descending primary keys retain their actual nullability. Verified against SQLite, local Turso and libsql.
1288
+ - **Capture external SQLite changes in dashboard time travel** — `@voltro/cli`
1289
+
1290
+ Native SQLite Timeline now reads a bounded database journal rather than relying on process-local change callbacks. Inspect replay combines source events and all current table pages from one transaction, including writes made through other connections. Background polling and database-time leases keep capture active; gaps, incompatible schemas and expired leases invalidate history before recovery. Shutdown releases the session's lease.
1291
+
1292
+ Development schema setup and successful schema apply/deploy or create-only migration prepare capture after the shared runtime storage schemas. Serving performs no capture DDL. Schema-changing manual/file/step operations retire capture; the normal apply/deploy path prepares it again. An unprepared source reports unavailable history instead of silently returning incomplete local history.
1293
+
1294
+ Sensitive, encrypted, server-only and sensitive-name values are masked before journal persistence, including in development. Database engines without source capture retain their existing Timeline capture path and consistency limits. Recording remains off by default in production.
1295
+ - **Sqlite workflow journal ownership** — `@voltro/workflow`
1296
+
1297
+ Refuse a second local SQLite workflow engine for the same file-backed journal before it can execute another engine's active work. Keep ownership separate from application writes and release it on normal shutdown, boot failure or process death. Normal dev and compiled serve share the guard; in-memory stores and remote Turso do not gain distributed runner coordination.
1298
+ - **Persist step migration receipts through the configured SQL store** — `@voltro/cli`
1299
+
1300
+ Use the existing dialect store upsert for step-migration receipts instead of PostgreSQL-specific SQL and raw timestamp bindings. Quote ledger identifiers through `SqlClient`, return status timestamps as dates, and surface ledger read failures rather than treating every migration as pending.
1301
+
1302
+ Retire installed internal SQLite Timeline capture before executing step apply/undo bodies and snapshot restore/cleanup DDL. Already-applied steps, refused rollback selections, snapshot dry-runs and calls without eligible snapshots preserve active capture. Automatic Timeline source recording remains disabled pending complete lifecycle integration.
1303
+ - **StorageService.ingestUrl refuses internal addresses, the URL and every redirect** — `@voltro/plugin-storage`
1304
+
1305
+ `ingestUrl` fetched any URL it was handed, and followed redirects on its own — a public URL that redirected to `169.254.169.254` or a service on the private network was fetched and stored. Every address is now checked with the framework's `ssrfReason` before it is fetched, redirects are followed by hand (at most `maxRedirects`, default 5) and each hop is checked the same way; a blocked address fails `StorageRejected` with reason `blocked-source` before anything is fetched or stored. A deliberate internal source, or a local test server, goes in `storagePlugin({ ingest: { allowHosts } })` (`IngestPolicy` is exported). An app that ingested from an internal host must now name it there.
1306
+ - **A stream's wire contract declares the access refusals its guards raise** — `@voltro/protocol`
1307
+
1308
+ `streamToRpc` put only the stream's own `error:` (plus plugin errors) on its wire contract, so the `ScopeError` a guarded stream refuses with before its executor runs, and a rejected credential's `Unauthenticated`, were undeclared on the stream — while the docs listed both as merged. A stream's union is now built by the same `wireErrorUnion` as every other procedure kind, including `NoSuchResource` for a guard that names a resource.
1309
+ - **Subscription delivery traces in production**
1310
+
1311
+ Production inspect trace rings now capture subscription snapshot and delta delivery spans, including their measured latency and setup parent. Development and production use the same delivery sink. Disabling the development trace buffer also disables delivery span capture while preserving delivery metrics.
1312
+ - **Temporal filter sql bindings** — `@voltro/database`
1313
+
1314
+ Normalize temporal filter values before dialect-specific parameter encoding. SQLite date and timestamp equality no longer binds unsupported JavaScript Date objects; ranges and membership filters normalize ISO offsets consistently too. This fixes data-explorer filters and other schema-aware SQL queries while preserving native PostgreSQL date bindings.
1315
+ - **makeTestContext binds its plugins to the test store, as boot does** — `@voltro/testing`
1316
+
1317
+ Plugins passed to `makeTestContext({ plugins })` were never given a store: boot calls each plugin's `bindDataStore` with the app's store, the harness did not. A plugin that persists — `auditPlugin({ sink: 'datastore' })`, a store-backed resolver — was therefore silently inert under test, and a test asserting its rows found an empty table instead of an error. The harness now binds every plugin to the context's raw store, the one `ctx.store` reads.
1318
+ - **ctx.webhooks records a defineEvent emit under its name** — `@voltro/testing`
1319
+
1320
+ `MockWebhooks.emit(descriptor, payload)` read the event's wire name from `id` or `event` only, so an emit with a `defineEvent` descriptor — which carries it as `name` — was recorded as `(unnamed event)` and `payloadsFor('<name>')` came back empty. It now reads `name` first.
1321
+ - **ctx.webhooks answers listTargets in unit tests** — `@voltro/testing`
1322
+
1323
+ `MockWebhooks` gains `listTargets(event?, scope?)`, which answers `[]` — no targets are subscribed in the harness. A handler that reads the targets through `useWebhooks(ctx).listTargets()` (a status page, a health check) threw in every unit test because the recorder lacked the method.
1324
+ - **Invalidate timeline history after proven change-stream gaps**
1325
+
1326
+ Both development and serve recovery paths clear timeline history and rotate its recorder identity when a store or broadcast transport reports lost change events. Frozen dashboard positions become unavailable instead of reconstructing from incomplete history. Replay also rejects identity changes during its row read, including when sequence numbers overlap after recovery.
1327
+ - **Timeline idle write activation gap** — `@voltro/cli`
1328
+
1329
+ Timeline capture rotates its source identity transactionally for writes made without an active observer, without persisting row images. A late commit can no longer silently enter an observer's history after activation or lease expiry. Refresh existing capture triggers through the normal schema apply/deploy path.
1330
+ - **Ignore migration tombstones in Timeline capture** — `@voltro/cli`
1331
+
1332
+ SQLite and PostgreSQL Timeline capture exclude `dropped()` migration markers from schema checks, trigger images, current-row reads and decoding. Apps retaining those markers after a migration can boot normally, and retired column values are not included in history even if the former physical column still exists.
1333
+ - **Timeline native turso boot** — `@voltro/cli`, `@voltro/runtime`
1334
+
1335
+ Prepare native local Turso Timeline capture during development and database migrations, and use the same bounded source capture in source and compiled production servers. Record each change through one source, mask private fields before persistence, and reject incomplete history instead of replaying across gaps. Remote libSQL and embedded-sync clients retain their existing change-stream behavior.
1336
+
1337
+ Mark serialized undo row images as server-only so protected application fields cannot leak through the undo ledger into inspect responses or Timeline capture. Undo and redo retain access to their original stored payloads.
1338
+ - **Distinguish SQL Server net changes from replayable row history**
1339
+
1340
+ SQL Server Change Tracking marks its events as net changes. The timeline preserves this metadata and rejects historical reconstruction across coalesced events and key-only delete images instead of fabricating historical rows. Normal reactive query invalidation is unchanged.
1341
+ - **Preserve incomplete CDC image metadata in timeline recording**
1342
+
1343
+ Development and serve timeline recording preserve oversized CDC image metadata instead of stripping it from store events. Reconstruction rejects marked tombstones, rehydrated images and unrecovered events rather than presenting them as original historical row snapshots. PostgreSQL key-only delete notifications no longer become fabricated historical rows.
1344
+ - **Two replicas booting on one PostgreSQL schema no longer deadlock** — `@voltro/cli`
1345
+
1346
+ The PostgreSQL Timeline install locked every captured table while a second replica, booting beside it, repaired change triggers on the same tables in another order. One of the two `voltro dev` processes died with a deadlock (`40P01`). The install now runs under the migration lock, which the trigger repair already respects, so the second replica defers its repair instead. `voltro db apply` and `voltro migrate` take the same lock for it.
1347
+ - **Bind timeline replay positions to their recorder history**
1348
+
1349
+ Timeline snapshots and replay responses carry a recorder identity. Replay requests require the matching identity, rejecting stale positions before reading rows even when a restarted recorder reuses the same sequence number. The shared dashboard hides old responses and offers an explicit return to the current history. Local and Cloud adapters pass the identity through.
1350
+
1351
+ This changes the internal inspect protocol; the shipped dashboard adapters are updated together, with no user-authored application source transformation required. History remains process-local and in memory. Delayed external CDC is not an atomic database snapshot guarantee.
1352
+ - **Invalidate incomplete timeline history after recording failures**
1353
+
1354
+ The process change tap now clears timeline history and rotates its recorder identity when recording an event fails. It continues delivering changes without treating the missing event as a complete history. Frozen selections and in-flight replay from the old history are rejected, even when later events reuse the same sequence numbers.
1355
+ - **Reject timeline replay when required row images are missing**
1356
+
1357
+ Timeline reconstruction no longer silently skips events with missing row images or identities. The inspect endpoint returns a conflict instead of presenting an unreconstructed row set as historical data. Numeric row IDs are now reversed correctly and remain distinct from string IDs.
1358
+
1359
+ The shipped dashboard already displays replay conflicts. This does not establish atomic snapshot/CDC consistency or prove that a supplied row image is complete.
1360
+ - **Timeline retained image retirement** — `@voltro/cli`
1361
+
1362
+ Capture schema retirement and incompatible reinstallation clear retained fixed-slot images together with journal entries and observer leases. Failed enclosing migration transactions restore the previous capture state. This does not enable source capture for additional database drivers.
1363
+ - **Apply schema privacy to timeline capture and historical responses**
1364
+
1365
+ Production Timeline capture, event lists and replay now share the data browser's schema masking for sensitive, encrypted and server-only columns. Historical row images cannot restore withheld values after current rows have been masked. Undeclared row fields and unknown-table images are withheld in production; protected tenant metadata is redacted too. Development retains its existing local schema visibility and sensitive-name redaction. Failed capture masking invalidates history without interrupting change delivery.
1366
+ - **Timeline shared boot route** — `@voltro/cli`
1367
+
1368
+ Serve timeline recording and read-only replay through the shared dev/production inspect route. Feed enabled production recorders from real store changes and read complete tables for replay instead of truncating at 500 rows. Preserve inspect authentication and disabled-inspection checks.
1369
+ - **Read timeline replay pages in one store transaction**
1370
+
1371
+ Development and production Timeline replay now use one shared transactional reader for all table pages. This prevents SQLite page boundaries from shifting when another connection commits between reads, while preserving production column masking. The transaction retains the dialect's existing isolation level; complete external change capture and its historical position are still separate requirements.
1372
+ - **Timeline trigger catalog formatting** — `@voltro/cli`
1373
+
1374
+ Timeline capture accepts whitespace-only trigger catalog formatting while continuing to reject changed SQL tokens, quoted values and unsupported syntax. This does not enable source capture for additional database drivers.
1375
+ - **Timeline visible source position** — `@voltro/cli`
1376
+
1377
+ Timeline capture derives source positions from visible journal entries and the retention floor instead of reserved auto-increment values, preventing an unfinished write from advancing observers past an event that commits later.
1378
+ - **Trace views validate snapshots before rendering**
1379
+
1380
+ Malformed trace snapshots now show a recoverable read error instead of crashing the waterfall or displaying invalid measurements. Provider-backed refresh preserves the error filter and selected trace context.
1381
+ - **Trace waterfall details and keyboard navigation**
1382
+
1383
+ The shared trace waterfall now includes an elapsed-time axis and expandable span details with complete names, parent IDs and attributes. Trace disclosures announce their state; the waterfall scrolls with the keyboard on narrow screens. Long error messages no longer widen the page, and error text remains readable in the light theme. Span hierarchy distinguishes replica-local IDs and avoids misleading nesting for cyclic or ambiguous parent references.
1384
+ - **Durable traces skip bare storage roots** — `@voltro/cli`
1385
+
1386
+ In `interesting` mode, a query or transaction with no operation around it (a root `sql.execute` or `store.*` span — a poller's, a background drain's) is no longer persisted unless it is slow or fails. A busy dev demo wrote 5.3 million spans a day, 5.28 million of them such roots (3.3 GB of `_voltro_traces`), each one another write on the database it described.
1387
+ - **The generated TypeScript client type-checks in Node ESM projects (NodeNext)** — `@voltro/cli`
1388
+
1389
+ `voltro build api --target typescript` wrote its relative imports without a file extension (`from './types'`). A consumer compiling with `moduleResolution: "NodeNext"` — the setting for TypeScript on Node ESM — could not resolve them inside the package's `.d.ts`: every export of the client lost its type, and with `skipLibCheck` the loss was silent. The generated files now import `./types.js`, `./errors.js` and `./manifest.js`, which resolve under `NodeNext` and `Bundler` alike. A declared error whose field is a list of structs (a validation error's `violations`) named that struct's type in `errors.ts` without importing it from `types.ts`; a consumer checking library types failed with `Cannot find name`. `errors.ts` now imports the models its errors use. Regenerate the client (`voltro api-client build`, or let `voltro dev` rewrite it when `publicApi.artifacts` includes `typescript`).
1390
+ - **Workflows on Turso keep running when two engine transactions collide** — `@voltro/sql-turso`
1391
+
1392
+ Transactions on Turso that do not use MVCC concurrency — the workflow engine's, migrations and schema changes — ran side by side on the connection pool as plain `BEGIN`. When two of them met (one reading and then writing while the other committed a write), the first write waited forever. The workflow engine's message polling stopped for good, so a parent workflow never received its child's result, and the process hung on shutdown. These transactions now run one at a time across the pool and open with `BEGIN IMMEDIATE`, so a lock wait ends at `busy_timeout` like any other. The app store's own transactions keep `BEGIN CONCURRENT` and run in parallel as before.
1393
+ - **The undeclared-table note lists only tables the app does not declare** — `@voltro/cli`
1394
+
1395
+ The `voltro update` note for the store's new refusal of undeclared tables listed every store-shaped call in the workspace — a thousand lines and more in a real project, including `URLSearchParams.delete('form')` and vendored scripts — and left the comparison to the reader. It now compares each string table name with the declarations of the app the line sits in (its `table()`s and the `voltro-tables.generated.d.ts` that `voltro dev` / `voltro codegen` writes, which also carries plugin tables) and lists only the lines that name a table that app does not declare. A web app's lines and files outside any app that do not use the framework are not listed. When nothing is found, the note says what its search cannot see — a table name built at runtime — instead of printing a generic paragraph.
1396
+ - **WATCH=0 turns off file watching for web apps too** — `@voltro/cli`
1397
+
1398
+ `WATCH=0` was documented as "disable filesystem watch" for `voltro dev`, but only the api's restart supervisor honoured it; a web app's Vite server kept watching every workspace package it imports from source. A long-running web dev server beside a framework under active development then absorbed hundreds of hot updates until its SSR module graph held several copies of one package and rendering failed. With `WATCH=0` the Vite watcher is off as well (`server.watch: null`).
1399
+ - **Web dashboards report measured request windows**
1400
+
1401
+ Web development now records page request counts, errors and latency alongside request events. Development and production expose the bounded invocation window to dashboard metrics, grouped by route pattern. Full cumulative series remain available in the metric registry.
1402
+ - **Improve webhook keyboard navigation and action feedback**
1403
+
1404
+ Make Webhook tables keyboard-scrollable, preserve full destination URLs, and improve action spacing and status contrast in both themes. Show explicit feedback for empty action errors and clipboard failures without hiding the revealed secret.
1405
+ - **Validate webhook observations before rendering management controls**
1406
+
1407
+ Malformed webhook observations now show a read error instead of crashing or enabling actions from invalid data. Failed reads hide retained tables and offer a provider-backed refresh without repeating management actions.
1408
+ - **Workflow admission reports unavailable reads** — `@voltro/devtools-ui`
1409
+
1410
+ Show a load error instead of an admitting or paused status when reading workflow admission fails. Cached snapshots no longer imply current admission health or expose pause/resume controls during a failed read. Successful reads restore the normal admission display and controls.
1411
+ - **Preserve terminal outcomes for skipped bulk cancellations** — `@voltro/cli`
1412
+
1413
+ A previewed cancellation target that finishes before Apply now receives a durable already-terminal receipt. Its original result and the operator reason survive lost replies and dashboard reloads without claiming that cancellation caused completion. Existing accepted controls remain unchanged, and repeating the same identity cannot cancel a later generation. Development and compiled serving share the resident-store implementation.
1414
+ - **Withdraw unreserved skipped bulk restart requests durably** — `@voltro/cli`
1415
+
1416
+ Bulk redrive now returns a durable withdrawal when a previewed target becomes ineligible before Apply. Reload and lost-response recovery can observe the closed request, and late submissions of the same identity cannot restart it. Existing pending or entered restart requests remain unchanged. The shared dev/serve implementation validates the previewed workflow execution under the resident reservation lock.
1417
+ - **Resolve skipped bulk retry requests without losing recovery evidence** — `@voltro/cli`
1418
+
1419
+ Bulk retry now records a durable non-started outcome for a previewed target skipped before dispatch. Existing retry intents remain unchanged, including work accepted before a lost response. The shared dashboard can recover and dismiss the skipped request after reload, and a delayed submission of the same identity cannot start it. Development and compiled serving use the same resident-store implementation.
1420
+
1421
+ Applying a preview reads its fixed target IDs even when an original status filter no longer matches, so changed targets are reported instead of silently disappearing. New matches outside the preview remain excluded.
1422
+ - **Admission overview excludes original starts from the collapsed counter** — `@voltro/devtools-ui`
1423
+
1424
+ The workflow admission overview counts only extra starts merged into each queued or admitted group. Independent starts contribute zero instead of inflating the collapsed-start counter; individual group details continue to show their total represented starts.
1425
+ - **Distinguish missing workflow metrics from zero** — `@voltro/devtools-ui`
1426
+
1427
+ Workflow definitions show unknown metrics as an em dash, count only running entries in the loaded results, and render historical trends only from observed statistics. Queued runs no longer inflate the running count.
1428
+ - **Preserve workflow definitions with explicit replica evidence**
1429
+
1430
+ Add a shared workflow definition observation reader and replica comparison view. Incomplete responses preserve available definitions and missing-peer evidence; conflicting schemas and registrations remain separate rather than selecting a default schema.
1431
+ - **Event and replay dialogs keep keyboard focus and prevent duplicate sends** — `@voltro/devtools-ui`
1432
+
1433
+ Event and replay dialogs focus their input and restore focus after dismissal. Pending emissions block dismissal and duplicate sends; accepted receipts focus Close and cannot be resent from the same dialog.
1434
+ - **Workflow event replay retains every delivery outcome**
1435
+
1436
+ Enable workflow event sending in compiled production inspect hosts and persist skipped replay deliveries without turning repeated filtering into a unique-key failure. Delivery lists remain reachable when dashboard replica selection adds query parameters. Skipped outcomes use neutral styling, delivery errors wrap on narrow screens, and Replay is a separate keyboard-accessible button.
1437
+ - **Serialize competing retries of queued workflow intents** — `cli`
1438
+
1439
+ The workflow inspector now serializes queued-intent retry decisions with admission and discard decisions. Concurrent requests cannot both report that they re-armed the same dead-letter transition. The queued payload and its provenance remain unchanged.
1440
+ - **Preserve long workflow queue waits in admission records** — `workflow`
1441
+
1442
+ Workflow admission records store elapsed waiting time as a number beyond the 32-bit integer limit. Discarding or admitting a start queued for more than about 25 days no longer overflows the PostgreSQL audit column. The declared migration updates existing integer columns while preserving numeric values and nulls; dashboard responses continue to use numeric milliseconds.
1443
+ - **Workflow message dialogs retain pending requests and unconfirmed signals** — `@voltro/devtools-ui`
1444
+
1445
+ Signal and update dialogs provide keyboard focus and safe dismissal. Pending requests prevent duplicate submissions and edits. Update results retain focusable feedback, and sent signals no longer disappear after a timeout without confirmed consumption.
1446
+ - **Reliable workflow signal confirmation and accessible message fields** — `@voltro/devtools-ui`
1447
+
1448
+ Workflow signal dialogs now recognize consumption that occurs before the send response arrives, while ignoring events preceding the send. Signal and update fields expose associated labels and announce validation errors.
1449
+
1450
+ Live refreshes preserve open workflow message dialogs for the same app, run, and replica selection. Consumption confirmations close reliably without background renders resetting their timer.
1451
+ - **Preserve workflow panel snapshots during read failures** — `@voltro/devtools-ui`
1452
+
1453
+ Workflow definitions, domain events and event deliveries retain qualified snapshots during refresh failures. Unknown reads no longer imply empty panels or zero counts; incoming traffic requires current contributing reads before showing an empty state. Event replay is withheld while its source snapshot is stale and restored on recovery.
1454
+ - **Complete parent and child workflow navigation** — `@voltro/devtools-ui`
1455
+
1456
+ Run details link children back to their parent and display the parent-close policy. Parent/child navigation preserves replica selection, including keyboard navigation. Execution-ID links resolve steps and events through the canonical run ID.
1457
+ - **Workflow snapshots distinguish unavailable reads from empty results** — `@voltro/devtools-ui`
1458
+
1459
+ Keep the last successful workflow run and statistics snapshots during refreshes and read failures, with separate loading and stale-state notices. Initial failed reads no longer appear as empty run history or a healthy run summary. Hide health and summary calculations while their required reads are unavailable instead of substituting page-level latency for missing window statistics.
1460
+
1461
+ Clear retained snapshots when the app, selected replicas or query changes. Describe run health and counts as belonging to the loaded, filtered results; distinguish them from statistics over the selected time window. Successful reads clear the notices and restore current summaries.
1462
+ - **Workflow recovery remains accessible without a run snapshot** — `@voltro/devtools-ui`
1463
+
1464
+ Keep saved retry, restart and control receipts visible on the run detail page while its selected replica is loading, unavailable or cannot find the run. Recovery uses the saved app/run identity instead of depending on the current run snapshot. Other apps and runs cannot display these saved requests, and switching views never automatically resubmits an action.
1465
+ - **Keep workflow start available and explain unconsumed signals** — `@voltro/devtools-ui`
1466
+
1467
+ The workflow detail page keeps its start action available when runs already exist. Signal dialogs show an ended run and its available error when consumption remains unconfirmed, retaining the notice until explicitly closed.
1468
+ - **Read retained restart receipts after their original run is removed** — `@voltro/cli`
1469
+
1470
+ Restart status reads now locate durable requests and withdrawals independently of their original run rows. Registered physical stores and namespaces are searched completely; ambiguous, contradictory or incompletely discovered ownership fails rather than selecting a fallback store. Retained dashboard receipts survive run deletion and reload without bypassing validation for new restart writes.
1471
+ - **Workflow payload retry explains saved-request conflicts and uncertain responses** — `@voltro/devtools-ui`
1472
+
1473
+ The payload retry dialog keeps its draft and displays actionable errors for conflicting saved requests or lost responses instead of raising an unhandled browser error. It uses the shared modal dialog and a labelled payload field. Copy now correctly describes a new execution with an unchanged original, and directs operators to review saved requests before changing payloads.
1474
+ - **Read retained retry receipts after the original run is removed** — `@voltro/cli`
1475
+
1476
+ Fresh-retry status reads now locate retained receipts by original run ID and request ID across registered physical stores and namespaces. Accepted and non-started outcomes survive original-run deletion and dashboard reload. Ambiguous or incomplete discovery fails explicitly; new retry submissions keep their existing validation.
1477
+ - **Workflow run views show elapsed time across suspension** — `@voltro/devtools-ui`
1478
+
1479
+ Run headers, run rows and child rows calculate elapsed time from the run's start and completion timestamps instead of showing the recorder's last body-attempt duration. Suspended and resumed runs therefore retain their full elapsed interval, including cancellations recorded without another body invocation. Missing or contradictory timestamps display an unknown duration. Recorded execution statistics and step durations are unchanged.
1480
+ - **A workflow run that ends failed writes a log line, under `voltro serve` too** — `@voltro/workflow`, `@voltro/cli`
1481
+
1482
+ A run that reached `failed` wrote nothing to the server log: the failure went to the run row, the metrics and the server-error bus, and without an error reporter subscribed an operator reading the log saw no sign of it. Each such run now writes one warning, `workflow run failed`, with the workflow, `executionId`, `runId`, error tag and cause — for an `encrypted: true` workflow the cause names the error's class and withholds its text. Under `voltro serve` the run recorder also had no logger at all, so its own warnings (a bookkeeping write that could not be recorded) went nowhere; it logs to `voltro:serve:workflows` now, as `voltro dev` always did.
1483
+ - **Use the shared checkbox for workflow run selection** — `@voltro/devtools-ui`
1484
+
1485
+ Workflow run selection now uses the shared design-system checkbox, with consistent checked and keyboard-focus styling. Space toggles the selected run without opening its detail page.
1486
+ - **`makeWorkflowRunner` runs an encrypted workflow without a key** — `@voltro/testing`
1487
+
1488
+ A test of a workflow declared `encrypted: true` failed with "no field-encryption key is registered". The runner now uses an ephemeral key when none is registered, the way `voltro dev` mints one; a key the test registers takes precedence.
1489
+ - **Workflow start dialogs support keyboard navigation and safe dismissal** — `@voltro/devtools-ui`
1490
+
1491
+ Workflow start dialogs focus their payload editor, keep background controls outside modal navigation, and return focus after dismissal. Pending submissions cannot be dismissed or sent twice before their loading state updates.
1492
+ - **Workflow start forms use the connected gateway schema** — `@voltro/devtools-ui`
1493
+
1494
+ Workflow start forms use the connected write gateway's schema instead of an independently selected inspection replica's schema. Local and Cloud providers keep these reads separate. Unavailable gateway definitions disable submission while preserving the draft. Shared dashboard hosts provide `useStartDefinitions` alongside `useStartRun`.
1495
+ - **Workflow statistics use consistent time-window boundaries** — `@voltro/cli`, `@voltro/devtools-ui`
1496
+
1497
+ Exclude workflow runs at or after the observation window end from statistics. Totals, tag and version summaries, latency samples and chart buckets now share the same interval, including its start and excluding its end. Future timestamps can no longer inflate totals while remaining absent from the chart.
1498
+
1499
+ Forward absolute time filters from the shared dashboard to statistics as well as the run list. Inspect accepts optional `startedAfter` and `startedBefore` timestamps, preserving historical intervals instead of silently substituting the last 24 hours. Invalid or reversed bounds return HTTP 400. Sparkline tooltip times follow the loaded window instead of the browser clock. Keep workflow filters and saved views available when an active filter returns no runs, so the user can clear or replace it without leaving the page. Report browser-storage failures without losing saved views or the name draft, and explain that applying a saved view clears absolute time bounds. Label loaded windows with their actual duration instead of a rounded, implicitly recent range.
1500
+ - **Workflow timelines include children that outlive their parent** — `@voltro/devtools-ui`
1501
+
1502
+ Scale workflow run timelines through the latest child completion instead of clipping them to the parent's duration. Open children continue advancing the timeline clock after the parent ends, while the parent header retains its own duration. Unconfirmed parent recordings do not become active again.
1503
+ - **Workflow timeline labels adapt to available plot width** — `@voltro/devtools-ui`
1504
+
1505
+ Workflow timelines adjust tick density to their measured width and anchor boundary labels inside the plot. Mobile views retain readable elapsed offsets without changing activity positions, the time range, or keyboard navigation. Very long runs keep a bounded number of ticks.
1506
+ - **Workflow timeline unrecorded endings** — `@voltro/devtools-ui`
1507
+
1508
+ Fix: Stop showing unfinished activity and wait recordings as live after their workflow run has completed. The shared timeline explicitly reports a missing ending without inventing an outcome or duration; independently running child workflows retain their own status.
1509
+ - **Preserve saved workflow views when browser storage cannot be read** — `@voltro/devtools-ui`
1510
+
1511
+ Unreadable or malformed saved workflow views no longer appear as an empty writable list. The dashboard preserves stored entries, blocks save/delete until a successful retry, retains the name draft and restores keyboard focus. Mutations re-read the current list before updating it.
1512
+
1513
+ ### Internal (no consumer-facing effect)
1514
+
1515
+ - **Add internal SQLite timeline capture migration primitives**
1516
+
1517
+ Add internal SQLite capture installation and retirement primitives with masking before persistence, bounded source history, transactional rollback, complete declared-schema identity and installed schema-cookie validation. DML-only leases use database time, preserve exact source positions, expire without revival and release independently.
1518
+
1519
+ Pending plugin bodies, file migration execution and approved declarative schema operations retire installed capture before changing captured columns. Empty passes and refused file rollbacks preserve active history. Declarative preparation runs after replanning and refusal checks, so already-converged and blocked plans do not retire capture.
1520
+
1521
+ An internal coherent source reader validates capture identity, lease lifetime, schema, retention and exact cursors in the same SQLite read transaction as journal events and application rows. Real concurrent-writer and additive-bootstrap tests cover snapshot consistency and lease invalidation. Internal journal image decoding reuses SQLite schema codecs, preserves privacy markers and rejects malformed batches before recording. The internal paged current-row reader shares journal codecs/privacy and stays inside the source transaction; journal events use numeric source ordering. An internal scoped session serializes source consumption, renews leases, polls changes and invalidates gaps before reacquiring; shutdown releases its own lease. Inspect adapters can consume coherent source snapshots for list/replay behind the existing authentication gate, without mixing later recorder events into earlier rows. Native SQLite host activation is described in the matching source-capture change note.
1522
+ - **Typecheck shared migration fixtures in the Turso test suite** — `@voltro/sql-turso`
1523
+
1524
+ Include sibling package sources in the test compiler root so the shared numeric primary-key migration contract is checked without changing production emission.
1525
+ - **Connect reserved workflow restart deliveries to recorded entry** — `@voltro/workflow`, `@voltro/sql-sqlite`, `@voltro/cli`, `@voltro/protocol`
1526
+
1527
+ Recorded workflow entry now consumes a matching dispatched restart reservation when a failed run receives its new engine delivery token. It retains run and execution identity, enforces admission and parent-close checks, and observes the single durable restart event. Unreserved, undispatched, mismatched, discarded, and terminal replay attempts remain refused.
1528
+
1529
+ A real SQLite engine and resident-store integration proves selective step re-execution, shared-pool capacity waiting and release, and duplicate-dispatch refusal. Dashboard Inspect request and receipt wiring remains separate and is not activated by this internal change.
1530
+
1531
+ Engine-bound command preparation recovers the original reservation before reading the run's current generation. Repeating a command after a lost response retains its original generation and exact step selection even after the run has entered, succeeded, or failed again. The SQLite proof also covers recovery after actual body completion without repeating side effects.
1532
+
1533
+ The shared host adapter resolves the resident store, reads step selection under the failed-generation lock, and uses the shared capacity coordinator. Repeated step recordings retain their original ordering while their latest status drives failed-step selection. Ambiguous timestamp cutoffs are refused rather than guessing which earlier side effects are safe to repeat. The ordinary Inspect routes have not yet switched to this adapter.
1534
+
1535
+ Restart receipt schemas and a read-only Inspect handler distinguish missing, pending, and entered commands. Receipt reads never reset journal replies or dispatch work, and omit internal delivery tokens, reply IDs, and admission ownership. The shared Inspect adapter and recovery worker are wired in dev and serve; migration of the run-panel actions remains pending.
1536
+ - **Bind internal restart dispatch to the workflow engine journal** — `@voltro/workflow`
1537
+
1538
+ Add an engine-bound internal restart dispatcher with mandatory capacity coordination and replay-safe journal reset/poll recovery. This does not yet activate Inspect step-resume or authorize recorder re-entry.
1539
+
1540
+ The internal capability also captures exact failed-run and selected-activity reply identities with engine ownership checks and lossless integer handling. Capture alone does not authorize a restart.
1541
+
1542
+ Internal reservations retain the exact operator selection and support immutable command lookup after an uncertain response, including after body entry. Reusing an identity for a different selection or owner is rejected. This remains dormant until the Inspect and recorder integration is complete.
1543
+
1544
+ Prepare requests without holding a resident transaction across journal reads, then revalidate ownership and pause state before reserving. Keep the new selection column nullable for additive table upgrades; existing requests without an explicit selection fail closed. The dispatcher exposes this preparation internally, while Inspect and recorder behavior remain unchanged.
1545
+ - **Persist internal restart withdrawal fences** — `@voltro/workflow`, `@voltro/cli`, `@voltro/sql-sqlite`
1546
+
1547
+ Add a resident-transaction primitive for permanently fencing never-reserved restart identities. Preparation and direct reservation reject fenced identities, including commands already observing the journal. Accepted reservations remain authoritative and are never cancelled or released by this primitive.
1548
+
1549
+ The workflow framework inventory includes the additive withdrawal table. This is the storage boundary for the authorized Inspect withdrawal endpoint. The shared dashboard and local, customer, and admin providers submit withdrawal with the saved identity, retain uncertain outcomes across reloads, and require confirmation before choosing new intent. Already accepted work keeps running. Tests cover ownership, immutable selection, rollback, both orderings, SQLite persistence after reopening, and the unchanged recorded-engine restart path.
1550
+ - **Keep SQLite workflow migration tests in the dialect package** — `@voltro/workflow`, `@voltro/sql-sqlite`
1551
+
1552
+ Move the budget-hold SQLite migration test into the SQLite package and remove the reverse workflow test dependency that created a Turbo build cycle.
1553
+
1554
+ ---
1555
+
1556
+ ## [0.81.0] — 2026-09-25
1557
+
1558
+ ### ⚠ BREAKING
1559
+
1560
+ - **The compose baseline gives each api app its own database** — `@voltro/cli`
1561
+
1562
+ `voltro baseline sync` now gives every api app in a `project.json` its own database, login and migrate service. The primary api (the app named `api`, else the first api app) keeps the project's database; every other api app connects to `<project>_<app>` with `<PROJECT>_<APP>_DB_PASSWORD`, and each api app gets `VOLTRO_API_<APP>_URL` for its sibling api apps by service name. Migration: sync, move each secondary app's own tables into its new database, and set the new password variables in production.
1563
+
1564
+ **`voltro update` carries you across this** — codemod `0.81.0/03_compose-baseline-one-database-per-api-app`. 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.81.0).
1565
+ - **A crashed process blocks its idempotency key for 30 seconds, not a day** — `@voltro/protocol`, `@voltro/runtime`, `@voltro/client`, `@voltro/voltro`
1566
+
1567
+ A process that died inside an idempotent mutation left its key `in_flight` for the whole replay window — 24 hours by default — so every retry with that key was refused with `MutationInFlight` (REST: `409`), including the retry of a workflow step whose callee had simply restarted. A running claim now holds on a 30-second lease that the server renews while the handler runs, over the socket and on REST alike; a dead process stops renewing, and the next send with the key takes over and runs the mutation once — the dead call's transaction never committed. A completed response still replays for the whole window. A takeover is conditional on the claim still being stale, so a claim renewed a moment earlier is never taken from a live handler. `ctx.apis` calls treat `MutationInFlight` as no answer yet: the same key is re-sent when the callee says, until it answers or the call's deadline passes (then `ApiUnavailable`). BREAKING only for an app that implements `IdempotencyStore` itself: `claim` takes the lease, and `renew(scope, key, now)` is required.
1568
+
1569
+ **`voltro update` carries you across this** — codemod `0.81.0/05_idempotency-store-renews-its-lease`. 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.81.0).
1570
+ - **Each api app owns its database schema; a second app on it refuses to boot** — `@voltro/database`, `@voltro/cli`
1571
+
1572
+ The first boot that migrates a database records which app owns it (`_voltro_schema_owner`). A different app booting against the same database and schema now refuses — naming both apps — before it plans anything, under `voltro dev`, `voltro serve` and `VOLTRO_AUTO_MIGRATE=0` alike; `voltro db apply` refuses the same way and claims an unclaimed database — as its first write, after every other check has passed, so an apply refused for drift or a blocked operation leaves the database as it found it. `voltro data` export and import treat the owner row as the deployment's own: it never travels to another database. Two apps on one schema each diffed it against their own declarations and planned to drop the other's tables. Migration: give every api app its own database (`DB_URL`) or schema (`DB_SCHEMA`) and reach the other app's data through `apis:` and `service:` procedures; `voltro doctor` lists api apps of a project that resolve the same target. `voltro db claim --yes` hands a schema to a RENAMED app.
1573
+
1574
+ **`voltro update` carries you across this** — codemod `0.81.0/01_one-database-per-app`. 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.81.0).
1575
+ - **ReactionDefinition is the union of the table form and the query form** — `@voltro/runtime`, `@voltro/voltro`
1576
+
1577
+ With reactions that watch a live query's rows, `ReactionDefinition` is now `EdgeReactionDefinition | LevelReactionDefinition`. Code holding a value of that type and reading a member only the table form has — `watch.table`, `watch.on`, `guards.dedupeKey`, `when(event)` — no longer compiles: type it `EdgeReactionDefinition`, or narrow with `isLevelReaction(def)` first. `defineReaction({ … })` is unchanged: an object literal resolves to its own form, and a value typed as the union is still accepted and validated by its `watch`.
1578
+
1579
+ **`voltro update` carries you across this** — codemod `0.81.0/04_reaction-definition-is-a-union`. 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.81.0).
1580
+ - **Store calls on a table the app does not declare now fail** — `@voltro/runtime`, `@voltro/cli`
1581
+
1582
+ Every store verb — in handlers, startups, schedules and workflows — now fails with `UndeclaredTableAccess` on a table that is neither declared by the app (its `table()`s, a plugin's `extendSchema.tables`) nor framework-owned (`_voltro_*`), in every environment. A string table name used to reach another app's table past its guards, audit and tenant rules. `store.raw(sql)` is unchanged. Migration: call the owning app through `apis:` and a `service:` procedure, or declare the table if it is this app's own.
1583
+
1584
+ **`voltro update` carries you across this** — codemod `0.81.0/02_store-refuses-undeclared-tables`. 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.81.0).
1585
+
1586
+ ### Added
1587
+
1588
+ - **A plugin can bring app-shaped building blocks with `sources`** — `@voltro/protocol`, `@voltro/cli`
1589
+
1590
+ `definePlugin({ sources: fileURLToPath(new URL('./app', import.meta.url)) })` points at a directory of the same file conventions an app uses — queries and mutations with their executors, `schema.ts` tables, `*.workflow.tsx`, `*.cron.tsx`, subscribers, reactions — and every app that installs the plugin discovers them exactly as it discovers its own: the same codegen, browser-safety guard, access gate, migrations, and both boot paths. A reusable building block (a ledger, an audit trail, an approval flow) is shared between apps of a project without copying it; each installing app gets its own copy of the block's tables in its own database. `voltro build` keys the block's modules by plugin and path, so the serve bundle finds them wherever the block lives after a deploy.
1591
+ - **A reaction can watch the rows of a live query, including another app's** — `@voltro/runtime`, `@voltro/cli`
1592
+
1593
+ `defineReaction({ watch: { query: 'deployments.pending', api: 'cloud' }, key: (row) => row.id, act: { kind: 'workflow', workflow: 'deploy.run' } })` reacts to every ROW of a live query's current result — the set of open work — instead of to each change of a table. Every delivery is the whole set, so a row that appeared while the reacting process was down, restarting or deploying is acted on when it comes back; nothing is lost. The workflow's `idempotencyKey` makes each row's act exactly-once across every replica, so no claim table is involved and a start that failed is tried again on the next delivery or on `resync` (default 5 minutes). `resync` and `stuckAfter` take milliseconds or a duration from `'30s'` to `'8 days'`. With `api:` the query is another app's `service:` query, subscribed over `ctx.apis` — push without the watched app knowing its watcher; without it, one of this app's own queries, read through the same guards and row filter as its REST projection. A row still in the set long after its workflow started is reported as `stuck` and never restarted. `voltro_reaction_pending`, `voltro_reaction_oldest_seconds` and `voltro_reaction_connected` expose the queue per reaction. The table form (`watch: { table }`) is unchanged; `runReaction` now takes the table form's type, `EdgeReactionDefinition`.
1594
+ - **Apps of one project call each other through typed service procedures** — `@voltro/voltro`, `@voltro/protocol`, `@voltro/runtime`, `@voltro/client`, `@voltro/cli`, `@voltro/testing`, `@voltro/workflow`
1595
+
1596
+ An api app can now call another app of the same project, typed, over the socket, without touching its tables. The called app declares `service: { callers: ['worker'] }` on a query, mutation or action and lists the caller under `serviceCallers: { worker: {} }`; codegen emits those procedures into `serviceGroup.generated.ts` and never into the browser's `rpcGroup.generated.ts`. The calling app binds it under `apis: { cloud: { package: '@acme/api' } }` and calls `ctx.apis.cloud.deployments.activate(input)` (Promise) or `.effect(input)` (Effect), with every input, output and typed error inferred from the callee's declarations. A service authenticates with a per-edge credential (`VOLTRO_API_<BINDING>_TOKEN` on the caller, `VOLTRO_SERVICE_CALLER_<CALLER>_TOKEN` on the callee, `_NEXT` for rotation); `voltro dev` derives and mints it into both apps' `.env.local`, production supplies it (`voltro secret generate service`) and a missing one fails boot. A caller name is letters, digits and dashes, at most 56 characters, so its `service:<name>` identity fits an `id()` column. The called app plants an `actors` row for each accepted caller at boot (`service:<name>`, `kind: 'serviceAccount'`), so `audit()`-stamped writes name the calling app instead of failing on the foreign key. A service subject holds no scopes: it reaches exactly the procedures that name it, is refused everywhere else — framework built-ins included — and no user, API key or `admin:full` session reaches a service procedure. A write made inside a workflow step carries an idempotency key derived from the step, so a step retried after a lost reply replays instead of writing twice; an app with `serviceCallers` deduplicates mutations by default. A call without an answer fails with `ApiUnavailable` within a deadline (10 s by default); calls are measured as `voltro_api_call_duration_seconds` and `voltro_api_call_errors_total`. `voltro doctor` reports a caller listed without a procedure, a procedure naming an unlisted caller and a binding the callee does not accept. `@voltro/testing` adds `makeServiceApiMock`, which decodes every input and output through the callee's real schemas.
1597
+
1598
+ `apiSurface: compatible` — the replaced golden lines in `@voltro/protocol` (and the `@voltro/voltro` aggregate re-exporting it) are `AnyCheckSpec` and `DeclaredAccess` gaining the member `ServiceAccessSpec`. Every existing member is unchanged and an app keeps writing the guards it wrote; the framework is the only producer of the new member, which it normalises from `service:`. Only a switch exhaustive over guard specs needs an arm.
1599
+ - **startTestApps boots several apps of one project, each service edge wired** — `@voltro/testing`
1600
+
1601
+ `startTestApps({ apps, edges })` from `@voltro/testing` boots the apps of one project as real `voltro` processes (`dev` by default, `serve` with `mode: 'serve'`), each on a free port — every callee before its callers, because a caller imports the contract its callee's codegen writes — and resolves once every app answers `/internal/readiness`; a cycle of edges is refused before anything starts. Every listed edge gets a freshly minted credential written to both ends, under the variables the apps read (`VOLTRO_API_<BINDING>_TOKEN` / `_URL` on the caller, `VOLTRO_SERVICE_CALLER_<CALLER>_TOKEN` on the callee); `apps.token(caller, callee)` returns it so a test can call the callee as the caller. An app that exits or is not ready in time stops the others and the call rejects with its log. Processes rather than one process, because apps with their own tables cannot share one process's table registry.
1602
+ - **makeTestApp requests carry the caller's address** — `@voltro/testing`
1603
+
1604
+ A route that reads `ctx.remoteAddr` (a per-client rate limit, an audit field) could not be tested per client: every `makeTestApp` request arrived without an address. A request's options now take `remoteAddr`, the address the connection comes from, defaulting to `127.0.0.1`. The route sees it as `ctx.remoteAddr`, resolved by the function the server's listener uses, and a `publicApi` procedure's plugin interceptors see the same address, as on the server. `makeTestApp({ security: { trustedProxies, clientAddressHeader } })` takes the api's own settings, so a forwarding header is believed in a test exactly when it would be in production.
1605
+
1606
+ `markRestInvocation` takes the request's address as an optional third argument, and `restInvocationOf` returns it beside the spec; both only gain an optional member, so existing calls and reads are unchanged.
1607
+
1608
+ ### Fixed
1609
+
1610
+ - **Codegen removes an rpc group the app no longer has procedures for** — `@voltro/cli`
1611
+
1612
+ An app whose last client-reachable procedure went away — deleted, or marked `internal: true` — kept its `rpcGroup.generated.ts`: codegen reported "no rpc primitives found — nothing to write" and left the old file, a typed contract for procedures the server no longer served. The next `voltro dev` or `voltro codegen` now removes it, as it already removed an empty `serviceGroup.generated.ts` and `apis.generated.ts`. A hand-written file of that name is left alone.
1613
+ - **The auto-launched dashboard binds `VOLTRO_DASHBOARD_PORT`** — `@voltro/cli`
1614
+
1615
+ `VOLTRO_DASHBOARD_PORT` was read by `voltro dashboard` only. The dashboard that `voltro dev` and `voltro start` launch on their own — api and web app alike — always came up on `:5179`, and the launcher wrote `5179` into the child's `VOLTRO_DASHBOARD_PORT`, overriding the value the developer had set. The launcher and `voltro dashboard` now resolve the port through one reader: the variable, else `5179`. A value that is not a port (`1..65535`) is refused with a warning naming it instead of silently becoming `5179`.
1616
+ - **The db pool boot line counts a connection outside the pool once** — `@voltro/cli`
1617
+
1618
+ With change data capture on, the line read `max=5 … + 1 = 6` and then, separately, `PLUS 1 outside the pool (CDC LISTEN consumer)`, which reads as 7 when the process holds 6. The outside connection is now named once, inside the sum it belongs to: `max=5 per replica (DB_MAX_CONNECTIONS) + 1 outside the pool (CDC LISTEN consumer) = 6 per process`.
1619
+ - **A declared `utf8mb4` store reaches the workflow engine's tables** — `@voltro/database`, `@voltro/workflow`, `@voltro/cli`
1620
+
1621
+ `store: { charset: 'utf8mb4' }` converted every table the app declares and left the tables the framework creates without declaring them in the database default: the durable workflow engine's `cluster_*` storage, which holds every workflow call's input and result as JSON text, and the deploy runner's `_voltro_deploys` / `_voltro_migration_ops`. On a `utf8mb3` database a workflow whose input carried an emoji was refused there. Every table the framework creates is now created in the declared charset: the runner's ledgers directly, and the workflow engine's tables by the process that creates them on its first boot, while they are still empty. A workflow table a booting process finds is never rewritten at boot; tables from before the declaration are converted by `voltro db apply` (also when the plan has nothing to do, and with `--plan`) once the deploy has closed and by the `voltro dev` auto-migrate, keeping type, nullability and column CHECKs, and `voltro db plan` lists them beside the plan (under `--json` too, on the log, never in the plan file). The database's own default is left alone. The step only ever widens, and a deploy that fails and is undone never runs it.
1622
+ - **The MySQL/MariaDB pool keeps its connections instead of reconnecting** — `@voltro/sql-mysql`
1623
+
1624
+ The driver pool was created with `maxIdle: 0`, so every connection released to it was closed within a second. A process reconnected after each second of quiet: a TCP handshake per burst, plus a TLS one where configured. Measured on MySQL and MariaDB: a query after two quiet seconds ran on a new connection. The pool now keeps the connections it opened, up to the limit.
1625
+ - **Workflows started by a reaction or an incoming webhook start again** — `@voltro/cli`
1626
+
1627
+ A workflow started by a `*.reaction.ts` act or an incoming `*.webhook.tsx` was refused with `Workflow placement requires explicit caller identity`, under `voltro dev` and `voltro serve`: neither path named a caller, and placement refuses a start without one. Both now start as the framework itself, placed in the changed row's tenant when it has one.
1628
+ - **The first boot on an empty database no longer warns about replica observations** — `@voltro/cli`
1629
+
1630
+ `voltro dev` started publishing this replica's observations before its auto-migrate, so the first write on an empty database hit a table that did not exist yet and printed `could not record this replica's observation — fleet views will report this replica as stale` on the first boot of every new project. The publisher now writes its first observation once the schema is applied — at once on `voltro serve`, whose fingerprint check runs before it — and never when the boot stops earlier.
1631
+ - **A 405 on a shared REST path names every method the path serves** — `@voltro/protocol`
1632
+
1633
+ A `DELETE` to a path served by `GET` and `POST` answered `405` with `Allow: POST`: each route refused with its own method, and the last refusal won. `Allow` and the error body's `allow` now list every method declared on the path, `GET, POST`, as the REST documentation already promised.
1634
+ - **A large REST surface no longer holds the boot for seconds registering routes** — `@voltro/runtime`, `@voltro/protocol`
1635
+
1636
+ Every REST route used to be mounted for all 34 HTTP methods the router knows, at its path and at its sub-path, and each insert compares itself against every entry already held. A public REST surface of about 1000 procedures therefore held about 34 000 router entries, and building them kept the thread busy for over a second after the port was bound — long enough for a liveness probe to time out. A path is now mounted for exactly the methods its routes declare: the same 1000 procedures hold 4 000 entries. Behaviour is unchanged. A method the path does not declare still reaches its routes and gets the precise `405` with `Allow`, a path nobody declared is still a `404`, and `HEAD` still answers from `GET`. For this, a REST route now declares its real `method` instead of `'*'` and sets the new `PluginHttpRoute.answersOtherMethods: true`. A plugin route that declares one method without that flag never sees another method, as before. A server of your own that mounts `restRoutesToHttpRoutes` output by method must hand the other methods to routes with `answersOtherMethods`, or they become `404` instead of `405`.
1637
+ - **The testing guide's tsconfig example no longer sets baseUrl** — `@voltro/cli`
1638
+
1639
+ The `paths` alias example in the testing guide set `"baseUrl": "."`, an option TypeScript 7 removed. It now maps `paths` alone, which resolves against the tsconfig's own directory. `voltro test` still reads a `baseUrl` an older config sets.
1640
+ - **voltro update lists a note's code and config lines before prose** — `@voltro/cli`
1641
+
1642
+ A manual note lists the lines it found in code and config first, under `── found in code or config (N lines)`. Lines in Markdown and text files follow in their own `── mentioned in prose (N lines)` block, and a note that matched only prose says `nothing in code or config matched`. Past the lines shown, the rest are named by file with a count instead of a bare `… and N more`. Before, the lines were sorted by path, and 40 lines of documentation hid a Helm env entry, the one line to change. The guide `voltro agents-md` copies into `agent-docs/` is no longer searched. It names every change of a release, so refreshing it made notes report "found" for changes the project never used.
1643
+
1644
+ ---
1645
+
42
1646
  ## [0.80.0] — 2026-09-24
43
1647
 
44
1648
  ### ⚠ BREAKING
@@ -13098,12 +14702,12 @@ _Changes staged for the next release accumulate here (rolled up from
13098
14702
  In its place, a real prefix predicate:
13099
14703
 
13100
14704
  ```ts
13101
- .where('key', 'startsWith', 'awb_') // ergonomic form
13102
- where(startsWith('key', 'awb_')) // predicate helper
13103
- jsonField('config', 'ns').startsWith('awb_') // inside a json() column
14705
+ .where('key', 'startsWith', 'acme_') // ergonomic form
14706
+ where(startsWith('key', 'acme_')) // predicate helper
14707
+ jsonField('config', 'ns').startsWith('acme_') // inside a json() column
13104
14708
  ```
13105
14709
 
13106
- `startsWith` is **case-SENSITIVE**, unlike `contains`, and the asymmetry is the design rather than an oversight. `contains` is a search primitive — a human typing into a box means `hello` to find `Hello`. A prefix is a NAMESPACE: `awb_` and `AWB_` are two different key spaces, and quietly merging them is a bug. It also matches the JS method it is named after.
14710
+ `startsWith` is **case-SENSITIVE**, unlike `contains`, and the asymmetry is the design rather than an oversight. `contains` is a search primitive — a human typing into a box means `hello` to find `Hello`. A prefix is a NAMESPACE: `acme_` and `ACME_` are two different key spaces, and quietly merging them is a bug. It also matches the JS method it is named after.
13107
14711
 
13108
14712
  It is the one string predicate a database can answer from an index: it lowers to `LIKE 'literal%'`, which a btree can range-scan. `contains` (`%…%`) cannot, which is also why there is no `endsWith` — a second un-indexable operator would only look cheaper than it is. `%` and `_` in the value are escaped, so they match literally, and the ESCAPE clause is stated per dialect (sqlite and mssql have no default escape character; mysql already has backslash *and* treats it as a string escape, so the clause is omitted there).
13109
14713
 
@@ -13115,7 +14719,7 @@ _Changes staged for the next release accumulate here (rolled up from
13115
14719
 
13116
14720
  ```ts
13117
14721
  apiKeyStrategy({
13118
- prefix: 'awb_',
14722
+ prefix: 'acme_',
13119
14723
  resolveKey: async (hash, { store }) => {
13120
14724
  const rows = await store?.query(apiKeys.byHash(hash))
13121
14725
  return (rows?.[0] as ApiKeyRecord | undefined) ?? null
@@ -15058,7 +16662,7 @@ _Changes staged for the next release accumulate here (rolled up from
15058
16662
  - **@voltro/client** — **Two ergonomics that remove the two most-repeated shapes in a Voltro frontend.** - **`mutate(input, options)` — `onSuccess` / `onError` / `notify`.** Every write site used to re-type `try { await mutate() } catch { toast.error(…) } finally { setSubmitting(false) }`. `pending` already replaced the `finally`; these replace the rest. Load-bearing semantic: supplying an error handler (`onError` or `notify.error`) marks the failure HANDLED — `mutate` resolves with `undefined` instead of rejecting, which is what actually deletes the try/catch. With no handler it rejects exactly as before, so unhandled failures stay loud. `notify` routes to an app-registered sink (`setMutationNotifier`) — the framework stays unbound to any toast library. - **`useSubscription` now returns `loading` and `isEmpty`, and accepts `fallback`.** Call sites branched on `data === undefined`, conflating "no snapshot yet" with "zero rows" — the cause of a flash of empty-state before the first snapshot. They are now distinct, derived once. `fallback` fills `data` while loading without lying about `loading`. Also documented: `error` carries a COLD-START stream failure (check it to avoid an infinite skeleton); a failure after data arrived deliberately does not blank good data — those reach the error bus (`useOnRpcError`).
15059
16663
  - **@voltro/cli** — **Make the client hook surface discoverable — and keep it that way.** A downstream app built its whole frontend on three transport hooks (`useSubscription` / `useMutation` / `useAction`) and hand-rolled forms, tables, upload, permission gates, debounce and pagination, because the agent guide documented ~none of the ~40 client hooks that already ship. Three changes: - **The always-loaded core now carries a "Pick the CLIENT primitive" rubric** — the same decision-rubric treatment the backend primitives get, plus a "you're about to write X → reach for Y" table (per-field `useState` → `useFormBinding`, hand-rolled table → `useDataTable`, `FileReader` → `useUpload`, `useMemo` fan-in → `useDerived`, …). - **The hook reference is complete.** `reference/hooks-overview.md` presented itself as "the client-side hook surface" while documenting 13 of 44 hooks; it now enumerates the schema-driven-UI, files/permissions/utilities, and AI families (en + de). - **A coverage GATE** (`agentsMdTemplate.test.ts`): every exported `use*` hook of `@voltro/client` must appear in the composed agent-docs corpus, or the test fails with the list. Docs can no longer silently fall behind the export surface. Plus **`voltro doctor` now flags hand-rolls**: it scans an app's source for the patterns a shipped primitive covers (hand-rolled form/table, `FileReader` upload, `setTimeout` debounce, `useMemo` fan-in, local Next.js compat shims, hand-rolled presence) and names the primitive to use. Advisory, never fatal — a false-positive lecture must not fail a build.
15060
16664
  - **@voltro/cli** — **Generated `matchError` / `AppError` / `AppErrorTag`.** Codegen now emits a per-app exhaustive error matcher into `rpcGroup.generated.ts`, derived BY REFERENCE from every descriptor's `error:` schema plus the cross-cutting plugin errors. `matchError(err, { [tag]: handler }, fallback?)` dispatches on `_tag` with the handler keys constrained to the app's ACTUAL error tags — so a hand-maintained tag list that silently drifts (dead/renamed tags) is gone; a stale tag is a compile error. Browser-safe (reads `_tag` structurally, no runtime dep). Workflows keep their existing `WorkflowErrors`.
15061
- - **@voltro/cli** — **`voltro doctor` + `voltro update --codemods-only`/`--from`** — two upgrade-path ergonomics from AWB adoption feedback: - **`voltro doctor [app]`** (and **`voltro serve --preflight`**) preflights an API app for production serve: it verifies the precompiled serve bundle exists and, if not, prints the exact fix and exits 1 — so a Dockerfile / CI step catches "prod `voltro serve` with no `voltro build`" at BUILD time instead of at cold-start (0.3.0 made an unbuilt production serve fatal). - **`voltro update --codemods-only` (alias `--run-codemods`) + `--from <version>`** — re-apply the codemods + manual notes for an explicit `[from, to]` delta WITHOUT bumping `package.json` or installing. The recovery path for a hand-edited version bump (`bump package.json` + install first), which otherwise makes `voltro update` report "already on X — nothing to do" and silently skip the codemods. `--from` also overrides the auto-detected source version on a normal update.
16665
+ - **@voltro/cli** — **`voltro doctor` + `voltro update --codemods-only`/`--from`** — two upgrade-path ergonomics from adoption feedback: - **`voltro doctor [app]`** (and **`voltro serve --preflight`**) preflights an API app for production serve: it verifies the precompiled serve bundle exists and, if not, prints the exact fix and exits 1 — so a Dockerfile / CI step catches "prod `voltro serve` with no `voltro build`" at BUILD time instead of at cold-start (0.3.0 made an unbuilt production serve fatal). - **`voltro update --codemods-only` (alias `--run-codemods`) + `--from <version>`** — re-apply the codemods + manual notes for an explicit `[from, to]` delta WITHOUT bumping `package.json` or installing. The recovery path for a hand-edited version bump (`bump package.json` + install first), which otherwise makes `voltro update` report "already on X — nothing to do" and silently skip the codemods. `--from` also overrides the auto-detected source version on a normal update.
15062
16666
  - **@voltro/runtime** — **`encryptField` / `decryptField` — standalone field cipher for raw-SQL paths.** `.encrypted()` columns encrypt/decrypt transparently inside the `ctx.store` middleware, so a code path that reaches the DB by RAW SQL (an auth strategy with no store handle reading/writing a session token, a one-off backfill) bypasses it. `@voltro/runtime` now exposes the SAME registered cipher standalone: `encryptField` encrypts a value; `decryptField` decrypts an `enc:v1:…` value and passes a non-ciphertext value through unchanged (so a raw-SQL path can adopt encryption while pre-existing plaintext rows keep working). Both throw a clear error when no cipher is registered. Encryption stops being all-or-nothing tied to going through `ctx.store`.
15063
16667
  - **@voltro/testing** — **`makeVoltroTestClient` — the missing half of the test story.** The framework shipped a strong BACKEND test story (`makeTestContext`, `mockStore`, `mockAi`, the workflow runner) and nothing for the client. A Voltro frontend is a reactive-data app: with no way to render a component against mocked `useSubscription` / `useMutation` — and to inject a loading state, a stream error, or a failing write — it is structurally untestable, which is why real apps end up with zero frontend tests. `import { makeVoltroTestClient } from '@voltro/testing/client'` returns a `Provider` plus `setSubscription` / `failSubscription` / `resetSubscription` and a `calls` log of every write with its input. A tag ABSENT from the fixture stays in the loading state — the distinction from an empty result is exactly what you want to assert. Deliberately NOT a renderer: it hands you a Provider, so it works with `react-dom/client` + `act`, testing-library, or your own harness, and locks you into none of them.
15064
16668
  - **@voltro/i18n** — **Plurals, Intl formatters, and catalogs a bundler can actually split.** - **`plural(locale, count, forms)` + `usePlural`** — real CLDR categories via `Intl.PluralRules`. Replaces the hardcoded `"{count} epic(s)"` pattern, whose literal `(s)` is simply wrong outside English, and which cannot express languages that distinguish few from many (Polish 2–4 vs 5+). An explicit `zero` form is honoured for exactly 0; ordinals via `{ type: 'ordinal' }`. - **`useFormatDate` / `useRelativeTime` / `useFormatNumber` / `useFormatCurrency` / `useFormatters`** — locale-bound `Intl` wrappers, so an app stops carrying several divergent hand-rolled "X minutes ago" helpers that each round differently. - **`defineCatalogs` + `LazyI18nProvider`** — code-split catalogs. `pickCatalog({ en, de })` is a STATIC import map: every locale is a value-level import, so the bundler must emit them in one chunk and a German-only visitor downloads English too, growing linearly per locale. A map of `() => import()` loaders is the only shape a bundler treats as a chunk boundary. `preload(locale)` before hydration keeps first paint synchronous via `peek()`; the provider's `fallback` is for a locale SWITCH, not first paint. Unknown locales degrade to the base catalog, and a failed chunk load stays retryable.