@voltro/ui-shadcn 0.69.1 → 0.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/CHANGELOG.md +1369 -111
  2. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -39,6 +39,1264 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.71.0] — 2026-09-14
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **Provider grounding survives every step of an agent's tool loop** — `@voltro/ai`, `@voltro/cli`
47
+
48
+ AgentEvent and persisted MessagePart now include stepMetadata with a zero-based model-step index and providerMetadata, preserving provider grounding across tool-loop steps instead of discarding it. Source and file events/parts retain their own optional providerMetadata. Update exhaustive event/part switches and keep attribution separate from answer text; stream hooks expose it in events and durable chats in messages.parts. Resumable streams journal/replay it unchanged. No raw HTTP headers or request/response bodies are copied; provider metadata remains untrusted, provider-specific data and must be validated before display.
49
+
50
+ **`voltro update` carries you across this** — codemod `0.71.0/06_agent-grounding-metadata`. 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.71.0).
51
+ - **`AppContext.transaction(work)` bounds store writes and start intents** — `@voltro/runtime`, `@voltro/cli`, `@voltro/testing`
52
+
53
+ AppContext requires transaction(work), which supplies a transaction-bound context for store writes and durable workflow start intents. Nested calls join the current mutation without a savepoint. Custom context implementations must provide the same boundary, not call work with the original unbound context. Callbacks may retry and must not perform external I/O. Framework and testing contexts implement the method.
54
+
55
+ Webhook-trigger transactions retain the trigger's trace and subject. Store-only transactional callbacks remain available, but do not rebind the surrounding workflow facade.
56
+
57
+ System contexts can select a tenant with transaction(work, { tenantId }) before opening the transaction. Rebind the complete context and physical placement; ordinary callers cannot switch tenants, and nested transactions cannot switch at all. This does not provide cross-database discovery or a distributed transaction.
58
+
59
+ **`voltro update` carries you across this** — codemod `0.71.0/19_app-context-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.71.0).
60
+ - **Commit workflow intent discard with its audit and caller outcome** — `@voltro/workflow`, `@voltro/cli`
61
+
62
+ Workflow intent discard now holds the resident admission lock and atomically writes its audit, settles unbound queued callers as cancelled and deletes the pending row. Failed persistence retains the queued input; competing discards cannot record two successful consumptions. Low-level `discardIntent` adapters must provide `transactional` and `insertIgnoreWithOutcome`; callers already under `withAdmissionTransaction` use `discardIntentInTransaction`. Normal inspect and cancelOn callers use the atomic operation automatically.
63
+
64
+ **`voltro update` carries you across this** — codemod `0.71.0/25_atomic-workflow-discard`. 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.71.0).
65
+ - **Event publication awaits its durable audience before fan-out** — `@voltro/runtime`, `@voltro/cli`
66
+
67
+ Event publication awaits the configured durable audience before live fan-out; failed persistence is no longer swallowed. Mutation receipts stay explicitly deferred and the post-commit drain awaits delivery. Migration: return persistence promises from custom onPublished callbacks and await the async callback passed to deferToCommit. Handle publication failures without blindly replaying already committed mutations. This acknowledges persistence, not remote delivery or workflow completion; callbacks still need an outbox for crash-safe handoff.
68
+
69
+ **`voltro update` carries you across this** — codemod `0.71.0/03_await-event-publication`. 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.71.0).
70
+ - **AI budget reservations bind to tenant, amount and window** — `@voltro/ai`, `@voltro/cli`
71
+
72
+ AI budgets use bigint micro-USD counters, validate amounts before store access, and atomically bind reservations to tenant, amount and window. Explicit reservation IDs deduplicate retries; release credits only the stored amount once. Workflow AI steps derive stable reservation IDs. Migration: run db plan/apply for the widening and receipt table; direct callers needing replay safety must persist an operation ID, consume the returned reservation (or null for check-only), and call releaseAiBudget by ID only when no bill remains to cover. Invoke outside an existing mutation transaction. Budget tables are now assembled for workflow and AI-dependent apps too.
73
+
74
+ **`voltro update` carries you across this** — codemod `0.71.0/02_bound-ai-budget-reservations`. 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.71.0).
75
+ - **URL ingestion checks the byte limit before it buffers the response** — `@voltro/plugin-storage`, `@voltro/cli`
76
+
77
+ URL ingestion checks decoded HTTP chunks against the byte limit before buffering the entire response, cancels unfinished reads on overflow or interruption, and accepts a server-side abortSignal. Without limits.maxBytes, imports now cap at 64 MiB; migration: explicitly configure a finite non-negative integer limit for larger trusted imports and provision memory accordingly. Scanning, checksum and put policies still run after bounded collection: this is not zero-copy provider streaming or an SSRF safeguard. Remote error messages no longer expose source URLs or response details.
78
+
79
+ **`voltro update` carries you across this** — codemod `0.71.0/04_bound-storage-url-ingest`. 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.71.0).
80
+ - **A cadence request ID carries tenant identity** — `@voltro/plugin-ai-flows`, `@voltro/cli`
81
+
82
+ Cadence request IDs include tenant identity and the absolute minute, so one tenant cannot suppress another tenant's code-flow run. Pass tenantId as the third argument to cronRequestId and treat its result as opaque. Coordinate deployment between cadence slots: IDs from different builds do not deduplicate submissions in the same slot.
83
+
84
+ **`voltro update` carries you across this** — codemod `0.71.0/20_cadence-tenant-identity`. 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.71.0).
85
+ - **Flow media inputs resolve uploads and earlier outputs by artifact** — `@voltro/plugin-ai-flows`
86
+
87
+ Flow media inputs now resolve uploaded URLs and earlier output references, including explicit zero-based artifact selection. Missing, skipped or invalid references fail before generation. The built-in adapter preserves image references/masks and video frame roles/references, validates its supported slots and parameters, and persists every generated image instead of only the first. The agentic media tool accepts resolved inputs and parameters too.
88
+
89
+ Migration: custom `MediaGenerator` callbacks return `{ artifacts: [{ url, mediaType, role, storageRefId? }], costMicroUsd?, requestId? }`, with exactly one `primary` role. Read variants from `useFlowRun(...).steps[index].artifacts`; `step.url` and named prompt outputs project the primary URL. Storage IDs belong in `storageRefId`, never `requestId`. `MediaPersist.put`/`ingestUrl` now return Effects, preserving host services and interruption; wrap external Promise APIs with `Effect.tryPromise`. Map storage `id` to `refId` and obtain the delivery URL via the authorized storage API, since `StorageRef` has no `url` field. Review stored definitions and adapter options during upgrade. Previously discarded variants/IDs cannot be reconstructed. This does not promise atomic multi-artifact writes, permanent signed URLs, or crash-safe external submission.
90
+
91
+ **`voltro update` carries you across this** — codemod `0.71.0/08_flow-media-inputs-and-artifacts`. 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.71.0).
92
+ - **A human review answers the workflow's computed execution ID** — `@voltro/plugin-ai-flows`, `@voltro/cli`
93
+
94
+ Human responses address the workflow's computed execution ID and the displayed review step. Missing runtime, invalid stored identity and failed delivery no longer return success. Dev and serve await both signal persistence and suspended-workflow completion. The engine projects humanResponse only after consuming the answer.
95
+
96
+ Migration: use FlowReviewInput/FlowReviewReceipt from @voltro/plugin-ai-flows/ir in host RPC schemas and declare FlowReviewDeliveryError from /errors alongside FlowRunNotFound and FlowNotAwaitingResponse. Pass reviewStepIndex from the displayed useFlowRun/useFlowReview state to respondToFlow and as the second argument to approve/reject/choose/submitText. Gate on a defined index, including zero; never guess from currentStep. waitingStepIndex refuses missing or ambiguous timeline identity. Accepted means delivery was acknowledged, not that the run finished. A delivery-unknown failure may already have taken effect: observe before retrying. This does not promise atomic signal-row/deferred storage or exactly-once response consumption.
97
+
98
+ **`voltro update` carries you across this** — codemod `0.71.0/09_flow-review-delivery`. 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.71.0).
99
+ - **Flow launch and retry return a `FlowStartReceipt`** — `@voltro/plugin-ai-flows`, `@voltro/cli`
100
+
101
+ Flow launch/retry return FlowStartReceipt { runId, requestId, admission } instead of a fabricated pending status. Both persist the observed workflow handle and retry preserves prior execution evidence. Use FlowStartReceipt from @voltro/plugin-ai-flows/ir in action output schemas, inspect admission.status, and observe run state separately. Run persistence and submission are not yet crash-atomic.
102
+
103
+ **`voltro update` carries you across this** — codemod `0.71.0/18_flow-start-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.71.0).
104
+ - **Deterministic flow steps honour `maxTokens`, `temperature` and options** — `@voltro/plugin-ai-flows`
105
+
106
+ Deterministic Flow text, agent and structured steps honor `params.maxTokens`, `temperature` and provider-keyed JSON `providerOptions`, for code and stored definitions. Invalid or unknown parameters fail before capability resolution instead of being ignored. Structured generation preserves and validates the complete JSON Schema, including local references and constraints; missing/unsupported schemas and invalid results fail rather than silently widening the accepted output.
107
+
108
+ Migration: review both code-defined flows and stored `_voltro_ai_flows.steps` for unsupported parameters and missing schemas. Remove `jsonSchemaToEffectSchema` and `JsonSchemaNode` imports: direct raw-schema generation uses `generateObjectJson({ jsonSchema, prompt })` from `@voltro/ai`, and editor validation must use the actual JSON Schema. `voltro update` supplies instructions but cannot rewrite database rows or infer intended constraints. Authored step settings do not constrain the agentic planner's independently chosen tools.
109
+ - **A policy guard may select its permission from validated input** — `@voltro/protocol`, `@voltro/runtime`, `@voltro/cli`
110
+
111
+ Policy guard actions may select an exact permission from validated input; approval serialization preserves that selected action. Scheduled Effects may require the app HttpClient, supplied in dev and serve with watchdog interruption. Migration for custom guard consumers: resolve `action` with `resolvePolicyAction(guard, input)` before using it as a string. Custom scheduler embeddings must supply `runEffect` with their service environment for service-requiring handlers; do not run those bodies as requirement-free Effects.
112
+
113
+ **`voltro update` carries you across this** — codemod `0.71.0/90_contextual-guards-and-schedules`. 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.71.0).
114
+ - **Workflow Inspect cancel and resume return an observable control receipt** — `@voltro/cli`, `@voltro/devtools-ui`
115
+
116
+ Workflow Inspect cancel and resume return WorkflowControlResult with the resident request identity and observed snapshot instead of a Boolean. Consumers must retain the receipt and distinguish accepted, confirmed, unknown, and already-terminal outcomes. Read retained receipts through GET /_voltro/inspect/workflows/control, even after run-history removal. The CLI prints the outcome and identity rather than generic success; unknown exits nonzero.
117
+
118
+ WorkflowsPage cancel/resume providers now return the same result rather than void. Supply useControlReceipt as a read-only DataSource provider to refresh an existing request. The control panel retains its last matching observation when a read fails or returns a different execution identity.
119
+
120
+ Set controlScope to the stable inspected-app identity and forward the supplied control unchanged in mutation adapters. Control identities are saved before dispatch and restored after reload independently of run filters. Storage failures prevent dispatch; workflow inputs and outputs are never persisted in the browser journal.
121
+
122
+ Each browser control reference owns a separate storage entry: saving from a stale tab inventory cannot erase another tab's reference or restore a dismissed one. Independently submitted actions are not automatically merged across tabs.
123
+
124
+ Inspect cancel/resume accept the shared control intent in the JSON body. Retain its caller-owned requestId before sending and reuse it after an uncertain response. Repeated known resume requests remain readable once running; new ones still refuse. Malformed action JSON and invalid control identities return 400 before invoking the action.
125
+
126
+ **`voltro update` carries you across this** — codemod `0.71.0/26_inspect-workflow-control-receipt`. 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.71.0).
127
+ - **A JSON column write encodes a string as a value, not as SQL input** — `@voltro/database`, `@voltro/cli`
128
+
129
+ JSON column writes encode strings as values instead of treating them as pre-serialized SQL input. This prevents plain workflow string results from failing PostgreSQL JSON parsing and JSON-looking strings from changing type. Pass JavaScript values directly to json() columns; remove pre-stringification only at these writes, not text columns or raw SQL. Existing stored values are not rewritten.
130
+
131
+ **`voltro update` carries you across this** — codemod `0.71.0/17_json-string-values`. 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.71.0).
132
+ - **Image and video generation carry references, masks and cancellation** — `@voltro/ai`
133
+
134
+ Image generation accepts references and masks and preserves grounding per provider call and per image. Video inputs retain frame roles and media types, with explicit audio control. Image, speech and video propagate external and Effect cancellation to provider I/O, including registered speech adapters; interruption is not a promise of remote cancellation. Migration: hand-authored image result fixtures must supply calls and preserve each call's metadata instead of flattening attribution.
135
+
136
+ **`voltro update` carries you across this** — codemod `0.71.0/01_ai-generation-contracts`. 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.71.0).
137
+ - **`insertIgnoreWithOutcome` reports the native insert decision** — `@voltro/database`, `@voltro/runtime`, `@voltro/sql-postgres`, `@voltro/sql-mysql`, `@voltro/sql-mssql`, `@voltro/sql-sqlite`, `@voltro/sql-turso`, `@voltro/cli`
138
+
139
+ `DataStore.insertIgnoreWithOutcome` and the corresponding typed-store adapter method are required and report the native insert decision, including on raw startup stores, namespace stores and transactions. Concurrent calls sharing a caller-supplied ID no longer both report `inserted`; generated IDs return the stored row. MySQL preserves insert metadata and warnings, and MSSQL recognizes duplicate-key failures inside Effect causes. Migration: custom store adapters must implement `{ row, outcome: 'inserted' | 'ignored' }` from their atomic write result and forward it through their wrappers, preserving codecs, attribution and commit-scoped events. Never infer success from a pre-read or ID equality. Plain `insertIgnore` callers need no change; transaction outcomes remain provisional until commit. `voltro update` prints the adapter checklist.
140
+ - **An outbox handler's Effect runs with app and plugin services** — `@voltro/runtime`, `@voltro/cli`
141
+
142
+ Outbox handlers execute returned Effects with app/plugin services in dev and serve, await scoped finalizers, and retry failures instead of recording the Effect object as delivered. Handler contexts now include stable `outboxId` and nullable enqueue `idempotencyKey`: add both to manually constructed contexts. Low-level `drainOutbox` callers with service requirements must provide `runEffect`; return Effects from app handlers instead of starting a nested runtime. Queued identity does not grant request authority or an unscoped EffectStore.
143
+
144
+ **`voltro update` carries you across this** — codemod `0.71.0/05_outbox-effect-environment`. 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.71.0).
145
+ - **Durable events go to the store the request already selected** — `@voltro/runtime`, `@voltro/cli`
146
+
147
+ Deliver durable events through the physical store and namespace already selected for the request, never the primary database or a closed transaction. Custom lifecycle adapters delivering these events must supply committedStore with that selected nontransactional store; missing ownership refuses context construction. Framework mutation runners supply it automatically. This does not make after-commit callbacks durable across process death.
148
+
149
+ **`voltro update` carries you across this** — codemod `0.71.0/19_app-context-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.71.0).
150
+ - **External schedule triggers move endpoint, credential and response shape** — `@voltro/devtools-ui`, `@voltro/cli`, `@voltro/runtime`
151
+
152
+ Four framework-observable changes land together, and each one is a break a consumer can be standing on. The external trigger endpoint is `POST /_voltro/schedules/:name/trigger`; the old `/_voltro/schedule/<name>/fire` was never served by any release, so anything pointing at it was already returning 404 — regenerate the manifests. It is gated by `VOLTRO_SCHEDULE_TRIGGER_TOKEN` rather than the inspect pair, so a cron pod no longer carries a credential that also authorises irreversible data erasure; `VOLTRO_SCHEDULE_TOKEN` was read by nothing and can be deleted. The response is now `202` with a run id, or `200` for a redelivery the app deduplicated, and the run is polled to completion — a caller that read the old reply as "the run finished" must poll `GET /_voltro/schedules/runs/:runId` until `terminal`. And `ScheduleRunStatus` gained `queued`, so an exhaustive switch over it needs that arm; the shipped dashboards have it.
153
+
154
+ **`voltro update` carries you across this** — codemod `0.71.0/24_external-schedule-trigger`. 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.71.0).
155
+ - **A structured decode failure keeps its model, usage and cost** — `@voltro/ai`
156
+
157
+ Preserve normalized provider/model, token completeness and available gateway cost on structured results and decode failures, without rejected values, responses, headers or SDK errors. Tool-loop output getter failures stay typed and usage covers all steps; last-step cost is not reported as the whole loop's charge. Evidence is not a durable per-attempt ledger. Migration: hand-authored structured result fixtures must provide evidence; read error.evidence instead of parsing decode messages.
158
+
159
+ **`voltro update` carries you across this** — codemod `0.71.0/01_ai-generation-contracts`. 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.71.0).
160
+ - **`generateText` takes a `messages` history and returns `sources`** — `@voltro/ai`
161
+
162
+ `generateText` accepts a complete `messages` history instead of `prompt` and returns cited `sources`. Temperature reaches text, structured, tool-loop and streaming calls, including zero. Conversation calls bypass the single-prompt semantic cache so another history's answer is not reused. Migration: adapters must distinguish prompt from messages; hand-authored result fixtures must supply sources.
163
+
164
+ **`voltro update` carries you across this** — codemod `0.71.0/01_ai-generation-contracts`. 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.71.0).
165
+ - **A mutation's workflow start is an intent in the business transaction** — `@voltro/runtime`, `@voltro/cli`, `@voltro/workflow`
166
+
167
+ Mutation workflow starts persist an admission intent in the business transaction instead of keeping only an in-process post-commit callback. Their handle is queued with deferral.mode=transaction and executionId=null, provisional until commit. Rollback and abandoned transaction retries remove the intent; the existing admission drainer discovers committed starts after restart and evaluates declared controls then. Caller and parent metadata travel with the intent.
168
+
169
+ Admission queues now persist schema-encoded payloads and decode before evaluating controls or starting an execution, including each item of a batch. DateFromString/NumberFromString survive storage and restart. Invalid stored payloads are isolated as dead letters with diagnostics that omit their values.
170
+
171
+ Migration: handle queued mutation receipts explicitly; do not interpret their id as an execution ID or report running before admission. Inspect pending starts using voltro workflows flow. No new user outbox handler is required. Low-level embedders replace makePostCommitWorkflowFacade with makeTransactionalWorkflowFacade(base, enqueueStart); enqueueStart receives an already-encoded payload, must write using the same transaction without encoding again, and return intentId/dueAt. Direct AdmissionDataStore hosts with transforming schemas supply encodeWorkflowPayload/decodeWorkflowPayload; absent codecs mean JSON-only values. WorkflowPayloadDiagnostic implementations now provide encode/decode as well as validate. Engine invocation and external side effects remain outside the business transaction; delivery is not an exactly-once guarantee.
172
+
173
+ **`voltro update` carries you across this** — codemod `0.71.0/10_transactional-workflow-starts`. 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.71.0).
174
+ - **An execution ID keeps its first caller identity and parent binding** — `@voltro/runtime`, `@voltro/cli`
175
+
176
+ Workflow execution IDs keep their first caller identity and parent binding through a database unique-key claim. Same-owner retries preserve the original trace/source; conflicting workflow, tenant, user, metadata or parent bindings refuse engine submission. Context reads no longer use a process cache. Missing rows, null identities and malformed data refuse execution instead of selecting system authority; requestless framework starts persist explicit system identity.
177
+
178
+ Migration: remove VOLTRO_WORKFLOW_START_CONTEXTS_TTL_HOURS and custom cleanup policies for _voltro_workflow_start_contexts. These rows are ownership, not run history, and have no automatic TTL: the engine can remember an execution after its history row is gone. Storage grows by one binding per execution; coordinated engine-and-binding cleanup is not provided. Back up and restore bindings together with engine state. Repair missing rows only from verified original identity data, never by assigning system identity. Low-level resolveStartContext implementations must return explicit identity, including for system starts; hosts deliberately omitting the resolver keep their system-only mode. Inspect workflow.startContext.persist failed and workflow.startContext.resolve failed by workflow/execution ID.
179
+
180
+ **`voltro update` carries you across this** — codemod `0.71.0/11_workflow-context-ownership`. 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.71.0).
181
+ - **`readControl(...)` observes a receipt without submitting another** — `@voltro/runtime`, `@voltro/cli`
182
+
183
+ Workflow facades expose `readControl({ operation, workflowName, executionId, requestId })` to observe the original resident receipt without submitting another control. Custom WorkflowsAppContext implementations must implement this method or explicitly refuse unsupported observation. Custom hosts supply the readControl adapter; built-in host integration remains pending.
184
+
185
+ **`voltro update` carries you across this** — codemod `0.71.0/16_workflow-control-results`. 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.71.0).
186
+ - **Cancel and resume return a control result, not a manufactured snapshot** — `@voltro/runtime`
187
+
188
+ Workflow facade cancel/resume return a control result with kind, operation, requestId and snapshot instead of manufacturing cancelled/running snapshots. Read execution state from snapshot; unknown is not confirmation, and already-terminal retains the original output. Engine-only control does not claim durable acceptance. A completed interruption-only engine exit is classified as cancelled by poll/query/wait. Durable control delivery and other cancellation writers remain under integration.
189
+
190
+ **`voltro update` carries you across this** — codemod `0.71.0/16_workflow-control-results`. 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.71.0).
191
+ - **Replace deferred-result rejection with workflow receipt observation** — `@voltro/runtime`, `@voltro/voltro`, `@voltro/cli`, `@voltro/workflow`
192
+
193
+ Remove `WorkflowDeferredResultError`: blocking workflow calls now follow their durable caller receipts. Remove imports and catch branches for this class; do not mechanically rename them. `WorkflowStartNotExecuted` represents a settled non-execution outcome, not a pending start. Observe accepted executions without another dispatch, preserving their original result or failure. For bounded observation use `start()` followed by `wait(handle, { timeoutMs })`; mutation-transaction waits remain refused until after commit.
194
+
195
+ **`voltro update` carries you across this** — codemod `0.71.0/21_workflow-blocking-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.71.0).
196
+ - **A direct start persists its submission before dispatch** — `@voltro/workflow`, `@voltro/runtime`, `@voltro/cli`
197
+
198
+ Direct controlled starts now persist their submission and transfer earlier batch caller receipts in the admission transaction before engine dispatch. A size-closing arrival no longer strands the preceding callers. Blocking run/start confirm durable acceptance before joining the prepared body result. Migration: custom WorkflowAdmissionOutcome start adapters must provide accept(): Promise<string> instead of commit(executionId), returning only an acknowledged execution identity; use takeAdmittedSubmissionInTransaction and acceptAdmissionSubmission rather than reserving or executing twice. Uncontrolled starts and durable terminal-body reporting remain separate paths.
199
+
200
+ If recovery accepts a submission before its original caller dispatches, that caller reuses the resident prepared identity and acknowledgement rather than reporting a false failure or evaluating the application key again. Direct batch probes cover encoded items, transaction rollback, lost engine replies and this recovery interleaving.
201
+
202
+ **`voltro update` carries you across this** — codemod `0.71.0/13_workflow-start-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.71.0).
203
+ - **An executing body interrupts its own fiber on a control request** — `@voltro/workflow`
204
+
205
+ Executing workflow bodies observe resident cancel/terminate requests for their own generation and interrupt their own fiber, without addressing a replacement generation through the engine. Custom execution recording stores must provide query. The monitor performs one sequential read followed by a one-second pause while idle and stops with the body; read failures retry without claiming cancellation. Control producers, suspended-run delivery and durable result receipts remain under integration.
206
+
207
+ **`voltro update` carries you across this** — codemod `0.71.0/15_workflow-recorder-entry-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.71.0).
208
+ - **Journal replay resolves every activity before it resets a reply** — `@voltro/workflow`, `@voltro/cli`
209
+
210
+ Journal replay resolves every selected activity before resetting any replies, refuses missing activities, and stops when an activity or run reset is unconfirmed. It no longer reports redriven after a failed reset, or translates failed reads into an absent journal. RedriveSkipReason is renamed RedriveReason: reset-unconfirmed reasons may follow partial writes, so false redriven is not proof of no mutation. Reconcile journal/execution state before another attempt; confirmed reset counts are not an atomic replay guarantee.
211
+
212
+ Unknown result variants and undecodable encoded exits now refuse with unrecognized-reply before resetting anything. Suspended runs refuse with suspended; use engine resume instead of failed-run replay.
213
+
214
+ **`voltro update` carries you across this** — codemod `0.71.0/14_workflow-redrive-outcomes`. 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.71.0).
215
+ - **`closeWorkflowChildrenForParent` gives way to a resident intent** — `@voltro/workflow`, `@voltro/cli`
216
+
217
+ Remove `closeWorkflowChildrenForParent` and its options, decision, store, emitter and logger types. Terminal recording creates a resident parent-closure intent; the resident drain stages generation-bound control receipts, and recorded body completion confirms cancellation. Custom hosts must include the resident tables and run `drainTick` against the same store instead of writing child terminal state after an engine interrupt. `WorkflowParentCloseStatus` remains the recording observer's status type.
218
+
219
+ **`voltro update` carries you across this** — codemod `0.71.0/15_workflow-recorder-entry-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.71.0).
220
+ - **`parentGeneration` survives queueing, storage and both boot paths** — `@voltro/workflow`, `@voltro/runtime`, `@voltro/cli`
221
+
222
+ Preserve parentGeneration through child caller metadata, queued context decoding, durable start-context storage and both recording boot paths. Build each application context after committed body entry, binding that exact generation to its child facade and event-triggered starts even for Promise-based calls. A retry cannot replace an execution's parent generation. Child entry atomically cancels cancel/terminate children of a closed parent generation before executing their body. Custom hosts supplying an explicit parentExecutionId must also supply its original parentGeneration; missing bindings are refused. Built-in hosts capture it automatically. Recovery of children that entered earlier remains unfinished.
223
+
224
+ **`voltro update` carries you across this** — codemod `0.71.0/15_workflow-recorder-entry-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.71.0).
225
+ - **A queued submission binds its engine identity before dispatch** — `@voltro/workflow`, `@voltro/cli`
226
+
227
+ Controlled queued submissions and recovery bind their declared engine identity before dispatch and reuse it after lost acknowledgement. Custom AdmissionDrainDeps adapters must implement resolveExecutionId without executing work and honor the fourth preparedExecutionId argument to startAdmitted. Existing unbound dispatches require reconciliation rather than guessing a new execution. Durable onFailure delivery remains unfinished.
228
+
229
+ **`voltro update` carries you across this** — codemod `0.71.0/13_workflow-start-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.71.0).
230
+ - **Separate queued workflow cancellation from execution controls** — `@voltro/runtime`, `@voltro/cli`
231
+
232
+ `WorkflowsAppContext` requires `cancelQueued` and `readQueuedCancellation` for `{ workflowName, receiptId, requestId }` targets. Custom implementations must implement or explicitly refuse them; custom runtime hosts supply separate resident adapters returning the shared queued-control result. Caller-bound and lazy facades forward both methods, transaction facades refuse until commit, and mismatched responses fail without an engine fallback. Both built-in hosts install resident adapters that retain request identity after source receipt removal. Automatic recovery remains incomplete; acceptance does not confirm a running execution has stopped.
233
+
234
+ **`voltro update` carries you across this** — codemod `0.71.0/23_workflow-queued-control`. 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.71.0).
235
+ - **Expose queued workflow cancellation over RPC** — `@voltro/protocol`, `@voltro/cli`, `@voltro/client`
236
+
237
+ Generate and bind queued cancellation and receipt-observation procedures in both API hosts. Targets use stable start receipt and request identities without requiring a run; caller identity comes from the authenticated request, not payload fields. Regenerate client RPC groups to use useWorkflow.cancelQueued and readQueuedCancellation. Custom WorkflowState implementations must provide these methods. Acceptance remains distinct from confirmed cancellation.
238
+
239
+ Foreign or missing source ownership is a typed Unauthenticated failure, not an RPC defect. Retained observation remains available to the original tenant after source receipt cleanup.
240
+
241
+ **`voltro update` carries you across this** — codemod `0.71.0/23_workflow-queued-control`. 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.71.0).
242
+ - **Wait follows a queued receipt to confirmed engine execution** — `@voltro/runtime`, `@voltro/cli`
243
+
244
+ Workflow wait follows a queued caller receipt to confirmed engine execution on both boot paths, preserving one timeout budget and the bound caller. Non-executed starts throw WorkflowStartNotExecuted instead of fabricating a snapshot. Migration: handle dropped/skipped/superseded/abandoned/rejected explicitly; deliberately wait for a skipped incumbent using the workflow-name/execution-ID overload. Both wait overloads now refuse inside mutation transactions: wait after commit. Custom runtime hosts must supply resolveQueuedStart against the authorized resident admission store. Timeout bounds polling, not an already-running database or engine read.
245
+
246
+ **`voltro update` carries you across this** — codemod `0.71.0/13_workflow-start-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.71.0).
247
+ - **A replaced debounce payload settles its receipts as superseded** — `@voltro/workflow`
248
+
249
+ Receipt terminal outcomes also include superseded: replacing a debounce payload settles its still-pending receipts in the same transaction. A later execution cannot acknowledge the overwritten inputs. Durable failure-report recovery retries the superseded report with backoff, preserving both original reports and receipt history.
250
+
251
+ WorkflowStartReceipt now requires terminal, initially null. Delayed and controlled queue drops/skips persist the caller outcome atomically with admission history and source deletion; settled receipts cannot be rebound to later work. A skipped execution ID names the incumbent, not a new run. Use recordWorkflowStartReceipt when constructing receipts and handle terminal outcomes when reading them; the update codemod flags custom receipt adapters. Operator-discard/timeout outcomes and waiting on queued handles remain unfinished.
252
+
253
+ **`voltro update` carries you across this** — codemod `0.71.0/13_workflow-start-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.71.0).
254
+ - **An interruption is cancelled only once the body returns** — `@voltro/workflow`, `@voltro/runtime`
255
+
256
+ Record an engine-requested interruption as cancelled only after the body returns an interruption-only outcome; retain capacity while it is still running. Confirmed cancellation uses the generation-fenced terminal transaction, emits cancellation observations and does not start onFailure. Low-level onTerminal and recordRun callbacks must accept the cancelled status; genuine handler failures remain failed even when cancellation was requested.
257
+
258
+ **`voltro update` carries you across this** — codemod `0.71.0/15_workflow-recorder-entry-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.71.0).
259
+ - **Execution recording commits the entry generation before app code** — `@voltro/workflow`, `@voltro/cli`
260
+
261
+ Workflow execution recording requires a resident `transaction` callback and commits the run's entry generation before invoking application code. Failed entry commits prevent body execution instead of silently disabling run recording. Body completion checks the generation before recording its outcome, durable failure arrival and lease release, including dispatched prepared submissions before ACK; superseded results cannot publish another terminal hook. Custom `wrapWorkflowExecuteWithRunRecording` hosts must pass `transaction: work => withAdmissionTransaction(store, work)` with the same resident store used for recording. Configure `failureContext: { handler, callerContext, tenantId }` for durable failure delivery and remove lease-release/handler-start writes from `onTerminal`, which is now an after-commit observer. Built-in dev and serve hosts supply the bound identity without cached authority. Notification-body failure is recorded without recursively notifying, including before dispatcher ACK. Step observations retain their separate recording contract. Parent-close delivery and non-body exits remain separate lifecycle work.
262
+
263
+ **`voltro update` carries you across this** — codemod `0.71.0/15_workflow-recorder-entry-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.71.0).
264
+ - **A proven pre-engine rejection queues its report atomically** — `@voltro/workflow`, `@voltro/cli`
265
+
266
+ Proven pre-engine submission rejection now queues the original onFailure report and caller receipt atomically with source marking and lease release. Retry reuses the same handoff; failed receipt writes roll the transaction back. Custom AdmissionDataStore and transaction adapters must forward native insertIgnoreWithOutcome, not emulate it with a read or upsert. Handler acceptance reconciliation, retry after handler drop/skip, report backlog recovery and other failure sources remain unfinished.
267
+
268
+ **`voltro update` carries you across this** — codemod `0.71.0/13_workflow-start-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.71.0).
269
+ - **A removed queue declaration abandons its receipts with a report** — `@voltro/workflow`, `@voltro/cli`
270
+
271
+ Removed controlled or batched queue declarations now preserve each input and caller in a resident failure report and terminalize receipts with `{ kind: 'abandoned', at, failureId }` atomically with queue removal. Handle this new receipt variant rather than waiting for an execution that will not exist. These reports have no current handler; removal no longer calls an ephemeral onAbandoned callback or silently discards a batch. Start-timeout and other failure paths are not claimed by this change.
272
+
273
+ **`voltro update` carries you across this** — codemod `0.71.0/13_workflow-start-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.71.0).
274
+ - **Cancel and resume return `WorkflowControlResult`, not a boolean** — `@voltro/protocol`, `@voltro/client`, `@voltro/web`, `@voltro/cli`
275
+
276
+ Workflow cancel/resume RPCs and useWorkflow callbacks return WorkflowControlResult instead of a boolean acknowledgement or void. Both server boot paths preserve the facade observation; cancel no longer independently overwrites the run as cancelled or emits a fabricated terminal event. Rebuild clients with the server and read kind/snapshot before reporting success. Inspect control responses and durable recovery remain separate work.
277
+
278
+ **`voltro update` carries you across this** — codemod `0.71.0/16_workflow-control-results`. 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.71.0).
279
+ - **A queued handle's `id` is a durable caller receipt** — `@voltro/runtime`, `@voltro/cli`, `@voltro/workflow`
280
+
281
+ Queued workflow handles now identify a durable caller receipt in id; deferral.intentId remains the reusable pending queue slot. Direct admission, delayed starts and mutation-enqueued starts persist the receipt atomically with the pending write. Independent debounce callers and later bursts no longer share a caller identity. Migration: use deferral.intentId for queue inspection, and do not interpret either ID as an executionId while queued. Custom WorkflowStartEnqueue, parkDelayedStart and queued WorkflowAdmissionOutcome adapters must persist and return receiptId as well as intentId/dueAt. Mutation receipts remain provisional until commit. This identity change does not yet provide the pending-to-execution waiting protocol.
282
+
283
+ AdmissionDrainDeps requires transaction(work), serialized with direct arrivals through withAdmissionTransaction on the resident store. The callback performs only database work and may replay; preserve the host payload codecs and never invoke the engine inside it. Ordinary controlled drains reserve the lease and transfer queue/receipts before engine submission. Delayed arrivals and the submission failure lifecycle are still being integrated. Unacknowledged submissions now stop automatic retries at deadLetterAfterAttempts with durable retry.stoppedAt, without claiming non-execution or emitting onFailure. Their concurrency reservations do not expire through the 24-hour backstop while acceptance remains unknown. Changing the attempt limit does not restart a stopped record. Later proven engine acceptance remains acknowledgeable. Submission reconciliation/inspect and permanent failure handoff remain unfinished; pending-intent retry commands do not reconcile submissions. Singleton skip/cancel now queues arrivals while an existing reservation has unknown engine acceptance, using deferral mode singleton and the real singleton key. Unconfirmed reservations do not disappear at the lease backstop; confirmed incumbents retain skip/cancel behavior. Low-level AdmissionState readers must distinguish singletonUnacknowledged from an absent holder.
284
+
285
+ AdmissionSubmission now includes dispatchAttempts and failureId. Dispatchers persist their attempt before engine I/O; custom isPermanentStartError classifiers must guarantee pre-engine input rejection, not merely non-retryability. Only a proven rejection with no earlier ambiguous dispatch can atomically release its lease and link an immutable failure report. A later schema error does not prove an earlier call never executed. Report delivery remains unfinished.
286
+
287
+ **`voltro update` carries you across this** — codemod `0.71.0/13_workflow-start-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.71.0).
288
+ - **A queued start-timeout preserves its payload and receipts** — `@voltro/workflow`, `@voltro/cli`
289
+
290
+ Queued start-timeouts now preserve admission history, the full failure payload, terminal receipts and the failure-handler arrival atomically with removing the inputs. Custom onAbandoned callbacks no longer own these notifications; consume the durable failure report and its abandoned receipt outcome. Expired notification calls do not recursively start another failure handler. Mixed notification/ordinary batches retain their inputs for reconciliation. Removed delayed workflows also use the resident failure/receipt path.
291
+
292
+ **`voltro update` carries you across this** — codemod `0.71.0/13_workflow-start-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.71.0).
293
+ - **A suspended generation settles cancellation before replacement** — `@voltro/workflow`
294
+
295
+ Settle a suspended generation's resident cancellation before replacing its owner or invoking application code. Run, control receipts and capacity release commit together; repeated entry cannot revive a cancelled execution. Custom entry hosts must handle cancelled and already-cancelled without running the body, and emit observations only for the first transition. This does not yet provide automatic engine wakeup or resume delivery.
296
+
297
+ **`voltro update` carries you across this** — codemod `0.71.0/15_workflow-recorder-entry-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.71.0).
298
+ - **Terminal run events commit with the generation-owned transition** — `@voltro/workflow`
299
+
300
+ Commit run-succeeded/run-failed/run-cancelled events atomically with the generation-owned run transition, control receipts and capacity release, including version-incompatible entry failures. Custom execution-recording hosts must include the event table in their resident schema; terminal events no longer go through custom recorder.recordEvent. Use emit/onTerminal for post-commit observation. Failed event writes roll back completion; replay does not duplicate the event.
301
+
302
+ **`voltro update` carries you across this** — codemod `0.71.0/15_workflow-recorder-entry-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.71.0).
303
+ - **Report observed workflow completion on waited handles** — `@voltro/protocol`, `@voltro/runtime`, `@voltro/cli`, `@voltro/voltro`
304
+
305
+ `WorkflowRunHandle.status` includes `succeeded`. Successful blocking starts and children return this observed status instead of `running`; replayed blocking starts also observe the original result and propagate its failure. Non-blocking starts retain their admission statuses. Update exhaustive status checks and custom wire schemas; rebuild generated clients and servers together. Use poll/wait for current execution state rather than treating an admission handle as a live status probe.
306
+
307
+ **`voltro update` carries you across this** — codemod `0.71.0/22_workflow-waited-handle-status`. 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.71.0).
308
+
309
+ ### Added
310
+
311
+ - **The trigger endpoint answers immediately and the run is polled to completion** — `@voltro/runtime`, `@voltro/cli`
312
+
313
+ `POST /_voltro/schedules/:name/trigger` answers `202` as soon as the run row exists, and `GET /_voltro/schedules/runs/:runId` reports the run with a `terminal` flag the caller stops on. The generated CronJob polls and exits with the run's outcome. Both alternatives defeat the reason a cron is made a cluster object at all: holding the request for the handler puts a 30-minute `maxRuntimeMs` behind a 60-second ingress timeout, and a fire-and-forget POST makes every Job green whatever the handler did, so the Job status an operator's alerting watches would carry no information. A `skipped` run exits zero — that is the overlap policy working, not a failure.
314
+ - **A dependency-free runtime for a generated TypeScript client** — `@voltro/api-client`, `@voltro/api-client-effect`, `@voltro/client`
315
+
316
+ `@voltro/api-client` — the runtime of a generated TypeScript client for a public API, with no dependencies: not on a schema library, not on Effect. It speaks the REST surface profile-aware from the wire shapes in the manifest: RFC 3339 or epoch dates revived to `Date` at exactly the declared fields, Problem Details or tagged errors decoded into the app's own error classes (`instanceof AccessDeniedError`, `_tag` narrows, `status` and `requestId` ride along), the envelope unwrapped, `Link: …; rel="next"` or a body cursor walked by `pages()`, snake naming folded back, `ETag`/`If-None-Match` answered from a cache. It carries a bearer or a token function (rotation without a restart), `x-tenant`, `traceparent`, a `User-Agent` that names the generated package, an `Idempotency-Key` (UUID v7) on every idempotent mutation, an `AbortSignal`, `x-request-id` on every failure; a `429` is a `RateLimitedError` with the wait, or — opted in with a ceiling — a wait and a retry; `Sunset` warns once per tag; a response naming another profile (`x-voltro-rest-profile`) is refused on the first call. Live queries ride the rpc socket through a client of the socket protocol spoken directly — the request with the auth headers, every chunk acknowledged, `Ping`/`Pong`, a reconnect with backoff that re-issues every open subscription with `voltro-resume-from`, the snapshot held and every delta applied to it, an in-band error as the app's class — so a package generated for a Node service has live queries with no dependency either. `createMockClient(manifest, errors)` answers the same namespaces without an API — typed data, typed errors, pages, pushes.
317
+
318
+ `@voltro/api-client-effect` — the Effect flavor over that same transport: `ApiClient` as a service with `layer`, a call an `Effect` whose error channel is the app's own error classes, pages and live queries as `Stream`s, `layerOver(mock)` for a test. One transport, lifted; nothing re-implemented.
319
+
320
+ `@voltro/client/runtime` exposes the browser client's protocol core — the rpc client over a WebSocket, its supervisor, the subscription cache, the error bus — as a React-free subpath, and `react` is now an optional peer of `@voltro/client`.
321
+ - **`publicApi.resource` derives a projection's method and path** — `@voltro/protocol`
322
+
323
+ `publicApi: { resource: 'teams' }` derives method and path from the resource, the descriptor's kind and target, and whether the input identifies one row — the convention `crud.*` already follows, opt-in per descriptor: a query whose input carries `teamId` (or `id`) → `GET /v1/teams/{teamId}`, a list → `GET /v1/teams`; a mutation whose `target.op` is `insert` → `POST /v1/teams`, `update` → `PATCH /v1/teams/{teamId}`, `delete` → `DELETE /v1/teams/{teamId}`; an action, or a mutation without a target, → `POST /v1/teams/{teamId}/<verb>`. An explicit `method` or `path` wins. A surface of hundreds of projections is no longer hundreds of hand-written paths.
324
+ - **An external schedule nobody triggered is recorded as missed** — `@voltro/runtime`, `@voltro/cli`
325
+
326
+ In `external` mode the app arms no timer, so a platform scheduler that stops firing produces silence — which on a dashboard is indistinguishable from a schedule with nothing to do. A coarse reconciliation now writes unfired occurrences to the ledger as `missed` and counts them in `voltro_schedule_external_missed_total`. It claims each occurrence exactly as a real trigger would, so a very late delivery is absorbed rather than running work the ledger has already written off, and it stops short of the present by the skew budget so it cannot race an arrival still in flight. `scheduling.externalMissedPolicy` defaults to `'record'` — writing it down, not running it — because the premise of this mode is that the platform owns the clock; `'record-and-run'` and `'off'` are the other two. Arrivals are counted by decision in `voltro_schedule_external_triggers_total`.
327
+ - **A template for a public API and the client a partner programs against** — `@voltro/cli`
328
+
329
+ A template for the surface a partner programs against: `voltro create-project --template api-public` scaffolds three procedures that project onto REST under `/v1` through `publicApi` — a paginated list, a single read answering a typed `404`, and a scope-guarded idempotent write — the `standard` profile, an OpenAPI document and Swagger UI from the same descriptors, and the generated TypeScript client declared in `publicApi.client` and kept written by `voltro dev`. It ships the wire schema and the typed error in a `lib/` module the descriptors import (the browser-safe shape), a dev-only key so the guarded write works on the first boot, and tests that measure both halves: the executors against the in-memory store (create → read, the typed refusal, keyset pagination) and the REST projection read off the descriptors (method, path, status, the scope). Boots with no infrastructure.
330
+ - **`voltro build api --target typescript` generates a publishable client** — `@voltro/cli`, `@voltro/api-client`, `@voltro/api-client-effect`
331
+
332
+ `voltro build api --target typescript` generates a standalone, publishable npm package for the `publicApi` surface — for Node, Bun, Deno and the browser — from the same bindings the Swift and Kotlin targets read. The generator walks the app's own `effect/Schema` values once and writes what it learned as data: the wire shapes (which fields are dates and how the schema puts them on the socket, which keys a snake_case profile renames, what every declared error carries) into `manifest.ts`, the types into `types.ts`, the app's errors as classes into `errors.ts` — so the package imports nothing from the app and publishes as it is (`pnpm build && pnpm publish`). Three flavors, `--flavor plain | effect | both` (default `both`): `.` is the Promise client and pulls in no Effect at all, `./effect` the Effect service (`effect` and `@voltro/api-client-effect` optional peers, installed only by a consumer who imports it). Per-tag namespaces typed from the wire shapes (`api.teams.getById({ teamId })`; an input with no required field is an optional parameter), `.pages()` on every call, `.subscribe()` on every query; `--version v2` for one API version, `--base-url`, `--name`, `--package-version`, `--license`; ESM + CJS + `.d.ts` from the package's own build. The manifest carries the surface digest the package was built from.
333
+
334
+ A build says what the client cannot decode: a schema kind with no JSON form (a `bigint`, a symbol, an opaque declaration) or an un-narrowable union becomes a raw JSON passthrough, and every such shape is named by tag and path — so a date under one of them is not discovered as a string by the consumer instead. A surface that maps completely prints nothing.
335
+
336
+ Every model carries the name the app exports its schema under — including a schema shared from a `lib/` module the descriptors import, which is the browser-safe shape the framework asks for — so `ReadonlyArray<Team>` reads the way the app reads.
337
+
338
+ The decision is the app's: `publicApi.client` in `app.config.ts` names the package, its place and its flavor and is what a build starts from; `artifacts: ['typescript']` lets `voltro dev` keep the package written beside `rpcGroup.generated.ts`. `voltro api-client show | set | build` reads, edits (through the TypeScript AST, so a hand-written config keeps its shape) and generates from that decision; a flag on `build` overrides a field for one run.
339
+ - **`voltro check --public-api` names what a change breaks for a consumer** — `@voltro/cli`, `@voltro/protocol`, `@voltro/plugin-openapi`
340
+
341
+ `voltro check --public-api --against <spec-url|file>` says what a change would break for a consumer holding a client generated from the published spec: it generates this tree's OpenAPI document the way the plugin serves it, compares the surface digests, and names every removal or narrowing — a path, an operation, a response property, a changed type, a newly required input or parameter, a dropped tag, a removed enum value; additions are listed and never fail. The OpenAPI document now publishes `x-voltro-surface-digest`, the digest of the projected surface (every projection's facts and the shape of its wire schemas) — the same one a generated TypeScript client carries — so one number says whether a client still describes the API it calls.
342
+ - **API-client collections for Postman, Bruno, Insomnia and Hoppscotch** — `@voltro/cli`, `@voltro/protocol`, `@voltro/plugin-openapi`
343
+
344
+ `voltro build api --target postman | bruno | insomnia | hoppscotch` writes an API-client collection from the same pipeline that generates the SDKs — one model, four serializers: a folder per tag, a request per projection with `summary` as its name and `description` as its docs, path parameters and query parameters with their example values, the body from `publicApi.example` (or the derived one, marked as such in the docs), a saved response per `errorStatus` with the Problem Details the API answers with, the bearer on the collection (`{{apiKey}}`), `Idempotency-Key` on every idempotent mutation, one environment file per `servers` entry with `baseUrl` and a secret `apiKey`, `apiVersion` as a variable, and a test per request that asserts what the spec cannot say — a 2xx decodes against the response schema (Postman), an error is `application/problem+json` with a `urn:voltro:error:` type and a matching `status`, a 429 carries `Retry-After` and `RateLimit-Reset`, a GET carries an `ETag`. `newman run` / `bru run` against an environment is the smoke test against a running instance.
345
+
346
+ `publicApi: { artifacts: ['openapi', 'postman', 'bruno'] }` has `voltro dev` write `openapi.generated.json` and the collections beside `rpcGroup.generated.ts` on every boot, only where the content changed — committed, generated files, so a review diff shows the surface change and a collection imported once cannot drift. The spec now carries an example on every `errorStatus` response and on every path and query parameter an authored request example names, and a projection's `idempotent` rides on its `RestRouteProjection`.
347
+ - **voltro schedule-manifest --check, and a trigger verb for humans** — `@voltro/cli`
348
+
349
+ `voltro schedule-manifest --check` fails when the committed manifests no longer match the schedules declared in code — the drift this mode is exposed to and the one nobody notices, because a CronJob firing yesterday's cadence looks entirely healthy. `voltro schedule trigger <name>` drives an external schedule through the same endpoint and the same credential a CronJob uses, with `--await` to poll the run and exit with its outcome.
350
+ - **Inspect AI run details on demand** — `@voltro/plugin-ai-flows`, `@voltro/devtools-ui`
351
+
352
+ AI run lists load newest-first summaries without payloads. Opening a run loads its input, output, steps, workflow admission and recorded errors in both dashboards; missing runs are reported explicitly. Recorded executions link to the filtered workflow view while preserving replica selection.
353
+ - **Authorize native dashboard file downloads** — `@voltro/cli`
354
+
355
+ Local dashboards can exchange an authorized storage-download target for a session-bound, single-use URL. The handoff expires after 30 seconds, preserves streaming, and keeps inspect credentials out of browser URLs.
356
+ - **Retry dead reverse-ETL deliveries from the dashboard** — `@voltro/plugin-cdc-out`, `@voltro/devtools-ui`
357
+
358
+ Both dashboards offer confirmed retry for dead deliveries with inspect write access. An atomic transition preserves the delivery key and payload, excludes active or completed deliveries, and scopes reads and retries to the selected plugin instance. Errors and unchanged outcomes are visible.
359
+ - **Search and page the complete comment thread inventory** — `@voltro/devtools-ui`, `@voltro/plugin-comments`
360
+
361
+ Both dashboards search comment threads by anchor, ID or author across the complete store and page beyond the newest fifty threads. Result counts remain separate from global statistics; search and page changes discard mismatched previous results.
362
+ - **Inspect comment thread contents on demand** — `@voltro/devtools-ui`, `@voltro/plugin-comments`
363
+
364
+ Both dashboards open comment thread messages on demand with author, timestamp, pagination and a missing-thread state. The authenticated comments inspection endpoint accepts threadId and offset to return the selected thread's messages.
365
+
366
+ Thread messages use store-side ordering, limit and offset with a separate count query. The viewer rejects responses for a different thread or page while a new page loads.
367
+
368
+ The thread overview uses count aggregates and a bounded recent-thread query instead of loading all threads and comment bodies. Per-thread totals are grouped in the store.
369
+ - **Expose the shared dashboard confirmation panel** — `@voltro/devtools-ui`
370
+
371
+ Export ConfirmAction for host-specific operations that need an inline review step with explicit confirmation, cancellation and pending-state controls.
372
+ - **Share pending-safe confirmation dialogs across dashboards** — `@voltro/devtools-ui`
373
+
374
+ ConfirmMutationButton keeps failed operations open for retry, blocks duplicate submission and dismissal while pending, and restores focus on close. Cloud administrative removals and local registry clearing use this shared dialog.
375
+ - **Inspect one replica or combine selected replicas** — `@voltro/protocol`, `@voltro/cli`, `@voltro/runtime`, `@voltro/devtools-ui`, `@voltro/env`
376
+
377
+ App inspectors can select all replicas, an explicit subset or one process. Authenticated reads preserve replica origin, report missing responses and cancel obsolete work. Shared data is read once; logs and compatible metrics combine without averaging precomputed percentiles. Process event streams verify the selected peer, and the shared selector keeps its selection in the URL.
378
+
379
+ Replica collections have a ten-second total budget and retain completed answers when peers time out. Plugin observations remain separate per process instead of assuming shared state.
380
+
381
+ Web/SSR boot paths share the same authenticated replica reads and streams. Local development discovers processes per project; deployment-scoped PostgreSQL discovery supports multiple hosts.
382
+ - **Search and inspect RPC schemas in both dashboards** — `@voltro/devtools-ui`
383
+
384
+ The shared RPC inspector searches procedure names and source files, groups results by namespace and opens schema and access-rule details using pointer or keyboard. Procedure rows wrap long metadata on narrow screens and use theme-aware badge colors.
385
+ - **Execute RPC procedures with explicit context** — `@voltro/devtools-ui`
386
+
387
+ The shared RPC explorer supports mutation and action execution through an injected transport. Operators explicitly choose the tenant context, provide JSON, and confirm before execution. The canInvokeProcedure capability controls availability; result and error feedback remain separate.
388
+
389
+ The local app overview uses the shared explorer instead of a separate execution form with a prefilled tenant.
390
+ - **Search and page the full storage reference inventory** — `@voltro/plugin-storage`, `@voltro/devtools-ui`
391
+
392
+ Both dashboards search stored references by key, ID, content type, owner or tenant and page beyond the newest hundred objects. Database-backed inventory queries apply search and pagination in the store, while password hashes remain excluded from inspect responses.
393
+ - **Embeddings accept native `providerOptions` and validate dimensions** — `@voltro/ai`
394
+
395
+ EmbeddingProviderConfig accepts native providerOptions for dimension selection and query/document retrieval purpose, forwarded by embed and every embedMany SDK batch. expectedDimensions independently validates the returned vector length; malformed vectors and wrong batch counts fail as AiError instead of reaching storage. Invalid expectations fail before provider resolution. Effect interruption reaches the SDK abort signal. Configure model-supported options explicitly and re-embed stored documents when changing models/dimensions; mockDim remains mock-only. Automatic vectorEmbedding runtime injection and string-query embedding are not provided by this change; corrected docs distinguish those unwired declarations from explicit helper calls.
396
+ - **Flow agents accept resolved `tools` and `maxSteps`** — `@voltro/plugin-ai-flows`
397
+
398
+ Flow agents now accept resolved `tools` and `maxSteps` from `AgentResolution` in both execution modes. `EngineDeps.resolveTools(flowRef, run)` adds authorized tools to the agentic planner using the original flow reference and durable run/tenant identity. Resolver failures and reserved planner-name collisions refuse before model I/O. Tool Effects run in the host service environment; this does not make mid-activity external side effects replay-idempotent or grant the planner's tools to sub-agents.
399
+ - **Stream binary plugin inspect responses** — `@voltro/protocol`, `@voltro/runtime`, `@voltro/cli`
400
+
401
+ Plugin inspect endpoints support lazy binary streams, preserving file bytes outside observation envelopes. The shared API handler forwards download metadata and cancels sources on disconnect without buffering the full response.
402
+ - **The namespace directory is readable over inspect** — `@voltro/cli`
403
+
404
+ Expose authenticated, paginated namespace-directory metadata at GET /_voltro/inspect/workflows/flow-control/namespaces in dev and serve API apps with workflows. Preserve unreadable, unready and mismatched owners as partial results without exposing store objects or connection strings. The observation envelope explicitly limits coverage to registered namespaces; it does not certify workflow draining or a complete SQL namespace census.
405
+ - **`readMaybeOne(store, query)` for boot and query-only stores** — `@voltro/runtime`
406
+
407
+ `readMaybeOne(store, queryOrBuilder)` supplies typed first-or-null reads for boot and query-only tuple stores; FluentStore first/maybeOne use the same implementation and preserve their scoped query path.
408
+ - **An admission stores the pool, key and limit it resolved** — `@voltro/workflow`, `@voltro/cli`
409
+
410
+ Persist each admission's resolved concurrency pool, key and limit beside its resident reservation. The cross-store capacity adapter validates committed submission and ledger ownership and uses these stored values across deployment changes. Missing or contradictory metadata refuses before acquiring a permit; global host wiring and terminal reconciliation remain separate work.
411
+ - **Accept and inspect stored schedule backfill requests** — `@voltro/cli`, `@voltro/protocol`, `@voltro/devtools-ui`
412
+
413
+ Inspect endpoints accept stored schedule backfills with a caller-chosen request ID and expose their progress through authenticated reads. Confirmation and cap checks remain before execution; repeated IDs observe the same request and conflicting reuse is refused. Shared browser-safe schemas describe the transport.
414
+
415
+ The shared dashboard stores the request identity before submitting, reads progress after reload, and retries an uncertain acceptance with the same identity. Running ranges stay locked; a new request requires an explicit action after completion or interruption.
416
+ - **Store and execute scheduler backfill requests** — `@voltro/runtime`, `@voltro/cli`
417
+
418
+ Scheduler handles can accept a stored backfill range under a request ID, execute its occurrences sequentially and expose stored progress and per-run outcomes. Repeated IDs do not restart accepted work, and ambiguous dispatch failures remain interrupted. The framework registers the request table; dashboard transport integration is still pending.
419
+
420
+ Owners heartbeat during long handlers. Silent owners and orderly scheduler shutdown leave interrupted receipts rather than automatic replay; heartbeat and recovery timers are released on settlement and shutdown.
421
+ - **The queue backlog of a schedule is measurable** — `@voltro/runtime`
422
+
423
+ Three series for `onOverlap: 'queue'`: `voltro_schedule_queued` (firings parked right now, per schedule, reported per replica so a fleet's depth is the sum at query time), `voltro_schedule_queue_wait_seconds` (how long a firing waited before it started — invisible in the duration histogram, which starts at `firedAt`), and `voltro_schedule_queue_recoveries_total` (occurrences whose replica stopped reporting, labelled by whether the firing behind them adopted the work or it was abandoned past the adoption bound — one series, because the ratio is the question, and only the second means work will never run). A queue job slower than its own cadence falls behind without bound; until now nothing measured that.
424
+ - **`useSchemaFormBinding` shares the form engine without an API tag** — `@voltro/client`
425
+
426
+ `useSchemaFormBinding({ schema, onSubmit })` shares the form engine without an API tag, for Actions and parent-owned saves. Successful void submissions now set `state.isSubmitSuccessful`; independent schema forms receive distinct accessible field IDs.
427
+ - **Download stored files from dashboard inspectors** — `@voltro/devtools-ui`, `@voltro/cli`
428
+
429
+ Storage inspectors offer a native browser download with pending, retry and handoff states. Local handoffs verify file access before issuing a single-use URL. Storage statistics describe the complete reference inventory.
430
+ - **Expose storage object deletion through inspect** — `@voltro/plugin-storage`, `@voltro/devtools-ui`
431
+
432
+ Storage inspect exposes a validated object-reference deletion action using the existing service to remove grants and release unreferenced file data. Both dashboards expose a confirmed delete action with a dedicated capability, pending protection and retryable errors.
433
+ - **Authorize inspected storage uploads through signed tickets** — `@voltro/plugin-storage`
434
+
435
+ Storage inspect writers can request a five-minute binary upload ticket with explicit tenant and owner attribution. Declared size bounds the upload, and the existing upload route retains scan, quota and normalization checks.
436
+ - **Preserve native storage streams for downloads** — `@voltro/plugin-storage`
437
+
438
+ StorageService.getStream preserves native provider streams with tenant checking and lazy acquisition. The ordinary serve route and a new authorized inspect download endpoint use it; inspect downloads carry attachment filenames without exposing public URLs.
439
+ - **`acquireSubmissionCapacity` runs between resident commit and engine** — `@voltro/workflow`, `@voltro/cli`
440
+
441
+ Low-level admission hosts can provide `acquireSubmissionCapacity` after resident commit and before engine preparation. Direct calls retain a queued receipt when capacity is full; queue transfer and fresh submission recovery honor the same check without consuming engine retry attempts. Deployment boot wiring and terminal permit reconciliation are not enabled by this adapter alone.
442
+ - **Refresh one active subscription query with useRefreshSubscription**
443
+
444
+ Refresh an individual active snapshot query with `useRefreshSubscription`, retaining displayed data and optimistic changes while coalescing overlapping refresh requests.
445
+ - **`voltro doctor` fails a `publicApi.example` that cannot decode** — `@voltro/cli`, `@voltro/protocol`
446
+
447
+ `voltro doctor` reads the `publicApi` surface: a `publicApi.example` that decodes against neither the schema's form nor the wire form of the profile fails the run, naming the projection, the part and the decode reason — the rendered spec and every collection would show a body the API refuses. Under `publicApi: { docs: 'required' }` it also reports a projection without `publicApi.description` and an input or output field without a `description` annotation, so a new projection does not reach the surface without text.
448
+ - **The framework's own periodic tasks can be externalised, or say why not** — `@voltro/runtime`, `@voltro/cli`
449
+
450
+ `scheduling.externalizeFrameworkTasks` drives the framework's own maintenance timers from the platform instead of from in-process timers, for an estate whose rule is that every cron is a reviewed cluster object. It is applied at the one factory every framework and plugin periodic task passes through, so no caller can be forgotten, and an externalised tick claims the same interval bucket a timer tick would — a redelivery inside one interval is absorbed. Tasks that cannot be externalised are refused LOUDLY at boot and keep their timers: `voltro.ai.inference` (250 ms), `voltro.workflow.admission` (1 s) and `voltro.workflow.cancelOn` (2 s) are below the resolution of every platform scheduler, and they are not crons — their real trigger is an arrival and the interval is only a ceiling for when reactivity is unavailable. A cadence no five-field expression fires at is refused the same way, because cron matches instants rather than measuring intervals. A task silently left ticking while a deployment believes it was externalised is worse than one never externalised.
451
+ - **The OpenAPI document carries servers, contact and an example per body** — `@voltro/plugin-openapi`, `@voltro/protocol`
452
+
453
+ The OpenAPI document carries what a consumer looks for first: `servers` (one entry per environment, each tagged `x-voltro-environment`; the first is the default base URL of a generated client and of a collection — defaulted from `VOLTRO_PUBLIC_API_ORIGIN` and `VOLTRO_ENVIRONMENT`), `info.contact`, `info.license`, `info.termsOfService`, `x-logo`, `externalDocs` on the document and per tag (`tagExternalDocs`). Every request body and every `200` carries an example: the author's `publicApi.example`, written in the schema's form and encoded into the wire form of the profile (an example already in wire form passes through), or one derived deterministically from the wire schema and marked `x-voltro-example: derived`. The generic `400`, `401` and `500` show the URN they answer with and a `detail` shaped like the server's; the `Link` header is documented only on a route whose output carries the cursor field. Every projection answer names its profile — `x-voltro-rest-profile: standard` — so a client generated for one profile can tell on its first call that it met another.
454
+
455
+ Before this the spec had no `servers` (a collection importer set `{{baseUrl}}` to `/`), the generic refusals showed `"type": "https://example.com/"`, `Link` was documented on every `200` including one-shot actions, and an operation without an authored example was an empty body in Swagger UI and in every collection.
456
+ - **`voltro update --wait` installs once the registry serves every package** — `@voltro/cli`
457
+
458
+ `voltro update --wait` waits, before installing, until the registry serves the target version for EVERY package the update bumps — a release lands one package at a time and `@voltro/dashboard` follows from its own repository later — polling every 15 s for up to 20 minutes and naming the late packages while it waits; at the ceiling the bumps are restored and the registry endpoints printed. A failed install now names the bumped packages the registry does not serve yet, each with the versioned endpoint a person can open, instead of a generic "wait a minute".
459
+
460
+ A boot that hangs inside one module import says which module it is waiting on: after 10 s (`VOLTRO_IMPORT_DEADLINE_MS`; `0` disables) the log names the file and how long, and again every deadline until it settles — a descriptor graph that reaches a driver, a plugin that opens a connection at module scope, an `app.config.ts` that imports its own descriptors, each used to look like a boot with no line.
461
+ - **Share the browser-safe terminal admission outcome schema** — `@voltro/protocol`, `@voltro/workflow`
462
+
463
+ Export `WorkflowStartTerminalOutcomeSchema` and its inferred `WorkflowStartTerminalOutcome` type from `@voltro/protocol`. Persisted start receipts reuse this definition: cancelled or superseded admission does not invent an execution snapshot, and skipped identities name the incumbent. This schema alone does not activate public queued cancellation.
464
+ - **Confirmed cancellation and capacity release settle atomically** — `@voltro/workflow`
465
+
466
+ Allow the low-level generation-completion transaction to record confirmed cancellation and release linked and dispatched pre-ACK admission capacity atomically, without manufacturing a failure report. Engine control delivery and confirmation remain separate; a resolved interrupt promise is not evidence for this outcome.
467
+ - **Workflow control options carry an optional cause** — `@voltro/runtime`, `@voltro/cli`
468
+
469
+ Server workflow control options accept an optional cause alongside requestId. Facade wrappers and the resident adapter carry it into first acceptance; retries on a fresh host preserve the original cause and target generation. Engine-only control refuses these options rather than silently discarding persistence intent.
470
+ - **A resident control request persists its cause with acceptance** — `@voltro/workflow`
471
+
472
+ Resident control requests can persist an optional cause (reason and structured detail) with acceptance. Retries preserve the original cause and timestamp; rollback leaves neither request nor cause behind. Producer and terminal-event delivery integration remains in progress.
473
+ - **The resident `_voltro_workflow_control_requests` table** — `@voltro/workflow`
474
+
475
+ Declare the resident `_voltro_workflow_control_requests` table for workflow apps, with the internal database-only acceptance and pre-dispatch boundary. Generation-fenced engine dispatch, confirmation and recovery are not yet wired; the table is a storage foundation and does not fix the public cancellation contract.
476
+ - **`reserveAdmissionSubmission` and the resident submission table** — `@voltro/workflow`, `@voltro/cli`
477
+
478
+ Add the low-level reserveAdmissionSubmission primitive and resident submission table. A stable identity reserves one admission lease and immutable encoded engine input in the same database transaction; repeated attempts read that reservation before evaluating capacity. Workflow/tenant identity conflicts fail closed, and a failed submission write rolls back its lease. acknowledgeAdmissionSubmission atomically links the reservation and lease to observed engine acceptance and refuses contradictory execution identities. Controlled drains use these primitives before engine I/O; delayed starts and terminal handling remain unfinished. This is not an exactly-once execution guarantee.
479
+
480
+ Separate caller receipts from reusable debounce slot IDs with recordWorkflowStartReceipt and bindWorkflowStartReceipts. Receipt binding is paginated and durably marked complete so retries cannot capture a later burst in the same slot. Framework start adapters persist caller receipts and controlled drains bind them; waiting is not yet wired.
481
+
482
+ Standalone and CLI drainers share durable submission retry/backoff. Unknown engine acceptance no longer enters the consumed intent's abandonment path, including when retry metadata cannot be saved. An incomplete queue read preserves completed recovery counters and requests another tick instead of returning an all-zero result. Terminal submission reconciliation remains unfinished.
483
+
484
+ takePendingAdmissionSubmission combines queue re-read, reservation, receipt binding and queue removal in one resident database transaction. Its pure prepare callback receives the current stored inputs. Overlapping selections, postponed rows and dead letters are not partially consumed; retrying an existing submission never consumes a later burst. This is a low-level transfer primitive, not the completed dispatcher integration.
485
+
486
+ The flow-control gate now recovers completed unacknowledged transfers before reading its pending queue, including after process replacement with an empty queue. The admitted start path receives persisted inputs; only observed engine acceptance is acknowledged. Recovery advances beyond a failing page and wraps without OFFSET. Normal pending-to-submission production and receipt waiting are still unfinished; engine idempotency is required and no exactly-once side-effect guarantee is made. Submission recovery continues after retry metadata recording fails, preserving earlier acknowledgements and reporting both errors. Recovery cursors carry actual retry deadlines across pages and finish exactly-full windows without immediately wrapping into a tight retry loop over stopped records. Recovery validates submission metadata per row: a corrupt record is retained and counted without blocking valid neighbors or later pages. Warnings identify the submission without leaking malformed values. The public typed pendingAdmissionSubmissions reader remains strict; invalid records are never silently omitted from its result.
487
+ - **Bind resume dispatch to the workflow engine journal** — `@voltro/workflow`
488
+
489
+ Expose `WorkflowResumeDispatcher` from `@voltro/workflow/cluster`, supplied by `workflowEngineLayer`. Low-level hosts pass the resident admission transaction; the capability retains the matching engine journal and runner without requiring another SQL layer. Dispatch reports attempts, not completed entry, and does not install automatic recovery.
490
+ - **A failure report links to one durable arrival-queue receipt** — `@voltro/workflow`
491
+
492
+ Low-level failure-report handoff can atomically link an immutable incident to one durable arrival-queue receipt, preserving handler flow control. Retries do not recreate the report or queue; queued handoff is not handler acceptance. Automatic source-hook integration and delivery acknowledgement remain unfinished.
493
+ - **Resident immutable failure-report storage with transactional replay** — `@voltro/workflow`, `@voltro/cli`
494
+
495
+ Add resident immutable workflow failure-report storage with transactional replay and ownership checks. Controlled submission rejection now records its report and releases its lease atomically, fenced by a pre-engine dispatch counter so later schema errors cannot discard earlier unknown acceptance. Handler delivery and terminal-run recovery are not wired yet; persistence alone is not delivery acknowledgement.
496
+ - **Generation-bound control results commit with body completion** — `@voltro/workflow`
497
+
498
+ Persist generation-bound control results in the same resident transaction as body completion and capacity release. Read them with readWorkflowControlInTransaction even after a later generation enters; successful output is preserved instead of relabelled as cancellation. Requests accepted after completion retain that observed generation's outcome as already-terminal, never a newly confirmed cancellation. Control dispatch returns settled for an existing result. Producer and suspended-run adapter integration remain in progress.
499
+ - **Inspect resident workflow queue samples across physical placements** — `@voltro/cli`
500
+
501
+ Dev and serve expose authenticated `GET /_voltro/inspect/workflows/flow-control/physical` with bounded per-placement queue/admission samples, discovery cursors and isolated read failures. Responses omit queued payloads, retain physical ownership identifiers and distinguish discovery completion from row-window coverage. The inspect envelope explicitly reports incomplete coverage; existing primary-store intent actions are not extended by this read endpoint.
502
+ - **Bind queue inspection and intent actions to an explicit physical placement** — `@voltro/cli`, `@voltro/devtools-ui`
503
+
504
+ Queue reads, input validation, retry and discard accept `placementId`, resolve its current ownership and refuse inaccessible placements instead of targeting a same-ID primary-store row. Both dashboards provide a physical queue selector, preserve the selection through their transports, clear confirmations on selection changes and block actions on mismatched read responses. Primary-store selection remains explicit through an empty field; workflow pause/resume is deployment-wide. Bulk run scope and exhaustive inventory pagination are not changed.
505
+ - **`executePreparedWorkflow` submits an already-chosen execution ID** — `@voltro/workflow`
506
+
507
+ Add the low-level executePreparedWorkflow primitive for Voltro definitions: submit a previously chosen execution ID without recomputing a time-dependent application key, retaining payload validation, annotations and suspended retry settings. The caller still owns durable identity/payload binding, admission and context persistence. Ordinary starts and failure-report delivery are not wired to it yet.
508
+ - **`executePreparedWorkflow` can await the typed body result** — `@voltro/workflow`
509
+
510
+ executePreparedWorkflow accepts an optional fourth argument { discard: false } to await the typed body result under its already-bound execution ID. The default still returns acceptance; result waiting preserves declared failures and does not derive the application key again. Persist acceptance before waiting for completion. This engine primitive does not itself make direct arrival admission durable.
511
+ - **Describe queued cancellation evidence without fabricated executions** — `@voltro/protocol`, `@voltro/workflow`
512
+
513
+ Export `WorkflowQueuedCancellationResultSchema` and its inferred type. Separate pending admission, settled admission outcomes, actual execution snapshots and unavailable evidence while preserving accepted/confirmed/unknown/already-terminal acknowledgement. The private resident projection retains original output and failure evidence and never retargets a replacement generation. Public queue-control methods and host recovery remain to be connected.
514
+ - **A facade host may own cancellation and resume acceptance** — `@voltro/runtime`
515
+
516
+ Allow makeWorkflowFacade hosts to supply a resident control adapter owning cancellation and resume acceptance, dispatch and observation. The facade does not issue extra engine commands or fall through to engine-only behavior after adapter rejection. Built-in host integration remains in progress.
517
+ - **A resume is confirmed at clean suspended body entry** — `@voltro/workflow`, `@voltro/cli`
518
+
519
+ Confirm resident resume requests atomically at clean suspended body entry, preserving the target generation and the new generation's entry timestamp. Cancellation takes precedence; running replacements and successful wake commands do not manufacture confirmation. Entry receipts remain readable after completion. Automatic wake delivery and production control integration remain unfinished.
520
+ - **`runnerGeneration` storage and transaction-only entry primitives** — `@voltro/workflow`
521
+
522
+ Add runnerGeneration storage and transaction-only entry/completion primitives for resident workflow runs. Entry preserves the original run identity/input and does not count or reset a repeated generation twice. A completion owned by the current generation records its failure report and handler arrival, clears contradictory outcome fields, and releases linked admission reservations atomically. Old generations and non-running rows do not complete again. Recorder entry and body completion use this boundary. Lease release checks missing update targets and visits every reservation page, including dispatched prepared submissions whose ACK is still absent.
523
+ - **The dispatcher can start a prepared execution through the facade** — `@voltro/runtime`, `@voltro/workflow`
524
+
525
+ The low-level workflow dispatcher can start an already-admitted, prepared execution through the runtime facade without re-deriving its identity; caller context uses that same identity. This does not yet provide automatic durable failure-report delivery.
526
+ - **`resolveWorkflowStartReceipt` for resident admission adapters** — `@voltro/workflow`
527
+
528
+ Added resolveWorkflowStartReceipt and WorkflowStartResolution for resident admission adapters. Resolution checks workflow/tenant ownership and follows the receipt's exact pending input or submission; it distinguishes pending, confirmed acceptance, terminal queue outcomes and proven rejection. Prepared execution IDs are not acknowledgements, and missing or contradictory evidence is not indefinite pending. Call inside the owning admission transaction; the runtime queued-wait adapter consumes this resolution.
529
+ - **A submission binds a prepared engine identity before dispatch** — `@voltro/workflow`
530
+
531
+ Admission submissions can durably bind a prepared engine identity before dispatch. Concurrent proposals retain the first binding, contradictory acknowledgements fail, and an earlier unbound dispatch cannot acquire a guessed identity. Automatic dispatcher integration remains unfinished.
532
+
533
+ ### Changed
534
+
535
+ - **A six-field cron on an external trigger is refused where it is written** — `@voltro/runtime`
536
+
537
+ `defineSchedule` now throws when a definition combines a six-field (seconds) cron with `trigger: 'external'`, and `startScheduler` refuses to boot when a schedule inherits `external` from the app-wide default and carries one. Every platform scheduler is minute-resolution, so such a job could never fire; previously it was a warning from the manifest generator, which only appeared if you ran it, and otherwise the schedule simply never ran with nothing saying so. The in-app timer keeps its sub-minute ability — the limit belongs to platform schedulers, not to the framework.
538
+ - **Schedule manifests are Helm-shaped and admissible in a restricted namespace** — `@voltro/cli`
539
+
540
+ `voltro schedule-manifest --provider kubernetes` now emits one CronJob template per schedule with every site-specific value behind `.Values.voltroSchedules.*`, plus a values excerpt to merge into an existing umbrella chart. `--format chart` wraps them in a standalone chart, `--format plain` renders YAML with the defaults substituted. The PodSpec satisfies Pod Security Admission at `restricted` (`runAsNonRoot`, `RuntimeDefault` seccomp, no privilege escalation, read-only root filesystem with an `emptyDir` for the two response bodies, all capabilities dropped) and declares resources; the previous output carried none of that and was rejected outright by the namespaces most likely to impose this mode. It also sets `startingDeadlineSeconds`, without which a controller outage spanning 100 missed occurrences stops the CronJob scheduling permanently and silently. The image is a value so a cluster with a registry allowlist can point at its mirror, and a digest is offered but never invented — a digest we made up would produce a manifest whose image can never be pulled. Object names are RFC 1123 with a per-schedule hash and bounded to 52 characters, so two schedules differing only in punctuation no longer collapse onto one object and a Job's generated suffix still fits. No generated file carries a secret value.
541
+ - **A queued firing starts on the event, not on the next poll** — `@voltro/runtime`
542
+
543
+ A firing waiting for its `onOverlap: 'queue'` turn now re-checks when the run ledger changes, instead of only on its own cadence, on both boot paths. The poll remains the floor and its interval is unchanged: the blocking run usually finishes on another replica, and that event reaches this process only where CDC or a broadcast transport carries it — so this buys latency where reactivity exists and takes nothing away where it does not.
544
+ - **The changelog is published, filterable, and readable by a machine**
545
+
546
+ Every released version is now at `https://docs.voltro.dev/changelog`, filterable by package and by kind of change, with a per-release permalink. Until now the only copy outside a source checkout was the `CHANGELOG.md` inside a published tarball, reachable by asking a CDN for a markdown file — which is not a place anyone looks, and the stability contract at the top of that file names it as the only migration path the framework provides.
547
+
548
+ Two machine-readable forms ship beside it: `https://docs.voltro.dev/changelog.json` (the whole history, one object per release, each entry carrying its kind, headline, packages and body) and `https://docs.voltro.dev/changelog.xml` (an Atom feed of releases). A release page also fetches only its own release, so reading one version does not download ninety-three others.
549
+
550
+ Entries now carry a headline. A rolled entry reads as a scannable line — what changed — with its packages as tags and the prose beneath it, rather than opening directly into a paragraph. Entries rolled before the field existed keep their prose and show no headline: across the releases already published the first sentence of an entry is a median 126 characters and reaches 1025, so nothing recovers a headline from a body after the fact, and a summary invented now would be a guess presented as a fact.
551
+
552
+ ### Fixed
553
+
554
+ - **A generated collection fails when the server answers 500** — `@voltro/cli`
555
+
556
+ A generated API collection now asserts that the request SUCCEEDED, as the first test in all four dialects. Every other test it carries fires only for the status it is about — the schema check for a 2xx, the Problem Details check for a 4xx/5xx — so a collection run against a server answering `500` to every request reported no failures at all: each error was a well-formed error, and nothing said the endpoint was down. Measured against a real server with newman, which is also how it is now kept: the Postman collection is generated from real projections, run against a live listener, and a request that stops returning 2xx fails the suite. The Bruno collection is run the same way (`bru`), so two of the four dialects are proven by execution; the other two cannot run here — `@hoppscotch/cli` needs `isolated-vm`, which has no prebuild for this Node, and Insomnia's npm CLI is deprecated in favour of a platform binary — and are held to the two that can by a parity guard: every dialect must cover the same concerns and emit the same number of tests.
557
+ - **A link to a file under `public/` reaches the file, not the router** — `@voltro/web`
558
+
559
+ The router claims every same-origin anchor click on the page, which is what upgrades a raw `<a href>` in rendered markdown or a CMS body into a client navigation. It also meant a link to the app's own `public/` asset could never arrive: the click was claimed, the URL pushed, and the document went on rendering the page you were already on. The address bar changed and nothing else did — a symptom with no error and nothing to attribute it to. The server had the file the whole time and was never asked.
560
+
561
+ A path whose LAST segment carries a file extension is now a file unless the app NAMES that leaf — `/changelog.xml`, `/report.pdf` and `/sitemap.xml` go to the browser, while a page an author genuinely called `feed.xml` stays a route. A dot elsewhere in the path (`/docs/v1.2/intro`) is not an extension.
562
+
563
+ The rule that suggests itself — "no route matches it, so let the server answer" — does not work, and the case that kills it is the common one: a single dynamic segment compiles to `([^/]+)`, which matches a dot like any other character, so an app with a `/[locale]` route matches `/changelog.xml` with `locale = "changelog.xml"` and the guard never fires in the app that needs it. The question is not whether a pattern matches, it is whether an author named the leaf.
564
+
565
+ What the rule costs when it is wrong: a raw `<a href>` pointing at a dotted DYNAMIC route does a full page load instead of a client navigation — the right content, more slowly. `Link` and `PlainLink` are unaffected either way; they navigate themselves and mark the click handled before any of this runs. `resolveAnchorNavigation` takes an optional third argument for the same decision; existing callers are unchanged.
566
+ - **A `Record` of dates speaks the REST profile like every other field** — `@voltro/protocol`
567
+
568
+ The REST profile's wire view rewrites the VALUES of an index signature, so a `Record<string, timestampMs>` speaks the profile like every other field. It did not, and the wire then contradicted itself: under `dates: 'iso'` every declared field was an RFC 3339 string while a record's values stayed epoch milliseconds — and `JSONSchema.make` documented them that way, so the OpenAPI document agreed with the defect and any client generated from it decoded the field beside it correctly and this one not at all. A key's own type is untouched: it is a string either way, and `naming: 'snake_case'` renames declared fields, never data keys.
569
+ - **A rejected credential answers 401, `openAccess` included** — `@voltro/protocol`
570
+
571
+ A presented and rejected credential answers `401` on every `publicApi` projection, under `openAccess` too. The caller is not anonymous, they are unauthenticated; it used to fall through under `openAccess`, the handler answered `403`, and a client could not tell an invalid token from a token without the right. A caller who sent no credential still reaches an open handler as anonymous.
572
+ - **An external firing is identified by the cron expression, not the caller's clock** — `@voltro/runtime`
573
+
574
+ An external trigger's arrival time is now snapped back to the nearest occurrence of the schedule's own cron expression, and that instant is claimed through a store-backed gate. Previously the firing took `new Date()` as its `scheduledAt` and bypassed coordination entirely, on the reasoning that "the external scheduler already guarantees once-only" — which Kubernetes explicitly does not: a CronJob may create a Job twice for one scheduled time, and the manifests this framework generates carry a `backoffLimit`. Two deliveries therefore produced two different instants, two claim keys and two runs. A redelivery is now answered `200 {"status":"duplicate"}` with the run id of the firing already in flight. The claim is a store claim whatever coordination the deployment declared, because `singleCoordinator` answers true unconditionally — correct for a timer that cannot race itself, and exactly wrong for a one-pod deployment receiving the same firing twice from outside. Skew is bounded by `scheduling.externalTriggerSkewMs` (default 5 minutes) and always additionally by the schedule's own period, so an arrival can never reach back past the previous occurrence; one that matches no occurrence is refused with `409` naming the cadence the app holds, which makes an out-of-date manifest self-detecting.
575
+ - **An external trigger cannot claim an occurrence before it is due** — `@voltro/runtime`, `@voltro/cli`
576
+
577
+ The external trigger endpoint now refuses an arrival whose stated instant is further ahead than clock drift explains (one minute), with `409` and the server's own time in the body. The skew budget is one-directional by design — it forgives a LATE arrival, which is the only kind a platform scheduler produces — and nothing bounded an early one, so a caller passing `?at=` a month out had that future occurrence snapped, claimed and RUN. The genuine firing, when its time came, was then answered as a duplicate and its work silently never happened: nothing errored, no row said anything, the job simply did not run. That is the hardest failure shape there is to notice, and it was reachable from one wrong value in a manifest.
578
+ - **An explicit primary key no longer collides with the id() key on apply**
579
+
580
+ `.primaryKey([...])` replaces the primary key an `id()` column carries — that is the declared contract, and the schema emitter has always honoured it. The applier that `voltro db apply` runs kept four hand-written column renderers, one per dialect family, and none of them suppressed the inline key. A table declaring both emitted `PRIMARY KEY` twice and the statement was rejected.
581
+
582
+ Every dialect refuses two primary keys, so the failure was not confined to one. It surfaced as a migration that stopped partway on an engine without transactional DDL, leaving the operations before it committed and no rollback. Re-running resumes from the recorded position once the schema applies.
583
+
584
+ The condition is pinned across all five dialects, together with the negative control that an `id()` column without an explicit set still carries its key inline.
585
+ - **Approval endpoints bind only where a descriptor declares them** — `@voltro/cli`
586
+
587
+ Both server boot paths bind `__voltro.approvals.pending` and `__voltro.approvals.decide` only when an app mutation or action declares `requiresApproval`, matching codegen. Apps without approvals no longer receive a false client/server mismatch warning naming those built-ins; deleting the Vite cache was not a remedy. Declared approvals still install the gate and refuse execution if wiring is absent.
588
+ - **Compose baselines render the project's own pnpm version** — `@voltro/cli`
589
+
590
+ Compose and Compose-MariaDB baselines render API, web and dev Dockerfiles with the project's exact `packageManager` pnpm version, accepting Corepack integrity suffixes. Without a project pin, the invoking pnpm version is used; an unknown toolchain, another manager or a range refuses before files are written. `baseline sync` now refreshes the three generated Dockerfiles as well, so a changed project pin takes effect without manual template edits; MariaDB sync refreshes only Dockerfiles. Keep custom Dockerfiles outside the generated paths.
591
+ - **Dev discovers connection files before the registry, and says what booted** — `@voltro/cli`
592
+
593
+ Dev now discovers `.connection.ts`/`.connection.tsx` before loading the connection registry and assembling vault tables, matching `db plan`. Both API boot paths report actor reconciliation even at zero planted rows and emit workflow readiness only after successful runtime acquisition, with consistent registered entity names.
594
+ - **`cancelOn` keeps the planner's event id through cancellation** — `@voltro/workflow`, `@voltro/cli`
595
+
596
+ cancelOn dispatch preserves the planner's eventId for cancellation and queued-discard writers. The CLI carries it into cancellation detail and the discard audit reason, distinguishing repeated publications with the same event name. Sweep callback inputs derive from the planner's decision types instead of dropping fields through hand-copied shapes.
597
+ - **A failed `cancelOn` delivery keeps its event window** — `@voltro/workflow`, `@voltro/cli`
598
+
599
+ The cancelOn sweep retains its event window after cancellation or queued-discard delivery failures instead of advancing past lost work. Its CLI adapter no longer counts a false cancellation result as a confirmed cancellation. Cold starts persist their lower bound before reading the journal. Failures remain visible and retryable; persistent failures can delay later windows.
600
+ - **`cancelOn` retries reuse one control request identity** — `@voltro/cli`
601
+
602
+ cancelOn derives one control request identity from the workflow, run and triggering event. Retries reuse it, and the shared canceller forwards the reason and event details with resident acceptance. Hosts without resident control refuse instead of executing an untracked retry. Boot and recovery integration remain in progress.
603
+ - **A column rename keeps the shape change that rides with it** — `@voltro/database`
604
+
605
+ Column rename planning retains simultaneous column-shape changes instead of discarding them while folding ADD and DROP into RENAME. Subsequent operations target the new column name and keep their own safety classifications. Enforced database dependencies still apply to the rename.
606
+ - **Prevent duplicate dashboard action submissions** — `@voltro/devtools-ui`
607
+
608
+ Outbox resend, schedule run-now and backfill controls synchronously block repeated submissions while a request is pending and allow retry after failure. Outbox resend also checks the current delivering state when submitting an already-open form.
609
+ - **Distinguish unavailable cache metrics from zero traffic** — `@voltro/cli`, `@voltro/devtools-ui`
610
+
611
+ Cache inspection returns 503 when instrumentation is unavailable. Both dashboards hide previous measurements on read failure and show the source error; process-local counter scope is explicit for memory and Redis backends.
612
+ - **Keep all declared column types visible in the data explorer** — `@voltro/cli`, `@voltro/devtools-ui`
613
+
614
+ The data explorer recognizes the full database column catalogue. Real and decimal fields support numeric edits and range filters, and arrays accept JSON edits. Binary, raw and bigint fields remain read-only pending lossless editing codecs.
615
+ - **Confirm database actions and show mutation results** — `@voltro/devtools-ui`
616
+
617
+ The Database inspector confirms seed and rollback targets, prevents duplicate submissions, and displays mutation failures with retry and successful completion feedback. Its content follows the surrounding inspector width.
618
+ - **Require database status before operator actions** — `@voltro/devtools-ui`
619
+
620
+ Seed and rollback actions require a successfully loaded database status. Failed or pending status reads hide action controls and prevent previously opened confirmations from executing.
621
+ - **Load replica comparisons on demand** — `@voltro/devtools-ui`
622
+
623
+ Process-specific comparisons initially mount two observations regardless of fleet size. Additional replicas load when opened; closing a panel releases its providers. Shared editors and combined telemetry remain unchanged. Closed replica observations use compact divider rows instead of full-page spacing.
624
+ - **Preserve exact decimal values in the data explorer** — `@voltro/devtools-ui`, `@voltro/cli`
625
+
626
+ Decimal cell edits, defaults and filter operands retain their string representation instead of converting through JavaScript numbers. Inspect filters reject numeric decimal inputs to prevent silently rounded operands.
627
+ - **Correct notification inspection and recipient switching** — `@voltro/plugin-notifications`, `@voltro/devtools-ui`
628
+
629
+ Skipped deliveries no longer inflate failure counts. Both dashboards show inbox message bodies and creation times, and start a fresh inbox subscription when the selected recipient changes.
630
+ - **Validate integer and vector edits before writing** — `@voltro/devtools-ui`
631
+
632
+ The data explorer rejects fractional or unsafe integer drafts and vectors containing non-numeric or non-finite elements. Both editor errors have dedicated English and German messages.
633
+
634
+ Invalid decimal drafts remain editable instead of reverting to the previous value. Escape does not dismiss an inline editor while its save is pending.
635
+
636
+ Record submission reuses the cell parser so generated editor defaults cannot bypass type validation.
637
+ - **Separate queue consumer policies from topic counters** — `@voltro/devtools-ui`, `@voltro/plugin-queue`
638
+
639
+ Both dashboards show retry limits and dead-letter targets before failures, search consumer configuration, and display shared process topic counters once per topic rather than repeating them for each group.
640
+
641
+ Successful QueueService batches now update production counters after transport completion; failed batches do not inflate them.
642
+ - **Preserve unreachable replica diagnostics in the inspector** — `@voltro/cli`
643
+
644
+ Replica read collection retains the peer refusal identifying an unreachable replica instead of replacing it with an opaque HTTP status. Explicit replica reads do not silently fall back to another process.
645
+ - **Keep RPC execution within the visible selection** — `@voltro/devtools-ui`
646
+
647
+ RPC details follow the current tab and search. Catalog failures hide prior actions, and unavailable execution explains subscription-only procedures, missing write access or missing transport.
648
+ - **Download files from aliased storage instances** — `@voltro/cli`
649
+
650
+ Local browser downloads accept storage instance aliases containing dots, matching the plugin mount contract, while still rejecting traversal paths.
651
+ - **Keep storage grants authoritative after operator actions** — `@voltro/devtools-ui`
652
+
653
+ Storage grant and revoke actions prevent duplicate submissions and competing writes. Success confirmations no longer permanently override the app’s grant list, so later external removals and restorations remain visible in both dashboards.
654
+ - **Stream local dashboard inspect responses** — `@voltro/cli`
655
+
656
+ Development and production dashboard proxies share a streaming response forwarder. Downloads preserve metadata, cancellation closes the upstream connection, and decoded compressed responses no longer carry an incorrect compressed length.
657
+ - **Carry vector dimensions into the data explorer** — `@voltro/cli`, `@voltro/devtools-ui`
658
+
659
+ The inspect catalogue exposes declared vector dimensions. Local and Cloud data explorers reject vector drafts of the wrong length and report the required element count.
660
+ - **Reliable webhook management feedback** — `@voltro/devtools-ui`
661
+
662
+ Webhook management shows mutation failures and completion, preserves subscription inputs for retry, prevents concurrent actions, and confirms deletion and secret rotation beside the target. Unavailable write providers cannot offer executable controls.
663
+ - **Show workflow operation errors on the affected run** — `@voltro/devtools-ui`
664
+
665
+ Cancel, suspend, resume and retry catch rejected requests, show run-scoped feedback and prevent duplicate or competing submissions while pending. Successful requests are distinguished from workflow completion.
666
+ - **Keep replica selection across workflow drill-downs** — `@voltro/devtools-ui`
667
+
668
+ Workflow detail, trace and back links within the dashboard retain the inspector's replica selection on both hosts. External trace-provider URLs remain unchanged. The unused separate requeue provider was removed from the shared workflow page; retry, resume and bulk recovery remain the connected operator actions.
669
+ - **`db plan --against` refuses an incomplete remote observation** — `@voltro/cli`
670
+
671
+ `db plan --against` unwraps the shared Inspect Observation before reading the remote live schema, logs its scope/origin/completeness, and refuses an explicitly incomplete observation or missing snapshot instead of deriving a misleading plan.
672
+ - **Every controlled direct workflow arrival leaves a receipt** — `@voltro/cli`
673
+
674
+ Persist a resident receipt for every controlled direct workflow arrival, including the caller closing a batch. Bind and consume its input in the admission transaction so recovery can distinguish reserved capacity from engine acknowledgement without leaving a duplicate queued start.
675
+ - **Doctor tells a real N+1 from a nullable, a memo or a singleton** — `@voltro/cli`
676
+
677
+ Doctor distinguishes nullable results, controlled form fields, subscription-derived memos, singleton receivers and set/page loops; concurrent per-row reads remain N+1. Record exact rule/file decisions with reasons in `voltro-doctor-reviews.json`; reports retain active/stale decisions and malformed-entry diagnostics.
678
+ - **Tenant-to-namespace ownership is persisted before any DDL** — `@voltro/cli`
679
+
680
+ SQL tenant provisioning persists exact tenant-to-namespace ownership in the selected physical database before DDL or seeds. Conflicting tenant IDs that sanitize to the same namespace refuse; incomplete provisioning remains visible and retryable. A bounded directory reader preserves errors rather than reporting an empty result. Cross-namespace background draining is not supplied by this directory alone.
681
+ - **A flow's agent resolver receives the durable run, not the caller** — `@voltro/plugin-ai-flows`
682
+
683
+ Flow agent resolvers receive `resolveAgent(ref, { runId, tenantId })` in both deterministic steps and the agentic `run_agent` tool. The context comes from the durable run, not the current subject; unavailable or invalid run context refuses generation instead of becoming a null-tenant call. Persona authorization remains the resolver's responsibility. The default model resolver now selects the AI Gateway rather than recursively calling itself.
684
+ - **Cadence counts only running receipts as a fresh start** — `@voltro/plugin-ai-flows`
685
+
686
+ Cadence refuses unreadable candidate lists and missing workflow runtime, reports per-flow failures after attempting remaining candidates, and counts only running admission receipts as fresh starts. Store-decided insert conflicts reuse scoped, validated persisted input; arbitrary write errors never authorize an unrecorded workflow submission. Run persistence and workflow submission are not yet crash-atomic.
687
+ - **One cadence candidate commits its row, intent and receipt together** — `@voltro/plugin-ai-flows`, `@voltro/cli`
688
+
689
+ Cadence commits each candidate's run row, workflow start intent and observed receipt in one context transaction. Failure collection remains outside the per-candidate boundary. Standard contexts return queued admission, which is deliberately not counted in started; matched is not an execution count.
690
+ - **A cadence admission handle is kept in the run's `workflowStart`** — `@voltro/plugin-ai-flows`
691
+
692
+ Persist cadence admission handles in the flow run's nullable workflowStart field, preserving queued identities without overwriting executor progress. Invalid answers and receipt-write failures reject the tick rather than count a successful start. The observation is not current execution state or crash-atomic submission evidence.
693
+ - **`launchFlow` commits through `ctx.transaction` outside a mutation too** — `@voltro/plugin-ai-flows`, `@voltro/cli`
694
+
695
+ launchFlow now commits its run row, workflow start intent and admission receipt through ctx.transaction even when called outside a mutation. It returns a queued transaction receipt, not an engine-start confirmation; an enclosing mutation still owns the commit. Brief validation keeps its typed failure. Retry and cadence transaction integration remain separate work.
696
+ - **`retryFlow` validates, queues and records in one transaction** — `@voltro/plugin-ai-flows`, `@voltro/cli`
697
+
698
+ retryFlow now reads and validates its stored payload, queues its workflow start and records the admission in one context transaction, including from actions. A missing run remains FlowRunNotFound; invalid stored inputs remain defects without exposing their contents. Submission preserves previous execution evidence. An enclosing mutation still owns the commit.
699
+ - **A flow refuses a missing workflow runtime before writing run state** — `@voltro/plugin-ai-flows`
700
+
701
+ launchFlow and retryFlow refuse a missing workflow runtime before writing run state. Launch no longer creates a pending-looking row without submitting a start; retry preserves the existing failure instead of clearing it when no runtime can receive the request. Missing runtime is a host wiring defect. This preflight does not make the run-row write and workflow submission atomic or turn a pending row into proof of confirmed engine acceptance.
702
+
703
+ Retry also refuses a missing, empty or incorrectly typed stored requestId before changing the row or submitting work. It never generates a new execution identity for an existing run; a valid stored identity is forwarded unchanged.
704
+
705
+ Retry validates the stored submission against the workflow payload schema before writing. Missing or invalid flow references, input objects and sources no longer become unchecked submissions or invented defaults. Rejection preserves the existing failure and does not expose stored payload contents.
706
+ - **`gatewayCostUsd` accepts only an explicit finite, non-negative charge** — `@voltro/ai`
707
+
708
+ `gatewayCostUsd` accepts only explicit finite, non-negative numeric charges: empty, whitespace-only, negative and non-decimal values no longer become authoritative zero or negative spend. Explicit zero remains valid. Direct invalid `actualCostUsd` inputs are refused before usage persistence.
709
+ - **An explicit seed of 0 survives gateway serialization** — `@voltro/ai`
710
+
711
+ Gateway image/video requests and asynchronous video operation starts retain an explicit numeric seed of 0 through HTTP serialization. The shipped adapter binds the seed per SDK call, including concurrent calls on one model, and preserves SDK authentication, media encoding, provider metadata, cancellation signals and status polling. Omitted seeds remain omitted; no app-side node_modules patch is required. Provider/model support determines reproducibility; sending the same seed does not itself guarantee identical media.
712
+ - **Migration snapshots retain HNSW operator classes and build parameters** — `@voltro/database`
713
+
714
+ PostgreSQL migration snapshots, fingerprints and db plan/apply retain index access methods and HNSW operator classes/build parameters. A same-name B-tree or changed HNSW metric/tuning is rebuilt instead of falsely reporting convergence; concurrent additions preserve HNSW too. Both DDL writers derive halfvec operator classes from column precision, and catalog snapshots retain vector dimensions and precision. Omitted HNSW parameters normalize to their defaults. Non-PostgreSQL snapshots reflect the DDL's skipped HNSW indexes. Explicit opclasses are identifier tokens, not SQL fragments. Rebuilding an index preserves table rows but takes build time and temporarily removes its query acceleration.
715
+ - **`generateVideo` bounds a decoded inline video before it allocates** — `@voltro/ai`
716
+
717
+ `generateVideo` rejects malformed/non-canonical base64 and bounds decoded inline videos with `maxInlineBytes` (64 MiB default), checking length before decoding allocation. Binary results obey the same limit; URL results remain URLs without an automatic download. This does not bound an upstream SDK's HTTP response buffer.
718
+ - **Structured output is validated against its JSON Schema, not just parsed** — `@voltro/ai`
719
+
720
+ Validate raw structured output against its JSON Schema instead of accepting a merely parseable value. Refuse unsupported schemas before provider I/O; schema violations use the decode failure channel without provider fallback or response contents.
721
+ - **Compare decimal values exactly in memory queries** — `@voltro/database`, `@voltro/runtime`
722
+
723
+ Memory queries use declared decimal column types for exact comparison, equality and ordering, including filtered updates and deletes. Text ordering remains unchanged. Predicate evaluation accepts column metadata, and compareDecimal provides lossless fixed-point comparison.
724
+
725
+ Reactive matchers apply the same exact decimal semantics to old and new row images. Candidate routing retains other indexable constraints and leaves decimal conditions to typed evaluation, including composite index hints.
726
+ - **A memory transaction validates its snapshot revisions before commit** — `@voltro/runtime`, `@voltro/database`
727
+
728
+ Memory transactions validate snapshot read/write-table revisions before commit and retry contention with the shared four-attempt policy. Concurrent increments, phantom claims and read-only dependencies no longer silently commit stale decisions; discarded attempts emit no events. Validation includes non-reactive tables and physical namespaces. Transaction callbacks must keep external side effects outside the retry boundary; `forUpdate` is optimistic in memory, not a blocking row lock.
729
+
730
+ The wrapped store also retains only the successful attempt's deferred CRDT folds. A failed attempt can no longer change a document after a later attempt commits; this correction covers SQL transaction retries too.
731
+ - **A failed rollback no longer replaces the DDL error that caused it** — `@voltro/database`
732
+
733
+ Transactional migration failures retain the original operation error when rollback also fails, reporting both causes in execution order. A rollback error no longer replaces the failing DDL statement and its database explanation. Successful rollback and commit failures keep their existing result semantics.
734
+ - **A JSON-validation check is no longer read as an enum** — `@voltro/database`
735
+
736
+ SQL Server schema introspection no longer treats string literals inside JSON-validation checks as enum members. It uses the shared enum-comparison parser and verifies the catalog column before reporting `oneOf`. Recognized column- and table-level JSON checks expose their actual catalog names, validation shape, enabled state and trust state in `ColumnSnapshot.jsonChecks`; application-specific predicates are not claimed as framework checks.
737
+
738
+ The separate `jsonValidation` snapshot field captures the proven value domain and participates in schema fingerprints; catalog-generated names do not. SQL Server declarations require value validation for JSON and container validation for fallback arrays.
739
+ - **SQL Server JSON columns accept scalars, and outdated checks reconcile** — `@voltro/database`, `@voltro/devtools-ui`
740
+
741
+ SQL Server JSON columns accept scalar values as well as objects and arrays. `db plan` emits `reconcile-json-check` for recognized outdated or unverified constraints; `db apply` validates existing rows and replaces recognized checks atomically, retaining application-specific checks and all data. Invalid existing JSON refuses and rolls back the migration. Fresh table and added-column DDL use the same value check. No automatic data rewrite is performed.
742
+
743
+ Fallback array columns are compared as their physical JSON-text storage, avoiding a repeated type alteration after a successful CREATE or ADD. Rolling deployments distinguish a proven container-to-value widening from newly enforced or narrowed validity.
744
+ - **A SQL Server column rename preserves its dependent CHECK constraints** — `@voltro/database`
745
+
746
+ SQL Server column renames preserve dependent CHECK constraints within the migration transaction, including their names, enabled/trusted flags, replication setting and string literals. Exact catalog column references are rewritten before any constraint is removed; unsupported or ambiguous expressions require an explicit migration. Renaming and upgrading a JSON validity check now work in one apply, and invalid existing JSON rolls the entire change back. Catalog identifier quoting escapes embedded delimiters on every dialect.
747
+ - **A MERGE upsert's recorder runs on the write's own connection** — `@voltro/sql-mssql`
748
+
749
+ Await SQL Server MERGE upsert recorders on the write's transaction connection and preserve captured request attribution across native and functional upsert paths. Recorder failures now reject the upsert and roll back both the data change and recorder append, without publishing change events.
750
+ - **SQL Server change images and patch results decode exactly once** — `@voltro/sql-mssql`
751
+
752
+ Decode SQL Server JSON-patch results, bulk change images and conflict lookup rows exactly once using the registered schema. Insert-ignore returns the same JSON value on insert and conflict; functional upserts receive decoded values instead of stored JSON text. Bulk inserts also decode the fallback output order.
753
+ - **Mutation dispatch picks the tenant's store before the transaction opens** — `@voltro/runtime`, `@voltro/cli`
754
+
755
+ Mutation dispatch selects the tenant's regional and namespace store before opening the transaction. Dev and serve consume one context-builder adapter; transaction retries and post-commit folds keep the selected store. The builder no longer tries to rebind a transaction view that has no withNamespace method. Same-tenant storeForTenant writes remain transactional; changing physical tenant placement inside a mutation refuses rather than escaping that transaction. Namespace provisioning is awaited before the transaction callback runs. Low-level MutationRunnerDeps hosts can supply resolveStore for request-specific placement and must build contexts against the supplied view without rebinding it.
756
+
757
+ Namespace provisioning now follows that physical store too: dev and serve construct each regional provisioner from the same connection configuration used to open the regional store. Completion is memoized separately per store and namespace; an unknown store refuses instead of provisioning through the primary connection. Low-level context hosts receive the selected, not-yet-namespaced store as the second ensureTenantProvisioned argument.
758
+
759
+ This fixes mutation placement, not regional background delivery: namespace/residency-aware admission draining remains under audit. Do not read a queued workflow receipt as evidence that those deployments have delivered the start.
760
+ - **MySQL native JSON is not parsed a second time** — `@voltro/database`, `@voltro/sql-mysql`
761
+
762
+ Do not parse MySQL/MariaDB native JSON a second time after mysql2 has decoded it. JSON-looking strings retain their exact type and contents in query results; array fallback columns still decode their text representation separately. No driver-wide JSON mode is changed.
763
+ - **A `LIKE` check is no longer read as an enum membership** — `@voltro/database`
764
+
765
+ PostgreSQL schema introspection recognizes direct string membership instead of treating every CHECK string literal as an enum member. LIKE, inequality and transformed-column checks no longer produce false `oneOf` values; actual text/varchar membership preserves empty strings and escaped apostrophes.
766
+ - **PostgreSQL `vector` columns accept number arrays on every write path** — `@voltro/database`, `@voltro/sql-postgres`
767
+
768
+ Registered PostgreSQL vector and halfvec columns now accept number arrays on ordinary insert, update, upsert, insertIgnore, bulk and transactional write paths. Native PostgreSQL array columns remain native. Reads, returned write rows, inline change images and JSON eager-loaded parent/child vectors decode back to number arrays; nullable values remain null. Raw SQL still owns its parameter/result codec. Regression tests use a separate real pgvector fixture, included in CI's integration and coverage database stacks; this does not close the separate HNSW migration-snapshot issue.
769
+ - **Prometheus says at boot whether its endpoint is locked or open** — `@voltro/plugin-prometheus`
770
+
771
+ Every Prometheus activation explicitly reports `metrics: locked` or warns `metrics: open`, using the same effective token as the scrape gate. Logs identify the configured path without exposing credentials; an open endpoint no longer relies on operators interpreting `tokenGated: false` as a security warning.
772
+ - **A queued blocking call cannot execute past admission** — `@voltro/runtime`
773
+
774
+ Prevent a queued blocking workflow call from bypassing admission and executing independently. Both `run()` and `start({ wait: true })` report the retained receipt rather than an unearned result; automatic blocking receipt resolution remains unfinished.
775
+ - **Remove replica observations during graceful shutdown** — `@voltro/cli`
776
+
777
+ Development and production shutdown await the observation publisher. In-flight publication completes before removing the replica record, preventing stale membership after normal restarts and writes that recreate removed observations.
778
+ - **`/me` does not match `/me/` or `/ME`, and the docs now say so** — `@voltro/protocol`, `@voltro/runtime`
779
+
780
+ Correct the REST documentation's trailing-slash promise and record the contract that already applies since 0.70.0: `/me` does not match `/me/` or `/ME`, including for hand-authored `restRoutes`. Rejected path variants return a plain-text `404 not found` before the descriptor's JSON error encoder runs. Clients and ingress rules must use the declared spelling and check status and Content-Type before parsing JSON. Runtime matching remains strict so alternate spellings cannot bypass an ingress path guard.
781
+ - **REST projections decode a body by Content-Type, and refuse it redacted** — `@voltro/protocol`, `@voltro/plugin-openapi`
782
+
783
+ REST projections ignore unused bodies, decode JSON and URL-encoded forms according to Content-Type, and return malformed bodies as a redacted 400 in the selected error profile rather than an empty 500. Form input retains repeated fields and uses schema-directed coercion; JSON types and path/body conflict checks remain intact. OpenAPI documents form bodies and conditional GET's If-None-Match parameter and bodyless 304 response. Documentation distinguishes inbound actions from event-listener permissions and schema-declared timestamps from arbitrary strings.
784
+ - **A resumable producer persists a sanitized terminal error**
785
+
786
+ Resumable producers persist a sanitized terminal error for source defects, throwing factories, missing terminals and producer interruption before marking complete. Consumers stop on the persisted terminal even when the done-flag write fails. Unrecoverable persistence failure is no longer silently called completion: direct producers fail and detached producers log a redacted error. Consumer disconnects still leave the producer running; process kills and unavailable stores cannot guarantee a terminal delivery.
787
+ - **An adopted firing keeps the trigger and outcome its own row recorded** — `@voltro/runtime`
788
+
789
+ A `queue` occurrence taken over from a replica that stopped reporting ran with the adopting firing's trigger rather than its own, so a handler branching on `ctx.trigger` saw the wrong answer for the occurrence it was given. `coordinationOutcome` — the column an operator reads to learn why an instance ran something — is likewise left as the occurrence recorded it; only `replicaId` moves to the replica that actually did the work.
790
+ - **Boot backfill no longer misses a gap on a fleet with many schedules** — `@voltro/runtime`
791
+
792
+ The boot-time catch-up looked for a schedule's last run in the 200 most recent rows of *every* schedule and matched the name afterwards. That window is measured in firings rather than in time, so on a deployment with several frequent schedules a quieter one's last run fell out of it — and "not in the page" produced the same answer as "never ran": no backfill, and no log line saying so. The lookup is now scoped to the schedule and bounded to one row.
793
+ - **A fleet booting together records each missed slot once** — `@voltro/runtime`
794
+
795
+ The boot catch-up recorded the slots it skipped without consulting the coordination gate, and every replica runs that catch-up against the same ledger — so a five-slot gap on ten replicas left fifty rows saying the same thing. The firing was always gated; the bookkeeping now is too, keyed on the slot every replica derives identically. A claim failure still records the row: a duplicate is cosmetic, a gap nobody recorded is the thing `backfill: 'latest'` exists to make visible.
796
+ - **Show schedule firing history when filtering by name** — `@voltro/cli`
797
+
798
+ Schedule inspection uses the shared database equality predicate so the dashboard displays recorded firings for the expanded schedule and counts only matching runs across pages.
799
+ - **Acknowledge dashboard schedule starts before handler completion** — `@voltro/runtime`, `@voltro/cli`
800
+
801
+ Dashboard schedule starts return a recorded run ID without waiting for the handler, preventing client timeouts for long-running schedules. SchedulerHandle.startNow exposes acceptance separately from fireNow, which waits for completion. Inspect firing history carries the eventual outcome.
802
+ - **Adopting a stranded queued firing is bounded by the schedule's own cadence** — `@voltro/runtime`, `@voltro/cli`
803
+
804
+ Taking over a `queue` firing whose replica stopped reporting had no upper bound, so an occurrence stranded by a multi-day outage would run unannounced — the opposite of what `backfill: 'skip'` leads a reader to expect. It is now bounded by twice that schedule's own period, measured from the abandoned waiter's last beat, with a floor above the ~90 s it takes to call a waiter dead at all so the fastest schedules can adopt anything at all. Neither obvious measure works alone: the occurrence's AGE refuses exactly the deep backlog `queue` exists to preserve, and a FIXED silence window is generous on a per-minute schedule and a coin flip on an hourly one, because an abandoned waiter is silent for about one period by construction. Past the bound the occurrence is recorded `missed`. `scheduling.queueAdoptionWindowMs` / `VOLTRO_QUEUE_ADOPTION_WINDOW_MS` replaces the derived bound for a deployment that needs a flat policy.
805
+ - **A queued firing survives the replica that was holding it** — `@voltro/runtime`
806
+
807
+ Under `onOverlap: 'queue'`, a firing waiting for its turn is now taken over by the firing behind it when the replica holding it stops reporting — run in order, before the adopter's own occurrence, and stamped with the adopter's `replicaId`. A rolling deploy is routine, so writing the work off instead would have meant losing a queue's backlog on every deployment. The same conditional write makes it safe against a waiter that was merely slow rather than gone: that one's own turn-taking write then finds nothing and it stands down. A firing keeps beating its own row while it runs an occurrence it adopted, so recovering someone else's work cannot make a peer read THIS one as a corpse and hand its occurrence on again.
808
+ - **`onOverlap: 'queue'` serializes across replicas, not just in one process** — `@voltro/runtime`, `@voltro/cli`, `@voltro/devtools-ui`
809
+
810
+ A schedule with `onOverlap: 'queue'` kept its line in one process's memory, while the coordination gate elects a replica per occurrence — so consecutive occurrences landed on different replicas and ran concurrently, which is the one thing the policy promises not to do. A firing that must wait now records itself as `queued` in `_voltro_schedule_runs` and starts only when no run of that schedule is live anywhere and no earlier `queued` occurrence is still waiting, ordered by the cron-derived `scheduledAt` that every replica computes identically. Waiting firings beat their own row, so a replica that stops reporting mid-wait is written off as `missed` and the line moves; the backlog is visible in the dashboard while it exists. Single-process deployments keep the in-process chain and write no `queued` rows.
811
+ - **A queued firing of a deleted schedule is cleared at boot** — `@voltro/runtime`
812
+
813
+ A `queued` row is exempt from the run-ledger retention sweep because it is a firing's place in line, not a record of one. For a schedule that was renamed or deleted that exemption had no exit: the name never fires again, so nothing adopts the row, and nothing collects it. Boot — the one moment the full set of declared schedules is known — now writes such a row off. Only rows that are also silent are touched, so a beating waiter under an unfamiliar name, which belongs to another deployment sharing the database, is left alone.
814
+ - **Retention no longer deletes a firing's place in the queue** — `@voltro/cli`
815
+
816
+ The `_voltro_schedule_runs` sweep bounded the table by age alone, so a `queued` row — a firing's position in line under `onOverlap: 'queue'`, not a record of a firing — could be deleted out from under the firing it represents, which then stood down and did no work. `queued` is now exempt; `running` deliberately is not, so a row a crashed process left behind still ages out. Reachable whenever a queue is further behind than the retention window, which a shortened `VOLTRO_SCHEDULE_RUNS_TTL_HOURS` makes ordinary.
817
+ - **Narrowing a subject keeps the application audit actor** — `@voltro/protocol`, `@voltro/runtime`
818
+
819
+ Preserve the application audit actor when narrowing a subject to a tenant, including after persisted identity round trips: scoped system work keeps a null actor instead of stamping a fictional application actor. User and API-key attribution remain intact. This fixes attribution, not the separate cadence scheduler tenant-placement defect.
820
+ - **The API serve bundle no longer carries the WOFF2 font codec**
821
+
822
+ `fontverter` joins the runtime-resolved leaves alongside `fontkit`, `subset-font`, `satori`, `sharp` and `@resvg/resvg-js`. It is reached only from the build-time half of the native-font pipeline, so `voltro serve` never takes the path that imports it — but the bundler inlined its WOFF2 codec regardless, as a chunk nothing in the bundle referenced.
823
+
824
+ Measured on an API app with no declared fonts, no storage and no mail: the serve bundle drops from 9.46 MB to 8.12 MB, 1.35 MB of it that codec. An app that declares server fonts is unaffected: the conversion runs during `voltro build`, where the package resolves normally.
825
+ - **Every scheduling knob reaches the production server** — `@voltro/cli`
826
+
827
+ `voltro serve` rebuilt the resolved scheduling config field by field on its way into the API server, so a knob the resolver gained arrived only where somebody remembered to add a second line. It had already drifted — `pollCeilingMs` never made the crossing while `disarmWhenIdle` existed only on the far side. The resolved config is handed over whole, and its type is the resolver's own rather than a copy of it.
828
+ - **SQLite write results decode through the registered column schema** — `@voltro/sql-sqlite`, `@voltro/sql-turso`
829
+
830
+ Decode SQLite-family write results and change images using the registered column schema before returning or recording them. JSON strings remain strings, arrays remain arrays, and conflict callbacks receive decoded rows. This covers keyed and bulk writes, JSON patches, upsert, insert-ignore and deletes.
831
+ - **`WebBuildOptions` is exported for a typed build block** — `@voltro/cli`
832
+
833
+ Export `WebBuildOptions` for a typed `app.config.ts` build block and use it in the web configuration contract. SSR externalization examples no longer reference a nonexistent `defineConfig` helper; external packages must remain installed in the runtime image.
834
+ - **`voltro codegen <web-app>` writes route builders without a dev boot** — `@voltro/cli`, `@voltro/web`
835
+
836
+ `voltro codegen <web-app>` writes typed route builders without a dev boot or page execution. Correct `useSearchParams` guidance: its router subscription already refreshes isolated readers on query and history navigation.
837
+ - **Aggregate the complete storage reference inventory** — `@voltro/plugin-storage`
838
+
839
+ Storage inspect statistics include the entire reference inventory instead of only the newest 10,000 objects. RefStore.statistics provides complete tenant and visibility totals; DataStore implementations use grouped aggregates.
840
+ - **Validate storage inspector grant actions** — `@voltro/plugin-storage`
841
+
842
+ Storage inspect share and revoke endpoints reject malformed identifiers, unsupported principal or permission values, and invalid expiry values with HTTP 400 before changing grants.
843
+ - **Give named storage instances distinct HTTP mounts** — `@voltro/plugin-storage`
844
+
845
+ Named and aliased storage instances mount file serving, binary uploads and resumable uploads under distinct instance paths. Generated upload and file URLs use the matching mount; the default instance retains its standard path.
846
+ - **An orphan sweep deletes only on a confirmed absence** — `@voltro/plugin-storage`
847
+
848
+ Orphan sweeps no longer interpret a failed provider HEAD as an absent object and delete its valid reference. Provider, metadata-delete and quota-release failures now fail the sweep instead of reporting successful cleanup. This does not make deletion and quota release atomic or repair retry identity after a provider delete failure.
849
+ - **Bind storage upload tickets to their issuing instance** — `@voltro/plugin-storage`
850
+
851
+ Upload, finalize, resumable and multipart tickets require the exact storage plugin instance. Handlers reject tickets from other instances before provider access. Direct signing-helper callers must supply the instance name derived from their storage configuration.
852
+ - **A stored cadence carries its row tenant into the transaction** — `@voltro/plugin-ai-flows`, `@voltro/cli`
853
+
854
+ Stored flow cadences carry their row tenant into the complete transaction context, keeping the run, queued workflow receipt and start intent on that tenant's store. Code-defined cadences still require an explicitly tenant-bound context. This does not provide discovery across physical tenant databases.
855
+ - **The tenant namespace directory no longer drifts on every boot**
856
+
857
+ Declare the tenant namespace directory primary key as an ID so database introspection does not propose a spurious type change during startup. Namespace ownership continues to use its deterministic hash.
858
+ - **A tenant-scoped subject gains no admin authority** — `@voltro/protocol`
859
+
860
+ Tenant-scoped subjects preserve existing scopes without granting admin authority when scopes are absent. Persisted workflow identities remain without permissions until authority resolution; tenant placement and audit attribution do not grant authorization.
861
+ - **A terminal run refuses body re-entry** — `@voltro/workflow`
862
+
863
+ Refuse body re-entry when the resident run is already succeeded or failed, including after an engine loses its cached result. After run-history retention, the durable parent-closure record still prevents repeating a completed execution. Preserve terminal ownership instead of rerunning side effects or replaying truncated observation data. Generation-fenced suspended wakeups remain separate work.
864
+ - **A generated client refuses a directory it did not write** — `@voltro/cli`
865
+
866
+ `voltro build api --target typescript --out <dir>` refuses a directory that holds files voltro did not generate, instead of overwriting them. Every name the package emits — `package.json`, `tsconfig.json`, `src/index.ts`, `src/types.ts`, `src/errors.ts` — is a name an app already uses, so `--out ./src` silently replaced an app's own files and then dropped the generated-directory marker on top, which also prunes the directory from the dev watcher. The refusal names the files it found and the way out, and happens before anything is written; a directory that is absent, empty, or already carries the marker is written as before.
867
+ - **Dev and start share one HTTP rewrite arithmetic** — `@voltro/cli`
868
+
869
+ `voltro dev` and `voltro start` share HTTP rewrite arithmetic: `/api` with `rewrite: '/'` sends `/tenants` upstream instead of `//tenants`, preserving query strings. The dev API WebSocket proxy preserves the browser's Host and Origin and retains query parameters when mapping `/ws/<name>` to `/ws`; foreign origins remain refused. Dev proxy contexts match path boundaries (so `/apix` does not match `/api`) and choose the longest HTTP prefix. The bearer-authenticated inspect channel retains its upstream Host. Projects can remove workarounds that existed solely for the doubled slash or rewritten WebSocket Host.
870
+ - **External schedule triggers get an endpoint that exists, and their own token** — `@voltro/runtime`, `@voltro/cli`
871
+
872
+ `trigger: 'external'` schedules are now driven through `POST /_voltro/schedules/:name/trigger`, mounted on both boot paths by one shared builder. The generated manifests previously targeted `/_voltro/schedule/<name>/fire`, a path no version of the framework has ever served, so every CronJob this framework produced answered 404 — and the test that should have caught it asserted the same wrong URL. The endpoint now spells its path in exactly one place, which the manifest generator imports. It is gated by `VOLTRO_SCHEDULE_TRIGGER_TOKEN`, minted per project by `voltro dev` and fail-closed everywhere else (`503` with the remedy until an operator sets it). That credential is deliberately separate from the inspect token, which the old path required: the inspect pair also authorises `plugin-governance`'s irreversible personal-data erasure, migration rollbacks and row writes, and an organisation that mandates CronJob objects for governance reasons must not have to mount that into every cron pod.
873
+ - **Tool cancellation interrupts the running Effect body** — `@voltro/ai`
874
+
875
+ SDK tool cancellation interrupts running Effect bodies in their supplied runtime. Tool handlers receive the SDK invocation ID and abort signal. MCP discovery preserves execution.taskSupport, rejects unknown requirements, and refuses task-required tools at both mount and execution because the client does not negotiate task execution.
876
+ - **A tenantless system context selects no default database** — `@voltro/cli`
877
+
878
+ Permit tenantless system context construction under residency and namespace isolation without selecting a default database. Database-backed capabilities refuse until explicit transaction(work, { tenantId }) or storeForTenant(tenantId) selection. Region and namespace refusals remain enforced; no primary-store fallback is introduced.
879
+ - **Workflow admission revisits healthy queues when discovery stalls**
880
+
881
+ Physical workflow admission revisits healthy queues when namespace discovery stalls on an unavailable later page. Admission, control recovery and observation sweeps share the cursor restart rule while retaining incomplete-discovery diagnostics.
882
+ - **A delayed admitted start transfers its input in one transaction** — `@voltro/workflow`
883
+
884
+ Delayed admitted starts now use a shared transaction-only input transfer that checks every selected input's workflow, tenant and dead-letter state before binding receipts and consuming the selection. An existing completed transfer is reused before reading a reused queue slot; partial or contradictory transfers refuse. This does not yet repair the direct size-closing batch arrival path.
885
+ - **A failed batch drain postpones the whole selection, not the first input** — `@voltro/workflow`
886
+
887
+ Failed batch drains now persist retry counters and one shared jittered deadline for the entire still-matching selection in one resident transaction. Previously only the first input was postponed, allowing the remaining inputs to be drained as a different batch. A concurrent changed selection is not overwritten; retry-persistence failures are logged explicitly.
888
+ - **Observe accepted queue executions for blocking runtime starts** — `@voltro/runtime`, `@voltro/cli`
889
+
890
+ Blocking workflow starts now follow their queued caller receipt through acceptance and completion instead of refusing deferring controls or submitting the arrival payload again. Share receipt outcomes with wait(handle) and preserve the original engine Exit for blocking results. The shared CLI gate/facade/drainer path is verified with a fresh gate and a real memory engine; mutation-transaction waits remain refused until after commit.
891
+
892
+ Execution identity derivation validates the assembled workflow payload, not the batch arrival-item schema. This prevents the admission drainer from rejecting a valid batch before dispatch; multiple waiting arrivals receive that batch's single execution result.
893
+
894
+ Run-history replay detection happens after admission. Batch starts and children no longer derive an execution identity from an unassembled item, and a historical run cannot bypass the current admission decision.
895
+
896
+ Delayed `start(..., { at, wait: true })` also follows its original receipt through the due-time arrival and subsequent flow-control deferrals before observing the accepted execution. It does not block a database transaction; mutation-transaction waits remain refused.
897
+ - **A control host accepts a caller-owned `requestId` for retries** — `@voltro/runtime`, `@voltro/cli`
898
+
899
+ Custom resident workflow-control hosts accept caller-owned `requestId` options for cancel/resume retries, preserving the original generation binding through facade wrappers. Engine-only hosts refuse explicit identities; built-in resident host integration remains unfinished.
900
+ - **Cancellation reports true only for a confirmed observation** — `@voltro/cli`
901
+
902
+ Operator and sweep cancellation delegate to the workflow control facade and report true only for a confirmed cancelled observation. They no longer independently overwrite run state, emit terminal events, close children or discard engine messages after an unconfirmed interrupt. Missing runtimes refuse; cancellation reasons are diagnostic logs, not durable delivery receipts. Durable control recovery and follow-up delivery remain in progress.
903
+ - **A confirmed cancellation stays confirmed after the body finishes** — `@voltro/cli`
904
+
905
+ The shared run canceller observes an explicit existing control receipt even after its run became terminal. A confirmed cancellation no longer turns into false merely because the body finished before the producer retried. A terminal run with an unknown request ID does not create another control request. Confirmation replay is not exactly-once delivery of producer follow-up work.
906
+ - **A clean suspended body is cancelled under its generation lock** — `@voltro/workflow`, `@voltro/cli`
907
+
908
+ Confirm cancellation of a clean suspended body under its resident generation lock, without issuing an execution-ID-only engine command. The control adapter and parent-close recovery atomically settle the receipt, run, terminal event and admission release; later engine wakes cannot revive the cancelled body. Running or replacement generations and runs without an accepted cancellation remain unchanged. General resume delivery remains unfinished.
909
+ - **An executing body validates a control request before interrupting** — `@voltro/workflow`
910
+
911
+ Executing workflow bodies now share the suspended-entry control-request validator: only pending, schema-valid cancellation requests for the exact generation trigger interruption. Settled pages are skipped; malformed records report a read failure and cannot fabricate cancellation.
912
+ - **A control receipt keeps the tenant its run generation proved** — `@voltro/workflow`, `@voltro/cli`
913
+
914
+ Resident workflow control receipts retain the tenant proven by their original run generation. A replay or later run deletion cannot replace that owner. Unknown ownership remains distinct from an explicit null tenant, and a replacement generation cannot authorize an older intent. This provides durable ownership evidence for receipt authorization; it does not yet expose a public receipt-read RPC.
915
+ - **Control receipts are readable through `useWorkflow().readControl()`** — `@voltro/protocol`, `@voltro/client`, `@voltro/cli`
916
+
917
+ Expose workflow control receipt observation through a read-only built-in query and useWorkflow().readControl(target). Authorization uses the retained receipt owner rather than a current run row, refuses unknown ownership, and does not submit another control. The hook closes its stream after one snapshot and preserves typed query denials. Host receipt-reader wiring and physical resident-store routing remain required.
918
+ - **Control settlement writes a `control-settled` run event** — `@voltro/workflow`
919
+
920
+ Resident control settlement writes a `control-settled` run event atomically with its receipt and owned transition, preserving the original request ID, generation and cause. Replays do not duplicate events; already-terminal observations are distinguished from confirmed cancellation or resume. Event-write failure rolls back settlement instead of silently losing its audit evidence.
921
+ - **Control results and snapshots share browser-safe protocol schemas** — `@voltro/protocol`, `@voltro/runtime`
922
+
923
+ Workflow control results and snapshots have shared browser-safe protocol schemas, with runtime types derived from them. Resident submit/read adapters are validated against the acknowledgement contract and requested operation, workflow, execution and caller-supplied receipt identity; contradictory or mismatched results refuse without engine fallback.
924
+ - **A delayed start persists its submission before engine I/O** — `@voltro/workflow`
925
+
926
+ Admitted delayed starts now persist their immutable submission, bind caller receipts and consume selected queue inputs atomically before engine I/O, reusing the admission's existing slot. They share prepared execution and lost-acknowledgement recovery with controlled drains, including a delayed arrival that completes a batch. Automatic failure-source wiring and terminal receipt outcomes remain unfinished.
927
+ - **A deferred arrival forwards its caller receipts atomically** — `@voltro/workflow`
928
+
929
+ Delayed arrivals deferred into another queue slot now forward unbound caller receipts and consume the source atomically, including bursts exceeding one page. Concurrent drainers re-read the source; invalid ownership rolls back the transfer. Queue writers allocate string identities independently of store defaults. Delayed direct dispatch still needs durable submission integration; this change does not claim end-to-end failure-handler delivery.
930
+ - **A dropped `onFailure` start is not acknowledged as accepted** — `@voltro/cli`
931
+
932
+ Do not acknowledge dropped or singleton-skipped onFailure starts as accepted failure reports. Both dev and serve now propagate a configured failure handler's unavailable or rejected admission back to the queue drainer instead of discarding its false result. No onFailure declaration remains an explicit absence, not an endlessly retried delivery error. Durable submission failure-report storage and terminal-run report recovery remain unfinished.
933
+ - **A rejected failure delivery does not notify its own `onFailure`** — `@voltro/workflow`, `@voltro/cli`
934
+
935
+ Pre-engine rejection of a failure-delivery submission records the rejection without recursively notifying that handler's own onFailure. Ownership follows the resident report and bound receipt; ordinary calls to the same workflow retain their configured notifications.
936
+
937
+ The admission drainer now discovers stored failure reports after restart and reconciles their receipts. Handler drops/skips and proven pre-engine input rejections retry through a new durable receipt generation with bounded backoff, preserving the original report and every previous receipt. Unknown engine acceptance retains its prepared submission; only a matching acknowledged submission records acceptedExecutionId. Recovery pages reports and isolates malformed metadata without logging payloads. Missing, foreign and dead-lettered arrivals report a safe reconciliation reason instead of claiming queued; absence alone cannot trigger a duplicate start. This is not complete coverage of all failure sources: recovery policy for missing/discarded arrivals and report preservation under coalescing handler controls remain unfinished.
938
+ - **Route host workflow controls through resident receipts** — `@voltro/cli`
939
+
940
+ Dev and serve persist cancellation and resume controls in the execution's resident store and expose read-only receipt observation. Resume uses the bound engine journal after acceptance commits; a dispatch failure retains the request ID instead of losing the caller's handle. Custom engines without the dispatcher refuse resume before acceptance.
941
+ - **Route inspect workflow messages through the resident facade** — `@voltro/cli`
942
+
943
+ Inspect signal and tracked-update actions use the same resident execution resolver and recorder as application workflow messages instead of writing through the boot's primary store. A missing runtime refuses explicitly, and tracked updates forward their requested timeout. Other resident operator controls and run-listing integration remain separate work.
944
+ - **Inspect resume reports `resumed` only for a confirmed result** — `@voltro/cli`
945
+
946
+ Inspect resume no longer overwrites a run as running or emits run-resumed after an unconfirmed engine command. It delegates to the workflow facade and reports resumed:true only for a confirmed running resume result; false does not prove rejection. A missing runtime refuses. Entry recording remains the sole state/event writer; the complete control receipt response across inspect consumers remains unfinished.
947
+ - **The staleness diagnostic no longer deletes on age alone** — `@voltro/cli`
948
+
949
+ The staleness diagnostic no longer deletes engine messages based only on a terminal run row and message age: neither condition fences a replacement execution generation. Unread-message and shard-owner reporting remains active. Generation-fenced cleanup is still required; messages may remain pending rather than being deleted without ownership proof.
950
+ - **Resolve physical workflow ownership before operator row access** — `@voltro/cli`
951
+
952
+ Inspect cancel, resume, discard and retry, plus the shared sweep canceller, resolve the run's validated physical store instead of reading the primary store. Discard acknowledges resident failed runs idempotently. Missing or ambiguous ownership refuses without primary fallback. External suspend still refuses without accessing storage. Durable control delivery, fresh retry admission and resident run-listing remain separate integration work.
953
+ - **Operator suspend refuses instead of faking a suspended run** — `@voltro/cli`
954
+
955
+ Operator suspend now explicitly refuses instead of marking a still-executing run as suspended and emitting a false lifecycle event. External suspension has no cooperative engine checkpoint; use workflow-owned suspension such as suspendOnFailure. No run rows, events or dashboard updates are written on refusal.
956
+ - **A terminal generation persists its parent-close intent** — `@voltro/workflow`, `@voltro/cli`
957
+
958
+ Each committed workflow terminal generation now persists a resident parent-close intent in the same transaction. Retry retains one intent; rollback leaves none. The workflow schema includes the new table. This preserves follow-up work but does not yet implement its recovery worker or late-child admission guard.
959
+ - **A vanished child row is a failed decision, not a cancellation** — `@voltro/workflow`
960
+
961
+ Parent-close processing no longer reports a child as cancelled or emits a cancellation event when its run row disappeared before the cancellation write. It returns a failed decision and retains the diagnostic instead of fabricating confirmation. This does not yet establish generation-fenced or durable cancellation delivery.
962
+ - **Parent close visits every child page, not the first thousand** — `@voltro/workflow`
963
+
964
+ Parent-close processing visits all child-run pages by default instead of silently stopping after 1000 children. Reads use bounded run-ID keyset pages; an explicit limit remains a total visit bound. Invalid limits and unreadable later pages fail rather than returning an apparently complete result. Pagination does not establish durable cancellation delivery or generation fencing.
965
+ - **Parent-closure intents recover through the admission drain** — `@voltro/workflow`, `@voltro/cli`
966
+
967
+ Recover parent-closure intents through each resident admission drain, independently of pending arrivals. Persist open-child evidence across scan pages and complete only an entirely terminal scan; isolate malformed records without logging their contents. Idle CLI drains request another probe within five seconds, respecting the configured base interval floor. Both built-in boot paths remove their interrupt-and-write parent-close callbacks: a child finalizer must end before resident recording confirms cancellation. Cross-store enumeration remains unfinished.
968
+ - **Parent-close staging commits child receipts with its cursor** — `@voltro/workflow`
969
+
970
+ Add resident parent-close staging: bounded child pages and their generation-bound cancel/terminate receipts commit with the recovery cursor. Retries retain the first target and cause; abandon is untouched. Only a whole scan without open bound children completes the closure, backed by the late-entry guard; accepting a control request does not claim the child stopped.
971
+ - **Parent closure waits for an accepted child control to settle** — `@voltro/workflow`, `@voltro/cli`
972
+
973
+ Keep parent closure incomplete while an accepted child control remains unsettled, even if the child row disappears. Control acceptance stores an indexed parentClosureId derived from its immutable cause; completion checks decoded receipts across bounded pages instead of treating a missing child or SQL NULL comparison as terminal evidence. Apply the framework table plan before starting the new server.
974
+ - **A child run retains `parentGeneration` beside its parent ID** — `@voltro/workflow`
975
+
976
+ Recorded child runs can retain parentGeneration alongside parentExecutionId. Ambient recorded workflow nesting captures both; an explicitly different parent never borrows the ambient generation. Parent-close staging ignores children bound to another parent generation and refuses unbound cancellable children. Explicit start-context propagation and late-child admission enforcement remain unfinished.
977
+ - **Route workflow admission and recovery to resident stores** — `@voltro/cli`, `@voltro/workflow`
978
+
979
+ Dev and serve discover registered tenant queues after restart and route new arrivals to their current resident store. All queues share the deployment pool coordinator, including release recovery. Operator pauses remain deployment-wide. Unreadable or reassigned placements are reported instead of being treated as empty, and local finish scans never borrow runs from another store.
980
+ - **A queued start keeps caller identity, tenant, trace and parent** — `@voltro/cli`
981
+
982
+ Queued workflow starts preserve caller identity, tenant, trace and parent metadata through the shared dev/serve admission drainer. Failed start-context writes refuse engine submission; failed reads or malformed non-null identity refuse execution instead of selecting the system subject. Cache entries require a successful durable write and are scoped by workflow name and execution ID. Diagnose workflow.startContext.persist failed / workflow.startContext.resolve failed with the workflow name and execution ID; repair storage access or restore valid context before retrying, never delete caller context as a workaround. This does not make transaction-to-workflow handoff atomic or protect against missing context rows.
983
+ - **A replay's own outcome survives `redrive` and `resume-from-step`** — `@voltro/cli`
984
+
985
+ `workflows redrive` and `resume-from-step` no longer overwrite run state with `running` after the journal adapter responds. A fast replay's success or new failure, completion timestamp and error remain intact; a replay not yet observed by the recorder keeps its previous state. Dev and production still record the accepted replay request on the timeline, without claiming that execution started or finished. This does not complete cancellation/replay reconciliation or atomic failure-report persistence.
986
+ - **A class-based recording store keeps its method receiver** — `@voltro/workflow`
987
+
988
+ Preserve the recording store's method receiver when reading prior workflow steps. Class-based custom stores no longer lose replay-shape checking because their query method was called without its instance. Reentry regression tests now exercise a class-based transactional store and retain the actual predicate evaluator.
989
+ - **Recover accepted controls across resident workflow stores** — `@voltro/cli`
990
+
991
+ Dev and serve scan persisted controls across declared stores and registered tenant namespaces without requiring a repeated client command. Bounded pages preserve original resume bindings and isolate malformed controls or unavailable placements with safe diagnostic identifiers. Shutdown stops recovery before disposing the workflow engine; receipt acceptance still does not imply confirmed execution.
992
+ - **Resident workflows validate their cluster delivery identity on entry**
993
+
994
+ Persist and validate the cluster delivery identity at resident workflow entry. Refuse foreign requests and unrecognized restart markers before invoking the body, including while a replacement is running. Missing cluster bindings require reconciliation; authorized operator redrive remains unfinished.
995
+ - **Keep workflow finish scans resident and exhaustive** — `@voltro/cli`
996
+
997
+ Admission gates inspect running workflows in their own physical store across all pages instead of consulting a potentially deployment-wide facade. Another placement's run cannot become this gate's timeout target, and a first page does not hide later running rows.
998
+ - **Workflow history Inspect reads honour the run's declared placement**
999
+
1000
+ Workflow run-history Inspect reads accept a validated physical placement for runs, statistics, steps, events, and children instead of silently reading the primary store; unavailable ownership returns HTTP 409.
1001
+ - **Visit resident workflow journals and record stalls in their owning store** — `@voltro/cli`, `@voltro/workflow`
1002
+
1003
+ Cancellation and staleness sweeps now discover resident stores in dev and serve, without duplicating global engine diagnostics. Each journal retains its own cancellation watermark; partial placement failures remain visible while reachable stores continue. Stall events use the resident recorder so deduplication reads the evidence where it was written. Control recovery revalidates queued placement identities before accessing their control tables.
1004
+
1005
+ Staleness scans advance by `(startedAt, runId)` within each physical store instead of repeatedly examining only its oldest page. Equal timestamps and deleted earlier rows do not skip later runs. Failed candidate reads retain the cursor; exhaustion starts a new census. Low-level callers can pass the returned `nextRun` as `afterRun` on their next visit to that store.
1006
+ - **Bind queued cancellation to its resident request**
1007
+
1008
+ Expose resident-transaction queued cancellation acceptance and observation helpers, and connect them to both API hosts. Retained requests remain readable and retryable after their source receipt is removed; foreign tenant or target identities refuse. Both host methods reject anonymous callers before physical discovery, including tenant-less targets. Observing a missing request does not create one, and accepted cancellation does not claim engine termination.
1009
+ - **Read queued workflow receipts in their resident placement** — `@voltro/cli`
1010
+
1011
+ Dev and production hosts resolve queued waits through physical receipt discovery and reread the full receipt under the resident admission lock. Caller identity and workflow/tenant ownership must match; duplicates, incomplete discovery and unavailable stores refuse observation instead of reading only the primary store. This changes receipt observation, not queue dispatch or cancellation.
1012
+ - **Reconcile lost workflow start acknowledgements from resident execution evidence** — `@voltro/workflow`
1013
+
1014
+ Admission recovery confirms an already recorded execution without redispatching or acquiring capacity again, including after automatic submission retry has stopped or run history has been retained out. Durable terminal evidence survives history cleanup; current run state takes precedence over older terminal generations. Repeated acceptance callbacks return the existing confirmed identity. Preparation without a dispatch attempt is not acceptance evidence.
1015
+ - **Return the engine execution identity from workflow retry** — `@voltro/cli`
1016
+
1017
+ Workflow retry returns the engine's execution ID on submission instead of waiting for the body and misreporting its string output or `"started"` as an ID. The declared idempotency key still governs deduplication; this correction does not establish fresh-execution ownership or complete operator retry integration.
1018
+ - **Cancel and resume hooks accept a caller-owned control intent** — `@voltro/protocol`, `@voltro/client`, `@voltro/cli`
1019
+
1020
+ Workflow cancel/resume hooks accept a caller-owned control intent as their second argument. Its requestId and optional cause survive the public RPC schema and the shared authorized dev/serve handler. Reuse the same identity after an unknown transport outcome; explicit identities require a resident control adapter. This does not add the public receipt-read RPC or generation-fenced resume delivery.
1021
+ - **Run recording encodes the declared input before entry** — `@voltro/workflow`, `@voltro/cli`
1022
+
1023
+ Workflow run recording encodes the full declared input before the entry transaction, preserving transformed schemas in stored runs and their durable failure reports. Both dev and serve provide the full workflow encoder, including batch envelopes; admission item codecs are not used for run rows. Encoding failure prevents body execution. Custom recorder hosts with transformed inputs supply encodePayload; hosts without it promise JSON-only input.
1024
+ - **Concurrency keys are JSON tuples, which PostgreSQL accepts** — `@voltro/workflow`
1025
+
1026
+ Encode concurrency ledger keys as collision-free JSON tuples instead of NUL-separated text, which PostgreSQL rejects. Pool names and keys containing control characters remain distinct; application key functions do not change.
1027
+ - **Suspension checks the current runner generation** — `@voltro/workflow`
1028
+
1029
+ Workflow suspension and crash-loop parking now check the current resident runner generation in their transaction. A late old runner cannot replace a newer running or completed outcome with suspended, emit a suspension event or report a stale suspend-on-failure error. Suspension commit failures propagate instead of pretending the run was parked. Successful suspension retains admission capacity and the reclaim counter.
1030
+ - **Body completion releases a reservation despite a lost ack** — `@voltro/workflow`
1031
+
1032
+ Body completion releases admission reservations bound through dispatched prepared submissions even when the dispatcher acknowledgement is lost. Undispatched proposals remain reserved; foreign reservation ownership rolls back the whole terminal transaction. Recovery confirms the same prepared execution without executing the completed body again. An admission acknowledgement also reads the current resident run outcome before linking its lease: a terminal run releases it using the recorded completion timestamp, while a newer running generation keeps capacity. Missing or malformed terminal timestamps refuse acknowledgement instead of inventing a release time. Admission writers allocate string ledger IDs explicitly, including on schema-free memory stores.
1033
+ - **An unrecorded control submission stays unknown** — `@voltro/runtime`
1034
+
1035
+ An unrecorded resident control submission preserves unknown with a null receipt ID even when the caller proposed an identity. Response target validation still rejects other workflows, executions, operations and foreign receipt IDs; receipt observation cannot silently lose its requested identity.
1036
+ - **A version rejection commits failure, arrival and release together** — `@voltro/workflow`
1037
+
1038
+ A workflow version rejection uses the resident generation-checked terminal transaction: run failure, durable onFailure arrival and admission release commit together. A newer owner cannot be overwritten by the rejecting runner. Failed terminal commits propagate without emitting terminal callbacks or metrics. The application body is never invoked for an incompatible version, and failure delivery retains the run's original stored payload.
1039
+ - **A generated package directory does not restart the dev boot** — `@voltro/cli`
1040
+
1041
+ A generated artifact directory no longer restarts the `voltro dev` boot that writes it. The convention that keeps codegen quiet is `<name>.generated.<ext>` in the FILENAME, and a generated npm package cannot use it: its files must be named `package.json`, `tsconfig.json`, `src/index.ts` to be installable, so every one of them was ordinary watched source and `publicApi: { artifacts: ['typescript'] }` cost a second restart after every surface change — measured, five files matched the watch. The rule moves up a level instead of gaining a fourth special case: a directory holding a `.voltro-generated` marker is generated in full and the watcher prunes the whole subtree, so the next generated package needs no change at all. The marker is emitted as part of the package, so the on-demand build and the dev artifact both carry it, and `files` keeps it out of what npm packs.
1042
+
1043
+ Only a POSITIVE marker answer is cached. A directory that is not marked yet becomes marked moments later — the boot creates it and writes the marker as the first file in it, while the watcher is asking about the directory the instant it appears — so caching that "no" pinned the answer taken in the gap and left the package watched for the life of the process.
1044
+
1045
+ ### Internal (no consumer-facing effect)
1046
+
1047
+ - **The primary-key width guard recognises fixed-width admission UUIDs** — `@voltro/database`
1048
+
1049
+ Recognize the fixed-width admission UUID constructions in the primary-key width guard without exempting their entire source file. A negative control keeps unbounded or extended constructions in that same file detectable.
1050
+ - **Six cadence admission regressions pinned against the real store** — `@voltro/plugin-ai-flows`
1051
+
1052
+ Pin six unresolved cadence admission regressions with the real fluent Memory store and public workflow receipt schema: failed reads appear empty, failed inserts still submit work, and non-running receipts count as fresh starts. Explicit expected-failure tests are not fixes; a successful running receipt and persisted run provide the positive control.
1053
+ - **Tenant cadence rollback and intent ownership under real residency** — `@voltro/cli`
1054
+
1055
+ Verify independent tenant-cadence rollback and committed intent ownership, and explicit system tenant selection under real residency wiring. Tenantless background contexts do not fall back to the primary store.
1056
+ - **The cadence tenant-placement failure, characterised end to end** — `@voltro/cli`
1057
+
1058
+ Characterize the unresolved cadence tenant-placement failure through the actual scheduler and shared serving context: code and stored flows fail before submission under the system subject, with a failed schedule record and no orphan run. This is a defect witness, not a fix or a successful cadence execution.
1059
+ - **A flow launch and its admission roll back together** — `@voltro/cli`
1060
+
1061
+ Exercise the public flow launch helper through the shared app-context builder and mutation runner: run, admission observation and start intent roll back together, while a fresh admission drainer discovers committed work without post-commit callbacks. Uses a real memory workflow engine; does not claim action/schedule atomicity or physical-process recovery.
1062
+ - **Durable, replay-safe global pool permits on a shared coordinator** — `@voltro/cli`
1063
+
1064
+ Add a resident restart-capacity adapter using the same persisted ledger validation and coordinator acquisition as original submissions. Consumed, foreign, bound or released restart reservations cannot acquire capacity; no original start submission is consulted.
1065
+
1066
+ Coordinator release persists a terminal tombstone even before acquisition arrives and is replay-safe after lost commit acknowledgement. Cross-store recovery still requires integration before activation.
1067
+
1068
+ Restart release now validates its consumed request and own released, execution-bound admission before writing the coordinator outside the resident transaction. Retained request/lease evidence survives run-history removal; missing or contradictory evidence cannot release capacity. Periodic recovery and production boot wiring remain unfinished.
1069
+
1070
+ Original submission release validates either its released execution-bound admission or its durable pre-engine rejection report and unbound lease. Held leases remain pending; absent reports, unbound unexplained releases and foreign execution bindings refuse before coordinator access. This private adapter is not yet wired to periodic recovery or production dispatch.
1071
+
1072
+ Add bounded resident release-census pages for both owner kinds, with explicit per-owner failures and restartable cursors. Revisiting a completed cycle recovers releases committed behind the cursor, including lost coordinator acknowledgements. The page primitive is tested against memory and the PostgreSQL restart lifecycle; periodic scheduling and physical placement integration are not yet activated.
1073
+
1074
+ The shared flow-control boot builder now forwards the host's submission-capacity checker into its gate, including queued recovery. A real memory-engine test verifies that denied capacity performs neither execution-key preparation nor body entry and that later capacity resumes the stored submission. Production hosts still need the durable placement/coordinator binding before enabling this dependency.
1075
+
1076
+ Verify durable, replay-safe global pool permits on a shared coordinator without copying resident payloads or caller context. Concurrent reservations and lost commit acknowledgements retain one slot owner. Permit identity includes the resident owner kind: restarts cannot reuse original submission permits, even with the same request ID. Both owner kinds count against the same pool. Released resident admissions refuse capacity acquisition, including the path without a shared pool. Admission and terminal-release integration remain separate work; this foundation is not an enabled deployment-wide pool fix.
1077
+ - **Resolve library output checks against the actual build root** — `@voltro/build`
1078
+
1079
+ Resolve declaration integrity checks, TypeScript-specifier rewrites and copied assets from Vite's resolved root and output directory. A library build launched from another directory must not inspect or mutate that caller's unrelated dist tree. Exercise both relative and absolute output directories with real Vite builds and deliberately incomplete declaration bundles.
1080
+ - **Bounded namespace discovery on the shared residency binding** — `@voltro/cli`
1081
+
1082
+ Add bounded registered-namespace discovery to the shared residency binding. Revalidate physical ownership, preserve unreadable and unfinished sources as partial results, and retain exhausted-source cursors across pages. This is a reader foundation, not an automatic workflow drainer or a census of unregistered SQL namespaces.
1083
+ - **Terminal-entry fencing and operator redrive, characterised end to end**
1084
+
1085
+ Characterize the unresolved interaction between resident terminal-entry fencing and operator-requested workflow journal redrive with a real SQLite cluster engine. This expected-failure test is not a fix.
1086
+ - **Internal storage object-lifecycle and quota persistence substrate**
1087
+
1088
+ Add internal storage-operation and object-generation persistence, plus transactional put-intent, reference, grant and quota composition. Replay, rollback, shared tenant quota and delayed-delete tests cover the substrate. Public StorageService integration is not yet complete; this entry does not claim retry-safe service puts or deletes.
1089
+ - **The doctor's exit expression keeps its flag order** — `@voltro/cli`
1090
+
1091
+ The doctor's exit expression lists the public-api flag ahead of the webhook flag — one of the four guards that read that line by regex expects the webhook flag last.
1092
+ - **Cancel and resume outcomes pinned against the real engine** — `@voltro/cli`
1093
+
1094
+ Pin cancellation/resume outcomes against the actual memory workflow engine: missing executions remain unknown and an already successful execution retains its output. These are now normal regression tests for the facade result; durable request delivery and recovery remain separate unfinished work.
1095
+ - **Capture a cluster workflow delivery's request identity at the boundary**
1096
+
1097
+ Capture each cluster workflow delivery's request identity and restart marker at the handler boundary. Stage restart markers atomically with expected-reply resets. This provides delivery metadata, not resident retry authorization.
1098
+ - **A request-ID-only reset can remove a newer terminal reply** — `@voltro/workflow`
1099
+
1100
+ Characterize the cluster message-storage reset boundary: a delayed request-ID-only reset can remove a newer terminal reply and make the request unprocessed again. This is evidence for generation-bound resume work, not a shipped fix or a safe resume adapter.
1101
+ - **Persist workflow execution routing separately from resident caller context** — `@voltro/cli`
1102
+
1103
+ Add a private execution-to-placement claim and caller-context adapter. The coordinator stores routing metadata only; caller identity and trace context remain resident. Competing or changed placement claims refuse, failed resident writes can retry against the original claim, and missing routing or context refuses execution. Production boot/recorder integration remains unfinished; this is not an activated residency fix.
1104
+
1105
+ Physical placement resolution uses strict point reads of persisted namespace registrations and current resident ownership. Stable identities distinguish physical source names and namespace registrations without copying tenant identifiers to the execution coordinator. Missing, unready, colliding, ambiguous or moved ownership refuses; resolution does not provision or claim namespaces. Explicit tenantless identities bind to the declared primary root, while tenants without namespace isolation bind to their selected physical root. Reads revalidate the loaded caller against its execution placement, including changes of tenant home or isolation mode. Production host/recorder integration remains open.
1106
+
1107
+ The private resolver can return the validated caller, resident store and placement ID together, so recorder composition need not independently route the caller again. A real memory-store recorder test writes its event only to that resolved namespace; this does not yet prove production engine/recorder wiring.
1108
+
1109
+ Verify claims and fresh-reader context resolution against separate coordinator and resident PostgreSQL databases, with two independent connections to each and competing namespace owners for one execution. Production boot, engine adoption and multi-process recovery remain separate integration work.
1110
+
1111
+ The shared workflow authority resolver now awaits caller-specific store resolution once per attempt before invoking scope resolution or subject guards. Both consume the same resolved store; lookup failures stop authority evaluation. Production hosts still require the complete resident execution binding rather than selecting the primary store.
1112
+
1113
+ Carry the resolved resident binding through caller and subject spreads using private symbol metadata that JSON persistence omits. An actual memory workflow engine test verifies that authority, context construction and recording share one binding and write only to its store. Discovery erases layer service types; the test restores the supplied engine service type without asserting an infallible layer. Production host activation remains unfinished.
1114
+
1115
+ The shared app-context builder has an internal workflow entry consuming that binding, checking the workflow/execution identity and bypassing repeat placement/provisioning. The engine test now uses this real builder and verifies committed and rolled-back transactions on the resident store. Dev and serve select this entry for executions; resident controls and discovery remain incomplete.
1116
+
1117
+ Compose host placements with distinct primary and regional source identifiers. A region named `primary` cannot overwrite the deployment primary, and fresh resolvers recover the same resident namespace from durable metadata. This private composition still awaits complete boot and control-operation wiring.
1118
+
1119
+ Resolve execution-qualified message targets through the persisted placement and resident caller before reading the run. Verify the workflow, execution and optional run ID together. A real memory-store signal test places identical run identifiers in coordinator and resident stores and verifies that only the resident recorder receives the event. Run-ID-only discovery and production control wiring remain unfinished.
1120
+
1121
+ Private Run-ID discovery now searches configured roots and registered namespaces, then revalidates the result against its durable execution placement. It refuses duplicate identities, incomplete discovery and exhaustion of its bounded scan (100 pages of 100 namespaces per source), rather than treating a partial result as unique or absent. Tests cover a match after the first page, duplicate root/namespace IDs, and an unfinished namespace despite an earlier root match. Production control wiring remains unfinished.
1122
+
1123
+ Register the routing table under the workflow schema gate through a table-only module, without importing execution wiring into schema assembly. The declarative migration test verifies exactly one create-table operation when the routing table is missing and its absence for apps without workflows. Schema registration alone does not prove production routing.
1124
+
1125
+ Dev and serve now construct the placed caller-context adapter, resolve authority from its store, build the execution context without rerouting, and bind recording transactions and dormancy wakeups to that same resident store. Serve receives named regional stores from its command wrapper. This is an incomplete host integration: control operations, resident queue discovery and recovery still require their matching readers before release. Running handles use their execution ID as `id`; qualified target validation accepts that exact alias while rejecting unrelated IDs.
1126
+
1127
+ Both hosts now resolve facade signal, suspending-signal completion and update targets to the resident store and recorder. Target resolution supports qualified executions, run IDs and execution IDs without a workflow name; only a fully completed absent Run-ID search permits checking the same value as an execution-ID alias. Discovery failures never trigger a fallback. Additional target identities are validated together. Other controls, inspection, queue discovery and recovery remain incomplete.
1128
+
1129
+ Verify the live serve composition with a real local HTTP listener and memory workflow engine: an HTTP action starts a workflow in a regional namespace, authority reads that namespace's permission data, a second HTTP action sends the signal using only the execution ID, and the workflow succeeds. Caller context, run history and signal events remain absent from the primary store. This does not prove SQL/cluster restart recovery or resident queue draining.
1130
+ - **A transaction boundary for resetting an exact set of journal replies**
1131
+
1132
+ Add an internal transaction boundary for resetting an exact set of observed workflow journal replies, with SQLite rollback and PostgreSQL concurrent-writer contracts. Resident retry authorization and engine wiring remain unfinished.
1133
+ - **Start context, failure report and queue handoff under retry** — `@voltro/cli`
1134
+
1135
+ Test durable start-context resolution together with failure-report recording and queue handoff: original identity and correlation survive retries, execution authority is stripped, incident delivery retains its first recipient, and a distinct later incident can use the current handler declaration.
1136
+ - **Verify workflow start acknowledgement recovery through engine and recorder** — `@voltro/cli`
1137
+
1138
+ Exercise real in-memory engine completion followed by a lost dispatcher acknowledgement. Recovery resolves the caller receipt without redispatch or capacity acquisition, including after terminal run history is removed.
1139
+ - **Locate queued receipts across registered workflow placements** — `@voltro/cli`
1140
+
1141
+ Share the bounded physical-store scanner between run and start-receipt lookup. Receipt discovery needs no execution ID, exhausts registered placements before returning a unique match, and refuses duplicate identities, incomplete discovery and unavailable stores. It returns routing metadata only; callers must authorize and reread the full receipt under its resident admission transaction. Public queued control integration is not activated by this internal locator.
1142
+
1143
+ Facade tests follow explicit, blocking, child and delayed queued waits through fresh physical resolvers to one real memory-engine execution. Regional cases assert positive resident storage and absence of payload/context copies in either root; the explicitly resident gate is not a proof of deployment-wide drainer routing.
1144
+ - **Admission-pool ownership checked against real memory stores** — `@voltro/cli`
1145
+
1146
+ Record executable admission-pool checks using real memory stores. Concurrent direct arrivals now have one winner on a shared store. Two remaining counterexamples are explicit expected failures: independently placed queues multiply the pool, and a direct arrival takes the drainer's slot while its engine submission is pending. These tests do not claim a complete concurrency guarantee.
1147
+ - **Preserve queued cancellation outcomes at the resident transaction boundary** — `@voltro/workflow`, `@voltro/runtime`
1148
+
1149
+ Add the private queue-cancellation transaction and its cancelled receipt outcome. Pin all receipt pages, rollback, reused queue slots and both takeover orderings. Verify atomic rollback and committed receipt replay against SQLite as well as the transaction fixture. Two PostgreSQL connections verify both competing takeover/cancellation orderings with observed database blocking and preservation of a replacement queue occupant. Failure reconciliation must not recreate explicitly cancelled handler arrivals. Public queued-control routing and cancellation follow-through after submission takeover remain to be integrated.
1150
+ - **Persist queued cancellation targets atomically with queue decisions** — `@voltro/workflow`
1151
+
1152
+ Add private queued-control request storage that retains original caller identity, ownership, cause and acceptance time. Queue cancellation and request acceptance share one transaction; a submission target records intent without claiming engine cancellation. Recorded entry binds retained requests across all pages before application code and settles matching cancellation, control receipts and local capacity atomically. Acceptance after entry binds that existing generation under the same lock for its resident monitor; replay never chooses a replacement generation. The table is in the workflow schema inventory. Public acceptance and restart recovery are not activated yet.
1153
+
1154
+ Private read-only observation preserves confirmed versus already-terminal evidence and reports unresolved generations or stopped submission retries as unknown. It cannot bind or dispatch; submission rejection uses the same ownership validation as start-receipt waiting.
1155
+
1156
+ An unresolved submission stop also prevents a replacement entry of the same resident execution, without retargeting or confirming the original generation's control. The replacement terminal event is separate evidence; settled controls never become new stop requests.
1157
+
1158
+ A two-connection PostgreSQL regression verifies rollback and commit of replacement state, terminal event, parent closure and local capacity release while the original control remains unconfirmed. Replaying entry does not duplicate the event.
1159
+
1160
+ Private acceptance also settles a clean suspension in the same transaction, including a retry against its original bound owner. It does not need an engine wake; missing run evidence remains unknown and status reads stay read-only.
1161
+
1162
+ The suspended-acceptance branch is verified independently on PostgreSQL for atomic rollback, committed evidence through a second connection and idempotent retry.
1163
+ - **Pin recovery fairness and shutdown completion** — `@voltro/cli`
1164
+
1165
+ Verify that an unreadable later namespace page cannot starve healthy roots and that shutdown awaits an in-flight control dispatch. Negative controls remove each protection and fail the corresponding test before restoring the implementation.
1166
+
1167
+ Exercise cancel and terminate recovery against real resident transactions: only the clean suspended owner may settle, release admission capacity, and persist terminal/control events plus parent-closure work. Running bodies, uncleared entry markers, newer generations, and repeated sweeps must not duplicate or manufacture those outcomes.
1168
+ - **Enumerate workflow recovery placements in bounded pages** — `@voltro/cli`
1169
+
1170
+ Expose declared roots and persisted namespace registrations through the workflow placement resolver with bounded per-source cursors. Preserve placement identity and incomplete-discovery evidence; do not depend on tenants previously seen by the process. Recovery-worker integration remains separate.
1171
+ - **The shared resident workflow control host** — `@voltro/cli`
1172
+
1173
+ Build the shared resident workflow control host adapter and exercise facade-to-body cancellation with a real memory engine. Persist caller-provided request identities, preserve generation ownership on replay, and read terminal receipts from freshly constructed hosts. Production boot integration awaits suspended/resume delivery.
1174
+ - **Resident workflow release recovers across hard process death**
1175
+
1176
+ Verify resident workflow rejection, durable failure handoff, and coordinator pool-release recovery across hard process death before and after the resident commit using separate PostgreSQL databases.
1177
+ - **Isolate restart admission from original workflow start acknowledgement** — `@voltro/workflow`
1178
+
1179
+ Two-connection PostgreSQL coverage verifies competing resident reservations and entry, rollback, terminal recording and local lease release against the declared schema.
1180
+
1181
+ Entry resolves its resident request through the run/token binding captured from the engine envelope, rather than accepting an operator request ID from the caller.
1182
+
1183
+ The recorder consumes the entry transaction's persisted reclaim counter instead of independently recomputing its crash classification from the previous run snapshot.
1184
+
1185
+ Add a resident restart-reservation boundary with immutable journal selection and generation ownership. Competing requests share no old lease, and persistence failure rolls back new capacity. Journal dispatch attempts require the still-held matching lease and original failed generation and are recorded before I/O. A private dispatcher rechecks the owner after capacity acquisition and resets only the captured journal selection; marked resets validate exact execution ownership and the previous delivery token. Repeated reset delivery recognizes its persisted token without deleting newer replies. Private consumption binds the reservation, replacement generation, delivery and audit event atomically; consumed tokens cannot authorize another entry. Restart entry shares the ordinary parent-close check and atomically cancels bound children of closed parents before body execution. The boundary is not boot-activated; engine polling/reconciliation, recorder integration and shared-capacity release remain integration work.
1186
+ - **Compose resident resume binding with exact journal dispatch** — `@voltro/workflow`
1187
+
1188
+ Add a private cluster resume dispatcher that binds the original suspension, persists each dispatch attempt before journal mutation, resets only the bound reply, and polls the runner. It reports an attempt rather than completion; only recorded body entry confirms the receipt. Reset failures preserve the binding and attempt history. Host activation and automatic recovery remain unconnected.
1189
+
1190
+ The real-runner integration test also covers a committed reset followed by poll failure: retry retains the original binding and polls without clearing a new reply or fabricating entry confirmation.
1191
+ - **An execution-only resume can wake a later suspension** — `@voltro/cli`
1192
+
1193
+ Characterize a delayed execution-only resume against the real memory workflow engine: a settled control receipt does not prevent that command from waking a later suspension. This is a regression witness for the required generation fence, not an implementation of safe resume delivery.
1194
+ - **Verify resident resume through the running host** — `@voltro/cli`, `@voltro/sql-sqlite`
1195
+
1196
+ Exercise HTTP resume and receipt observation through a real serve host with a SQLite cluster journal and a separate resident store. Verify replacement entry, original receipt retention, and replay after a second suspension. Give the independent delivery fixture an explicit runner port instead of an offset from another fixture's base.
1197
+ - **Persist original suspension observations on resident controls** — `@voltro/workflow`, `@voltro/cli`
1198
+
1199
+ Add nullable resumeJournal metadata to resident workflow controls and a database-only binding boundary. Retries retain the first committed reply identity; missing evidence waits, replacement generations refuse, and a prior ambiguous unbound dispatch cannot capture a fresh suspension. Automatic engine capture, dispatch and recovery are not yet activated. The framework schema planner adds the nullable column.
1200
+
1201
+ A two-connection PostgreSQL regression test proves the resident lock remains held during observation: replacement entry blocks until the binding commits, then confirms the original request without changing its reply identity.
1202
+ - **Verify resident resume recovery after process death** — `@voltro/cli`
1203
+
1204
+ Kill a real producer after durable resume acceptance with either a rejected journal reset or a lost dispatch acknowledgement after the real reset and poll complete. Boot a successor over separate retained primary, resident, and SQLite journal files. Verify recovery without resubmission, retention of the original journal binding, and unchanged later suspension when the old request is replayed.
1205
+ - **Workflow start recovery across process death at submission**
1206
+
1207
+ Exercise physical workflow start recovery with a durable cluster journal and a fresh serve process after SIGKILL immediately before engine submission or after submission before acknowledgement. Assert the retained submission, execution and caller identity, resident-only records and one successor body execution. This probe does not claim exactly-once arbitrary body-side effects across a body crash.
1208
+ - **Verify suspension reply fencing with a real cluster runner** — `@voltro/sql-sqlite`
1209
+
1210
+ Exercise exact-reply reset and polling against a real SQLite cluster workflow: an old observation cannot clear a replacement suspension, while its own observation can wake it. This is an integration test of the private journal boundary, not activation of durable control delivery.
1211
+ - **Read exact workflow suspension observations from the journal** — `@voltro/workflow`
1212
+
1213
+ Add a private, read-only journal observer that joins the current reply with its exact workflow delivery in one statement. Missing, completed, malformed or marker-mismatched replies do not become resume targets. Integration coverage joins the real SQLite cluster runner, run recording, resident control binding, exact reset and entry confirmation; automatic host recovery remains unconnected.
1214
+ - **Add an ownership-checked suspension reset boundary** — `@voltro/workflow`
1215
+
1216
+ Share the locked expected-reply reset with a private resume-specific boundary that checks workflow, execution, run procedure, restart marker and the Suspended envelope before writing. Completed, malformed or changed replies cannot be resumed through it. Durable control binding and host dispatch remain separate integration work; this does not activate a new public resume path.
1217
+
1218
+ ---
1219
+
1220
+ ## [0.70.0] — 2026-09-11
1221
+
1222
+ ### ⚠ BREAKING
1223
+
1224
+ - **@voltro/cli** — The DevTools dashboard has a lock of its own: `VOLTRO_DASHBOARD_TOKEN`. A deployed dashboard that holds other apps' inspect tokens in `VOLTRO_DASHBOARD_APPS` refuses to boot in production without it, `/api/dashboard/config` no longer serves token values, and the proxy attaches a configured target's credentials itself.
1225
+
1226
+ Before this the dashboard had no door: whoever reached its URL got every configured app's inspect read AND write token from `/api/dashboard/config` in one request, and the proxy forwarded whatever bearer that browser then presented. The URL was the credential.
1227
+
1228
+ Three rules now, on both boot paths: the browser proves the dashboard token once (`POST /api/dashboard/login`) and gets an HttpOnly, SameSite=Strict session cookie derived from it by HMAC (12 h; a rotated lock invalidates every cookie); every other `/api/dashboard/*` route answers 401 without a session; the config route says where the targets are and whether the pod holds credentials (`hasToken`, `hasWriteToken`), never which. A dashboard with nothing to guard (no configured targets, a loopback dev registry) boots open as before — setting the token locks it anyway. `voltro secret generate dashboard` prints the variable paste-ready. `codemod: none`: no user code changes; the operator sets one variable, and the chart's `dashboardToken` renders it.
1229
+
1230
+ **No codemod** — this break touches no user-authored code.
1231
+
1232
+ ### Added
1233
+
1234
+ - **@voltro/protocol, @voltro/plugin-openapi, @voltro/cli, @voltro/testing** — The `publicApi` surface speaks a REST profile, chosen once in the api's `app.config.ts` (`publicApi: { profile }`) and overridable per descriptor (`publicApi.format`): `'rpc'` — the default, today's wire — `'standard'`, `'jsonapi'`, or a `RestFormat` object of the app's own, with building blocks (`dates`, `errors`, `envelope`, `pagination`, `naming`, `etag`) that override the preset. `'standard'` is the Zalando / Google AIP / Microsoft intersection: `timestampMs` as RFC 3339 (accepted inbound too), every failure as RFC 9457 Problem Details — the transport's refusals included — no envelope, a `nextCursor` as a `Link: <…>; rel="next"` header, a weak `ETag` on GET with `If-None-Match` → 304. `'jsonapi'` answers `{ data, links }` as `application/vnd.api+json`. The spec follows the profile (`format: date-time`, the `ProblemDetails` component, `Link`/`ETag` headers, snake-case names). `makeTestApp({ publicApiProfile })` measures the same wire.
1235
+
1236
+ A path with `{teamId}` or `:teamId` binds that segment to the input field of the same name — always, under every profile — with the remaining fields from the query string (GET) or the body; the brace form used to be no route, the colon form a `400`, so `GET /v1/teams/{teamId}` was unreachable for a projection.
1237
+ - **@voltro/testing, @voltro/database** — `makeTestContext({ fieldEncryptionKey })` registers the field cipher for `.encrypted()` columns under test. Setting `VOLTRO_FIELD_ENCRYPTION_KEY` — in `process.env` or the context's `env` — did nothing here, because the harness runs no boot gate and the gate is what turns the variable into a cipher; the only working route was `setFieldCipher(makeFieldCipher(key))` from another package, which nothing pointed at. The refusal now names the option.
1238
+ - **@voltro/database, @voltro/runtime** — `insertIgnoreWithOutcome` on the framework store (and on `yield* EffectStore` plus the `tx` view), with the typed helper `insertIgnoreRowOutcome`: `{ row, outcome: 'inserted' | 'ignored' }`.
1239
+
1240
+ `insertIgnore` returns the existing row on the ignore path, and that row is indistinguishable from one it just wrote — so a loop that plants missing rows reported every row it visited as planted unless it read the destination first, per row. The answer is decided the way `upsertWithOutcome` decides it: the id the store minted before the statement is not the id the ignore path hands back; where the id cannot decide (caller-supplied, or database-assigned) the destination is read first and the answer stays exact. Plain `insertIgnore` never pays for that read.
1241
+ - **@voltro/protocol, @voltro/client, @voltro/web** — `defineMutation({ serialize: { key, maxPending? } })` — calls of one mutation to the SAME resource run in invocation order on the client, for every caller of the tag. A row lock serialises transactions, not the order a browser invoked them in: two quick saves of one snapshot could take the lock in reverse order and the older snapshot then overwrote the newer one; server-side idempotency does not see it, and `.version()` detects the conflict and replays the older write. `key` names the resource from the input; calls sharing it run one at a time, FIFO, per subject, the permit spanning connection setup, the request, replay and its acknowledgement — optimistic previews stay immediate, different keys run independently. An unknown outcome (a transport failure after the request started) blocks the key for that subject rather than let a later write pass it; a subject change drops the queue; admission beyond `maxPending` (default 32) rejects with `MutationSerialExecutionError`. `useMutation`'s `pending` now counts every concurrent call, not only the one that settled last.
1242
+
1243
+ Declared on the descriptor — where `concurrency` lives for workflows — rather than as a hook builder, because a hook-scoped queue orders one component's calls and lets a second component writing the same resource interleave. Not a durable outbox, a distributed lock or a cross-tab merge.
1244
+ - **@voltro/cli** — API `fonts:` declarations now package validated native-readable fonts and relocatable Fontconfig artifacts during build. Dev, serve and direct production entries activate them before startups and handlers; missing or corrupt artifacts refuse boot. Native renderers no longer depend on undeclared host fonts or original workspace font paths.
1245
+ - **@voltro/plugin-openapi** — Every mounted API version has an OpenAPI document of its own — `/openapi/v2.json` beside `/openapi.json`, or `?version=v2` — the same spec filtered to that version, every operation fact included; the unversioned URL keeps serving everything. With more than one version mounted the docs page shows Swagger UI's "Select a definition" dropdown: one entry per version, newest first and preselected, an expiring version named with its earliest `sunset` (`v1 — sunset 2027-03-01`), the whole document last.
1246
+
1247
+ Before this `v1` and `v2` shared one document and one viewer: a consumer integrating `v2` scrolled past every `v1` path, and a generator built both into one client.
1248
+ - **@voltro/protocol, @voltro/cli, @voltro/plugin-openapi** — `onProcedureSurface` — a plugin hook told the app's wire-reachable rpc procedures (every query, mutation, action and stream discovery found, minus `internal`) once discovery has run, on both boot paths; the procedure counterpart of `onRestSurface`. `openapiPlugin({ includeAppProcedures: true })` documents them as `POST /rpc/<name>` operations without the app listing them: opt-in, so the spec stays REST-only unless asked, and a procedure also passed in `procedures` is documented once.
1249
+
1250
+ Before this `procedures` was the only way in, and filling it meant importing every descriptor module into `app.config.ts` — evaluated before discovery on every load of the config, the pattern the `onRestSurface` docs name as a boot that hung — while `voltro serve` loads no TypeScript at runtime, so a plugin could not read them later either.
1251
+ - **@voltro/protocol, @voltro/plugin-openapi** — A `publicApi` projection documents itself completely: `tags` from the rpc tag's prefix (`teams.list` → `teams`; `publicApi.tags` overrides) with a top-level `tags[]` and `tagDescriptions` on the OpenAPI plugin; an `operationId` from the tag (`teamsList`; `publicApi.operationId` overrides); `security` on every operation — required, or optional under `openAccess` (`[{ bearerAuth: [] }, {}]`), `publicApi.security: false` for none — with `securitySchemes.bearerAuth` published once any projection exists; one response per `errorStatus` entry carrying the error's own schema, `422` for the union's other tags, `429` for a mounted rate limiter; `publicApi.example: { request?, response? }`; path parameters `in: path` and query parameters typed from the input.
1252
+
1253
+ Before this the plugin wrote `tags` only for an explicit `version`, `security` only for a `guards:` route — so after "Authorize" Swagger UI sent the token to those alone and "Try it out" on every other route ran without a key — no `operationId`, no `errorStatus` response, no example, and no query parameter at all for a GET projection.
1254
+
1255
+ ### Fixed
1256
+
1257
+ - **@voltro/protocol** — A `publicApi.path` segment that names no input field refuses the boot, naming the descriptor and the segment (`descriptor 'bookings.get' — path '/v1/bookings/:uuid' binds ':uuid', and the input declares no field of that name (fields: bookingId)`). A field the path binds may repeat in the body or query string with the same value — a PATCH carrying the whole resource — but another value answers `400` naming the field.
1258
+
1259
+ Before this the segment was bound silently: the route mounted, the handler never saw the value, and a body that carried the same field with another value was overwritten by the path without a word.
1260
+ - **@voltro/protocol, @voltro/runtime, @voltro/plugin-openapi, @voltro/plugin-prometheus** — A plugin route answers the path it declared, byte for byte: the dispatcher refuses a request whose path is not the route's own — case-folded twins and a bare trailing slash included — with `404`, before the body is read. `PluginHttpRoute.match: 'exact'` narrows a route to its path alone, no sub-path; the default stays `'prefix'`, the contract every route that branches on its tail was written against. A REST descriptor is an exact route; the OpenAPI spec and docs routes and the Prometheus scrape route declare `'exact'`.
1261
+
1262
+ Before this the router matched without case and ignored a trailing slash, and every route owned its sub-paths: `/OPENAPI.JSON`, `/openapi.json/`, `/Docs` and `/Metrics` all answered, so a path guard at the ingress that named the one documented spelling covered none of the others. The docs of both plugins now say the guard can name the path as written.
1263
+ - **@voltro/workflow, @voltro/cli** — A run that ended outside the engine no longer leaves its unanswered requests in the cluster journal. Cancelling a run (`timeouts.finish`, `cancelOn`, the operator's button, the bulk action) discards its unprocessed messages at once, and the staleness tick discards those of every ended run (`succeeded`, `failed`, `cancelled`) that a runner last read more than a minute ago — logged as `workflow.cluster: discarded unprocessed messages of runs that have ended`.
1264
+
1265
+ Before this, the `activity` request a killed executor was inside stayed unprocessed after the run was cancelled: every runner that acquired the shard re-read it every ten minutes for the rest of time, its handler waited on a latch that never opens, and the `unprocessed messages` warning was printed again on every engine start. An unprocessed `run` request of a cancelled run would have gone further and executed it. The engine's own reset deletes one request; this deletes the ended run's, and `ClusterDiagnostics.discardMessages` is the one write on that service.
1266
+ - **@voltro/cli** — `voltro db encrypt-column <table>.<column> .` accepts the app directory every other `db` command takes as its last argument — accepted and not needed, the connection comes from env. It used to parse `.` as a column reference and refuse. A positional that is neither a `<table>.<column>` nor a directory is named in the refusal.
1267
+ - **@voltro/runtime, @voltro/cli, @voltro/database** — Every API key has its `actors` row after the first boot on this version. Both boot paths plant, after the schema is applied, one row per `_voltro_api_keys.id` that has none — through the same `ensureActor` a new key goes through (the built-in row, or the app's `apiKeys.ensureActor` handed the issue input rebuilt from the row). Idempotent; two queries when nothing is missing; `api keys: planted actors rows for keys that had none` with the count, and a key whose row cannot be written is named in a warning rather than failing the boot.
1268
+
1269
+ Two release notes had asked the operator to run a loop once. The first was printed to the wrong group; the second printed `store.select(...)` for a `*.startup.ts`, and `StartupContext.store` is a `DataStore` with no fluent builder — both API pods refused to boot on it. The 0.69.1 note now says what the boot does and which line to look for. The framework tables `@voltro/database` exports (`_voltroApiKeysTable` and the others) are typed `SchemaTable` instead of `TableLike`, so `queryFor(_voltroApiKeysTable)` compiles where a hand-written read is still wanted.
1270
+ - **@voltro/runtime** — Idempotent WebSocket mutations acquire and finalize their claims safely across request interruption. A closed socket cannot orphan a claim acquired by a still pending store Promise; synchronous executor failures release fresh claims too. Already-started Promise executors finish their actual write and record the result before interruption completes, preventing a replay from duplicating the write. Effect executors remain interruptible and release only after cleanup. A late interrupt never deletes the completion receipt of a successful write.
1271
+
1272
+ Codemod: none — no application source or configuration changes. Claim TTL, replay limits, transport guards and process-crash recovery guarantees are unchanged.
1273
+ - **@voltro/cli** — Docker deployments retain optional native platform bindings until runtime tracing, and the pruner traces both conditional import and require entries so relocated API and web images can still execute native image processing.
1274
+ - **@voltro/workflow, @voltro/cli** — `own` on a shard-lock holder in the stall report and the `workflow.cluster` warning is now the engine's own account (`Sharding.hasShardId`), and a holder that is this runner reads `this runner (pid N)`.
1275
+
1276
+ The first version compared the holder's pid with `pg_backend_pid()` of the query that asked. The advisory lock lives on the runner's reserved connection, and the pool hands every other query a different backend — so a runner's own 300 locks read as a stranger's, always, and behind a connection pooler nothing in `pg_stat_activity` could have told them apart either. The report's stated lever for a stranger, ending the holder's session, would have ended the runner. The hint now distinguishes the three cases (another runner, a session that outlived its runner, this runner re-reading a request nobody answers), and the docs say to read `own` before `pg_terminate_backend`.
1277
+ - **@voltro/cli** — Production HTTP proxies release upstream resources when the browser abandons an unfinished response, including SSE, and propagate truncated upstream responses as transport failures instead of hanging. Complete responses retain payloads and keep-alive; no application configuration change is needed.
1278
+ - **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/testing, @voltro/plugin-ratelimit** — `publicApi.rateLimit` on a descriptor is that endpoint's own rule for calls through the `/v1` projection — ahead of the plugin's `rules` and `default`, one bucket per caller per endpoint, under `enabled`. A REST call now carries `surface: 'rest'` and the descriptor's `publicApi` spec on the interceptor context, so a plugin can tell a projected call from an rpc one.
1279
+
1280
+ The field said "rides @voltro/plugin-ratelimit when mounted" and nothing read it: three calls at `limit: 1` answered `200 200 200`, no bucket, no `onLimited`. The plugin never learned which calls came through the projection, let alone what it declared. Without the plugin mounted the spec is inert, as the comment always implied.
1281
+ - **@voltro/cli** — The retention sweep arms its policies on the `_voltro_workflow_*` tables only where the workflow engine that creates those tables is composed. An api with no `*.workflow.tsx` has no engine and no such tables, and the five policies still armed on them were five failing `DELETE`s per tick, each an `ERROR` line in the database log, for the life of the deployment — not switchable off from the app. The boot line `retention: N policy(ies) armed` now lists exactly what is swept.
1282
+ - **@voltro/plugin-mail** — SMTP replies are classified by SMTP rules instead of HTTP rules: 4xx replies remain retryable, permanent 5xx rejections do not trigger automatic retries (RFC 5321 §4.2.1). HTTP providers keep their existing policy. Tests exercise actual loopback connections, envelope, MIME and attachment encoding without sending external mail.
1283
+
1284
+ Codemod: none — corrected SMTP reply semantics require no application source or configuration migration.
1285
+ - **@voltro/workflow** — The workflow engine's boot check for its cluster tables asks whether a table exists with a query that succeeds either way (`to_regclass` on postgres, `information_schema` on mysql, `OBJECT_ID` on mssql, `sqlite_master` on sqlite) instead of selecting from the table and reading the failure as "no" — which the database server logged as an `ERROR` on every first boot of a database. The probes `@effect/cluster` itself makes for its legacy tables are upstream and still fail-as-answer.
1286
+ - **@voltro/cli** — `voltro dev` acquires the workflow runtime as a boot step, as `voltro serve` always has — `workflow runtime ready — N entity type(s) registered` on both paths, through one shared helper with a parity test.
1287
+
1288
+ A `ManagedRuntime` builds its layer on first use, and `voltro dev` never used it at boot. A fleet of dev pods therefore had NO cluster runner after a restart — no runner row, no shard locks, no re-delivery — until something asked the runtime for a service. The first thing that asked, in a deployment, was the once-per-fleet staleness tick, five minutes after the boot, in the one pod that ran it: a run orphaned by the restart waited exactly that long and was then adopted by that pod alone.
1289
+ - **@voltro/cli** — `voltro baseline sync` writes its `docker/Caddyfile` with tabs, the way `caddy fmt` writes it. Caddy warned on every proxy start that the file "is not formatted", and a project that checks its generated files for drift could not fix it on its side — the next sync undid it.
1290
+ - **@voltro/plugin-ratelimit** — `redisStore({ url })` binds its client on the first `consume` when nobody activated it — from `url` or the env it reads at activation — so a limit holds in the test harness, where `makeTestContext` runs no lifecycle hooks. A store with neither stays fail-open (`failOpenReason: 'unbound'`), as at activation.
1291
+
1292
+ Before this a suite that "checked the redis store" ran every call through an unbound store: `200 200 200` at `limit: 2`, no key in Redis, no warning — and nothing a test could read told "the limit held" from "the store was never there".
1293
+ - **@voltro/cli, @voltro/i18n** — A `src/pages/[locale]/…` route renders in the locale it matched, on both web boot paths: `voltro start` and `voltro dev` hand the matched `[locale]` parameter to the same i18n resolver (also for the ISR key and for an app without catalogs), and `resolveLocale` takes it as `routeLocale` — ahead of the `voltro:locale` cookie, `Accept-Language` and the default, and only on an exact supported spelling. The build's static prerender already did this; the runtime render fell back to cookie or browser language, so `/en` answered with a German head and `html lang="de"`. In the same change `voltro dev`'s buffered SPA and ISR responses merge a page's `meta.title` into the shell the way production does — there was a second `<title>` before.
1294
+ - **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/testing, @voltro/plugin-ratelimit** — A rate-limited call on the `/v1` projection answers `429` with `Retry-After` and the `RateLimit-Limit` / `RateLimit-Remaining` / `RateLimit-Reset` headers, the tag and fields as the body — on both boot paths and in `makeTestApp`. A `PluginErrorSchema` may declare `rest: { status, headers }`; the projection encodes a plugin's cross-cutting error under that status (`422` when it names none; the descriptor's own `errorStatus` entry wins), and the plugin declares `429` for `RateLimited`.
1295
+
1296
+ The plugin merged `RateLimited` into every rpc's wire union at boot and the client decoded it typed, but the REST projection encoded through the descriptor's own `error:` union only — the one surface with no typed channel to fall back on answered `500 Internal Server Error` with no `Retry-After`. The only way to a `429` was to import a plugin error into every descriptor file. A `500`'s cause now rides on the response value off the wire, so the `GET /v1/… → 500` log line names the reason on `voltro serve` too.
1297
+
1298
+ ---
1299
+
42
1300
  ## [0.69.1] — 2026-09-11
43
1301
 
44
1302
  ### Added
@@ -161,7 +1419,7 @@ _Changes staged for the next release accumulate here (rolled up from
161
1419
 
162
1420
  **The criterion is narrower than "the type crosses the boundary", and the difference is what keeps this from becoming churn.** Counted over the repo, 21 packages have a third-party dependency whose types appear in their api golden. A second copy needs a second DECLARER, so the question is whether the APP is expected to declare the package too. It is for `effect` (every handler imports it), for `ai` / `@ai-sdk/*` (you declare them to configure a provider) and for `stripe` (`normalizeStripeEvent` takes an event built by the app's own SDK instance). It is not for `@effect/sql`, the dialect drivers, `ioredis`, `@opentelemetry/*` or `@radix-ui/*` — one range is declared in one place and one copy resolves.
163
1421
 
164
- `scripts/check-boundary-peers.mjs` (CI + `pnpm check:boundary-peers`) asserts that rule and PRINTS the judged-invisible set on every run, because a check that has quietly narrowed reads exactly like a clean one. It ships a `--selftest` that re-introduces `ai` as a dependency and requires the rule to fire.
1422
+ A build check asserts that rule and PRINTS the judged-invisible set on every run, because a check that has quietly narrowed reads exactly like a clean one..
165
1423
 
166
1424
  `@voltro/plugin-comments` was the one package declaring `effect` under `dependencies` while every other peers it — the two-copies rule the repo already holds absolutely, with a single unapplied exception.
167
1425
 
@@ -554,7 +1812,7 @@ _Changes staged for the next release accumulate here (rolled up from
554
1812
 
555
1813
  ### Internal (no consumer-facing effect)
556
1814
 
557
- - **@voltro/cli** — `scripts/browser-surface-mismatch.mjs` drives the schema cross-check in a real Chromium — a real `fetch` to the derived capabilities URL, a real console, the real `buildApiRuntime`.
1815
+ - **@voltro/cli** — A build check drives the schema cross-check in a real Chromium — a real `fetch` to the derived capabilities URL, a real console, the real `buildApiRuntime`.
558
1816
 
559
1817
  The unit tests reach everything except the environment the check actually runs in, and this one's whole value is that it SAYS something where the alternative is silence: a version that had quietly stopped firing would look, from outside, exactly like a client that is always fresh. Three cases, and the last two are what keep the first honest — a disagreeing surface warns and names the procedure, an agreeing one is silent, and a server publishing no surface at all is silent too. Falsified by disconnecting the reporter: four cases go red, the two silence cases stay green.
560
1818
 
@@ -706,7 +1964,7 @@ _Changes staged for the next release accumulate here (rolled up from
706
1964
  ### Internal (no consumer-facing effect)
707
1965
 
708
1966
  - **@voltro/cli** — Building the CLI package now gives its declaration-rollup process a bounded 6144 MiB heap, and packing delegates to the same build script. The seven-entry rollup exhausted the default roughly 4 GiB heap even in an isolated Node 24 build; the measured build succeeds with the scoped allowance. This does not change the memory settings of application builds or serving processes, and does not disable declaration validation or increase build concurrency.
709
- - The local release gate now executes the CI test planner with all packages and both CLI projects, rather than skipping its GitHub-specific preparation and reading missing shard files. Missing filters or project selectors fail before tests start; the coverage verifier also refuses a missing expected-package plan. Selftests execute the workflow's actual producer/consumer handoff. MySQL test fixtures check authenticated TCP readiness and distinguish cold initialization from failed steady-state healthchecks. The Postgres partial-index contract creates and cleans up its own database on the regular fixture server, so all seven assertions run without an additional opt-in flag in both local and CI gates. Row-history test files run serially because their live fixtures install and write the same fixed-name Postgres table; parallel setup could deadlock before the history assertions ran when the schema was cold.
1967
+ - The local verification run now plans tests across all packages and both CLI projects, rather than reading shard files that were never written. Missing filters or project selectors fail before tests start; the coverage verifier also refuses a missing expected-package plan. Selftests execute the workflow's actual producer/consumer handoff. MySQL test fixtures check authenticated TCP readiness and distinguish cold initialization from failed steady-state healthchecks. The Postgres partial-index contract creates and cleans up its own database on the regular fixture server, so all seven assertions run without an additional opt-in flag in both local and CI gates. Row-history test files run serially because their live fixtures install and write the same fixed-name Postgres table; parallel setup could deadlock before the history assertions ran when the schema was cold.
710
1968
 
711
1969
  ---
712
1970
 
@@ -1193,7 +2451,7 @@ _Changes staged for the next release accumulate here (rolled up from
1193
2451
 
1194
2452
  The rule asks "is this column nullable?" by finding the `table('<name>', { … })` declaration among the sources it was handed — the app's. That is right for the app's own tables and cannot work for ours: `_voltro_webhook_targets` is declared inside `@voltro/plugin-webhooks`, so a mutation writing to it resolved to "I could not look". The rule keeps that answer distinct from "this table has no nullable columns", because the two lead to opposite conclusions — but for our tables it was a permanent unknown, leaving anyone auditing their mutations to carry them as a standing exception and count them by hand.
1195
2453
 
1196
- Doctor is a static analyser: it parses files and boots nothing, so it cannot import a plugin to ask a table about itself. The nullability of our own tables is therefore generated from our declarations — with the same extraction rule the doctor applies, so a manifest entry cannot mean something different from what the rule would have concluded — and shipped as data. `scripts/gen-framework-table-nullability.mjs --check` runs in CI, because a stale entry would answer with a value that is no longer declared, which is worse than the honest unknown it replaces.
2454
+ Doctor is a static analyser: it parses files and boots nothing, so it cannot import a plugin to ask a table about itself. The nullability of our own tables is therefore generated from our declarations — with the same extraction rule the doctor applies, so a manifest entry cannot mean something different from what the rule would have concluded — and shipped as data. A build check refuses a stale entry, because a stale entry would answer with a value that is no longer declared, which is worse than the honest unknown it replaces.
1197
2455
 
1198
2456
  An app's own declaration still wins where both have one: it is the more specific authority and the one a developer can actually edit.
1199
2457
  - **@voltro/plugin-sentry, @voltro/cli** — The browser half of the Sentry integration can now resolve its own configuration — from the public env, and from a module that runs at boot.
@@ -1262,7 +2520,7 @@ _Changes staged for the next release accumulate here (rolled up from
1262
2520
 
1263
2521
  `void sub.unsubscribe(channel)` in the dispose function returned by the redis broadcast provider was fire-and-forget with no `catch`. ioredis rejects the promises of every command in flight when the socket closes — `Connection is closed.`, thrown from its own close handler — so a `voltro dev` reload, which disposes subscriptions while the previous connection is going away, produced an unhandled rejection and node killed the process.
1264
2522
 
1265
- This is the THIRD round on one symptom with three different causes: a client with no `error` listener, `quit()`'s unguarded promise, and now a discarded command. Each fix was correct and none of them was the next one, so this round adds a rule instead of a fourth fix: `scripts/check-resp-fire-and-forget.mjs` (CI + gate, with a `--selftest`) fails when a discarded RESP command has no `.catch`.
2523
+ This is the THIRD round on one symptom with three different causes: a client with no `error` listener, `quit()`'s unguarded promise, and now a discarded command. Each fix was correct and none of them was the next one, so this round adds a rule instead of a fourth fix: A build check fails when a discarded RESP command has no `.catch`.
1266
2524
 
1267
2525
  The rule is deliberately narrow. The general property is `no-floating-promises`, which this repo has no eslint to run — and measured before writing it, a blanket `void <call>` rule matches **194** sites in `packages/*/src`, nearly all legitimate. The RESP subset matches 3, all correct. Widening the command list to "any method" costs the 194; the header says so, next to the number.
1268
2526
 
@@ -1539,7 +2797,7 @@ _Changes staged for the next release accumulate here (rolled up from
1539
2797
 
1540
2798
  **Default-DENY, because a default is a decision nobody made.** A path with no entry in `ROUTE_SCOPES` is refused with the fix in the message, so a new endpoint cannot ship unlabelled — it fails on its first request, in its author's own dev loop. Mutating routes are enumerated as `write` rather than inferred from the HTTP method: `?scope=fleet` on `/agent/call` would run a user's handler once per replica.
1541
2799
 
1542
- **Three dispatchers, not one.** Wrapping `handleInspectRequest` looked complete from inside itself while `handleInspectAsyncRequest` and `handleSharedInspectRoute` went on answering bare — `/cluster`, `/schedules` and every workflow read. Nothing in the route table showed it; it was found by asking a running process for every route. `inspectDoors.test.ts` pins all three on every commit and `scripts/observation-e2e.mjs` re-asks a real bundled serve. The same sweep caught a wrapper defect no unit fixture could: a route that builds its response by hand rather than through `json()` was wrapped into an envelope with `data: undefined`, which serialises away — every field around the answer correct, and the answer gone.
2800
+ **Three dispatchers, not one.** Wrapping `handleInspectRequest` looked complete from inside itself while `handleInspectAsyncRequest` and `handleSharedInspectRoute` went on answering bare — `/cluster`, `/schedules` and every workflow read. Nothing in the route table showed it; it was found by asking a running process for every route. `inspectDoors.test.ts` pins all three on every commit and A build check re-asks a real bundled serve. The same sweep caught a wrapper defect no unit fixture could: a route that builds its response by hand rather than through `json()` was wrapped into an envelope with `data: undefined`, which serialises away — every field around the answer correct, and the answer gone.
1543
2801
 
1544
2802
  **The dashboards say it on screen.** `ScopeNotice` renders inside the SHARED `devtools-ui` pages, not in each host dashboard, for the reason every omission in this codebase has been invisible: a label each consumer must remember is a label one consumer will not have, and the page that forgot is indistinguishable from a page with nothing to warn about. It renders nothing when the answer is complete — a banner over a complete answer trains people to ignore banners.
1545
2803
 
@@ -1603,7 +2861,7 @@ _Changes staged for the next release accumulate here (rolled up from
1603
2861
 
1604
2862
  The publisher runs on BOTH boot paths, pinned by `bootPathParity.test.ts`: a replica that never publishes is a hole in every other replica's answer, and it reads as "has written nothing", which is indistinguishable from broken.
1605
2863
 
1606
- **Measured with two real replicas against one database** (`scripts/fleet-observation-e2e.mjs`): both publish, EITHER answers for BOTH (`responded: 2, expected: 2`), and a killed replica's row keeps being readable with a growing age rather than vanishing — which is the whole design in one assertion, since a dead process going silent is indistinguishable from "nothing happened" and a row that gets old is not.
2864
+ **Measured with two real replicas against one database**: both publish, EITHER answers for BOTH (`responded: 2, expected: 2`), and a killed replica's row keeps being readable with a growing age rather than vanishing — which is the whole design in one assertion, since a dead process going silent is indistinguishable from "nothing happened" and a row that gets old is not.
1607
2865
 
1608
2866
  That test found two defects the single-process one could not:
1609
2867
 
@@ -1671,7 +2929,7 @@ _Changes staged for the next release accumulate here (rolled up from
1671
2929
 
1672
2930
  **A declared address and a fallen-back one are different facts.** An unset `POD_IP` falls back to `127.0.0.1`, which is a shrug — recorded as NOT reachable, and no peer tries it. `VOLTRO_INSPECT_ADVERTISE_HOST` declares one, including `127.0.0.1` when the peers really are on this machine. This is the same posture the framework takes everywhere: we do not second-guess a declaration, and we do not treat a fallback as one.
1673
2931
 
1674
- Measured between two real replicas (`scripts/fleet-observation-e2e.mjs`): A answers for B, B's identity survives the hop, an unknown id is refused with no outbound request, and a mutating endpoint is refused with `400`.
2932
+ Measured between two real replicas: A answers for B, B's identity survives the hop, an unknown id is refused with no outbound request, and a mutating endpoint is refused with `400`.
1675
2933
 
1676
2934
  **And the consumer that nearly shipped broken.** Both dashboards unwrap the envelope in their single fetch helper; the CLI's `inspectFetch` — which thirty-odd subcommands read through — was missed. `voltro cluster status`, `voltro logs`, `voltro schedules` would each have read `undefined` off an envelope and printed an empty table. Found by asking what ELSE reads these routes, not by a failing test, which is why the seam now has one: the payload comes out, the envelope is kept so a command can say "1 of 3", an answer that predates the envelope passes through unchanged, and a payload that merely HAS a `data` key is not mistaken for one.
1677
2935
  - **@voltro/cli** — **`/_voltro/inspect/stream` events carry `origin`.**
@@ -1925,7 +3183,7 @@ _Changes staged for the next release accumulate here (rolled up from
1925
3183
 
1926
3184
  Measured against a real bundled production serve, not inferred: `{"voltroVersion":"0.55.0","mode":"serve"}` where it previously read `{"voltroVersion":"0.0.0","mode":"start"}`.
1927
3185
 
1928
- `scripts/check-process-identity.mjs` (CI + `pnpm gate`) fails on any second derivation, and ships a `--selftest` that classifies eight shapes — the four that were really in the tree, plus four benign reads that must not trip it.
3186
+ A build check fails on any second derivation.
1929
3187
  - **@voltro/cli, @voltro/devtools-ui** — A sweep of all 31 dashboard pages in a real browser, against a running api, found four defects nothing else was looking for. None threw where a test could see it; three of them blanked a whole page.
1930
3188
 
1931
3189
  **A "structured empty" that was not the structure.** `/_voltro/inspect/database` answered `{ migrations: [], seeds: [] }` when the app had no snapshot to give, while `DatabaseStatus` declares `dialect` and `replication` as present. The page read `status.replication.replicaCount`, threw during render, and the Database page went blank with a console trace. A structured empty exists so a reader can render it WITHOUT branching; one that omits half the structure is a differently-shaped payload wearing the word. It answers the full shape now, and the panel tolerates the short one because a customer app on an older version still sends it — a dashboard that crashes on an old app cannot be used to diagnose one.
@@ -2081,7 +3339,7 @@ _Changes staged for the next release accumulate here (rolled up from
2081
3339
 
2082
3340
  - **The build was stopped by a dependency that contributes nothing to it.** An api serve bundle reaches `@voltro/content` through `serveCommand → dev → webDev → contentWiring`, and esbuild resolves before it tree-shakes. After shaking, the content pipeline is **0 bytes** of a 14.42 MB bundle. So an api app with no markdown anywhere was blocked by a markdown loader whose code it would never have carried. - **Shipping the file does not bloat anything.** Same measurement with the fixed package resolved as a consumer resolves it: 14.42 MB, content still 0 bytes. The dynamic import stays shaken away.
2083
3341
 
2084
- `scripts/check-dist-internal-specifiers.mjs` now bundles every emitted file of every publishable package — from `.publish/`, the tree users receive — with bare specifiers external, and fails if any relative specifier does not resolve. GATE-2 (`publint`) answers "does a declared subpath resolve"; this is one level below it, where `./serverLoad` lives.
3342
+ A build check now bundles every emitted file of every publishable package — from `.publish/`, the tree users receive — with bare specifiers external, and fails if any relative specifier does not resolve. GATE-2 (`publint`) answers "does a declared subpath resolve"; this is one level below it, where `./serverLoad` lives.
2085
3343
  - **@voltro/client** — Three places still taught the pre-fix contract for a cold-start failure.
2086
3344
 
2087
3345
  `SubscriptionFailed` gives it its own state — `loading: false`, `failed: true`, `error` non-optional — precisely so a component branching on `loading` alone cannot render a skeleton forever. But `SubscriptionMeta.error`'s doc comment and two docs pages still said the opposite ("leaves `loading` TRUE … check `error` to break out of it"), which is the sentence a deployment quoted back at us as evidence for the defect that had already been fixed.
@@ -2344,7 +3602,7 @@ _Changes staged for the next release accumulate here (rolled up from
2344
3602
 
2345
3603
  Also in the box: replies (anchor-pinned — a reply cannot smuggle into a thread on a different anchor than the access check ran for), resolve/reopen, author-only edit, delete with a `comments:moderate` scope override cascading reactions, per-emoji reactions aggregated with `count` + `mine`, per-subject thread unread (`markRead`; your own comments are never unread for you), `useComments`/`useThread`/`useMentionSearch` hooks, the ejectable unstyled `<CommentsThread>` in `@voltro/ui`, and a Comments panel in both dashboards. Moderation is honestly opt-in (one plugin-moderation rule, documented — no "automatic" claim).
2346
3604
 
2347
- Proven over the real wire (`scripts/comments-e2e.mjs`, real `voltro serve` + postgres + signed session subjects): A comments → B's ALREADY-OPEN subscription receives the new snapshot live; B's mention lands in the inbox (no self-notification); resolve at A arrives live at B; soft-deleted and missing anchors refuse; a cross-tenant mention delivers nothing. The docs site carries a LIVE demo (the real plugin against the docs demo backend). The declared limits are documented: attachments = storage-grant + URL, and the channel-wide reactivity granularity with the read-set work as the named narrowing.
3605
+ Proven over the real wire (real `voltro serve` + postgres + signed session subjects): A comments → B's ALREADY-OPEN subscription receives the new snapshot live; B's mention lands in the inbox (no self-notification); resolve at A arrives live at B; soft-deleted and missing anchors refuse; a cross-tenant mention delivers nothing. The docs site carries a LIVE demo (the real plugin against the docs demo backend). The declared limits are documented: attachments = storage-grant + URL, and the channel-wide reactivity granularity with the read-set work as the named narrowing.
2348
3606
  - **@voltro/content, @voltro/cli, @voltro/changelog** — Content collections (plan 03): `@voltro/content` — file-based, schema-typed markdown content without installing a markdown dependency.
2349
3607
 
2350
3608
  `defineCollection` declares a folder (`content/<name>/**/*.md`) with an `effect/Schema` frontmatter schema in a `*.collection.ts` file. The isomorphic `getCollection`/`getEntry`: at build/SSR time the server reads the filesystem, decodes frontmatter (a violation FAILS the build naming the file), and renders markdown with dual-theme shiki; the build emits JSON artifacts under `dist/assets/content/…` that the CLIENT branch fetches on SPA navigations — no markdown engine, no highlighter, no content bodies in the browser bundle (proven by the fixture e2e's budget checks: 402 chunks → 9 after the split). Slugs come from the relative path; duplicates are build errors. Locale trees (`i18n: { locales, defaultLocale, missing }`) serve `de/` mirrors with per-collection fallback-or-404 policy. Rendered entries carry `headings[]` (depth/slug/text — the SAME ids stamped on the HTML, via one shared `extractHeadings`). `kind: 'data'` decodes `.json` files (authors.json). `reference('<collection>')` fields are validated by the build — a dangling reference names collection, entry, field and target. `config.feeds` builds RSS from a collection next to sitemap.xml and serves the same XML as a live dev route. `voltro dev` serves artifact shapes on demand and invalidates on `content/**` edits.
@@ -2378,12 +3636,12 @@ _Changes staged for the next release accumulate here (rolled up from
2378
3636
 
2379
3637
  ONE memoized build feeds every surface: `writeEntryFiles` bakes CSS + preloads into the generated shell (served identically by dev, static prerender, SSR streaming and `voltro start`), the dev server answers the hashed files from the same memo, `voltro build` writes them into `dist/assets/fonts` — the shell's URLs and the files cannot disagree.
2380
3638
 
2381
- No font CDN request ever leaves a visitor's browser — the GDPR argument the docs carry (LG München), proven by e2e: a real chromium loads the page with ZERO foreign-host requests. Full e2e (`scripts/font-pipeline-e2e.mjs`): hashed woff2 in dist, subset measurably smaller than the source, @font-face + fallback face + preload in the built HTML, dev parity, browser network assertion. Deliberately NOT built: a Google-Fonts download helper (license terms are per-family — the manual path is documented).
3639
+ No font CDN request ever leaves a visitor's browser — the GDPR argument the docs carry (LG München), proven by e2e: a real chromium loads the page with ZERO foreign-host requests. Full e2e: hashed woff2 in dist, subset measurably smaller than the source, @font-face + fallback face + preload in the built HTML, dev parity, browser network assertion. Deliberately NOT built: a Google-Fonts download helper (license terms are per-family — the manual path is documented).
2382
3640
 
2383
3641
  fontkit + subset-font ship as optional dependencies of @voltro/cli (script-free, verified — the plan-11 decision inherited); without them fonts still self-host and the metrics/subset halves degrade with one named warning each, plus a `voltro doctor` rule naming which half is missing.
2384
3642
  - **@voltro/client, @voltro/ui** — The form contract, made seamless where it still had seams:
2385
3643
 
2386
- - **App-wide message wiring.** `<ValidationMessagesProvider messages={(id, params) => t(id, params)}>` once at the root resolves every form's schema ids AND server ids (`ctx.validation.fail`) through the app's i18n catalog — the per-form `messages:` option still wins, `undefined` falls through per id. - **`toInput` is compiler-checked.** The typed `useFormBinding` has two shapes now: without `toInput`, form values ARE the mutation input; with it, the form gets its own `Values` shape and the mapper's return is checked against the mutation's input — a mapping that stops producing the wire shape is a type error. - **`<AutoForm>` renders the structure the schema declares.** Nested structs become real `<fieldset>` sections with legends; widget props come from the FIELD HANDLE, which fixes a real defect the audit found — a dotted field's value was read as a flat property, so nested inputs rendered permanently empty. Widgets receive `onBlur` (all built-ins forward it), so the reveal-on-blur timing works in AutoForm exactly as in the headless binding; `reference` reaches registry widgets for query-bound pickers. - **Proven over the real wire** (`scripts/forms-e2e.mjs`, real `voltro serve` boot): a `ctx.validation.fail` refusal arrives as a TYPED `ValidationError` with its field and message id, and a target's declared `relations:` reconciles the junction end to end — set, diff, absent ≠ empty, explicit clear — with an executor that never touches the junction.
3644
+ - **App-wide message wiring.** `<ValidationMessagesProvider messages={(id, params) => t(id, params)}>` once at the root resolves every form's schema ids AND server ids (`ctx.validation.fail`) through the app's i18n catalog — the per-form `messages:` option still wins, `undefined` falls through per id. - **`toInput` is compiler-checked.** The typed `useFormBinding` has two shapes now: without `toInput`, form values ARE the mutation input; with it, the form gets its own `Values` shape and the mapper's return is checked against the mutation's input — a mapping that stops producing the wire shape is a type error. - **`<AutoForm>` renders the structure the schema declares.** Nested structs become real `<fieldset>` sections with legends; widget props come from the FIELD HANDLE, which fixes a real defect the audit found — a dotted field's value was read as a flat property, so nested inputs rendered permanently empty. Widgets receive `onBlur` (all built-ins forward it), so the reveal-on-blur timing works in AutoForm exactly as in the headless binding; `reference` reaches registry widgets for query-bound pickers. - **Proven over the real wire** (real `voltro serve` boot): a `ctx.validation.fail` refusal arrives as a TYPED `ValidationError` with its field and message id, and a target's declared `relations:` reconciles the junction end to end — set, diff, absent ≠ empty, explicit clear — with an executor that never touches the junction.
2387
3645
  - **@voltro/cli, @voltro/runtime** — The opt-in gRPC surface — an external client generated from the framework-emitted `.proto` calls a named Voltro procedure: unary for mutations/actions, server-streaming (live current-snapshot frames) for queries. Nothing re-implements the wire semantics: a gRPC call runs the SAME bound runner every other surface uses, so guards, the plugin interceptor chain (order proven side-by-side against a socket call in the e2e) and typed errors behave identically.
2388
3646
 
2389
3647
  `app.config.ts` `grpc: { port, procedures: [tags], tls? }` — nothing exposed by default, every tag named, a phantom tag refuses the boot. The `.proto` comes from the procedures' own `effect/Schema` via a checked-in `grpc.manifest.json` whose FIELD NUMBERS are append-only: an inserted field never renumbers its neighbours, a deleted field's number goes `reserved` (emitted into the proto), and reusing a reserved number is a codegen error — the one gRPC trap that silently corrupts old clients. proto3 presence maps `Schema.optional` AND `NullOr` to the `optional` keyword (absent and null are one wire state, documented); the unmappable (shape unions, tuples, recursion, free-form objects) is a loud per-procedure error naming the schema path.
@@ -2395,7 +3653,7 @@ _Changes staged for the next release accumulate here (rolled up from
2395
3653
  Declared v1 limits, with alternatives: no client/bidi streaming, no gRPC-Web (browsers use the framework's subscription protocol), no Connect protocol (REST/OpenAPI projection is the answer there), and `*.stream.ts` procedures are not exposable.
2396
3654
  - **@voltro/cli, @voltro/web** — Build-time image pipeline (plan 11). `import hero from './hero.jpg?image'` turns a static asset into an `OptimizedImageAsset`: every ladder width up to the intrinsic width encoded as AVIF + WebP plus a same-family fallback, hashed into `dist/assets/`, with intrinsic width/height and a 16px blur data URI. `<Image src={hero}>` renders a `<picture>` with per-format sources — dimensions and blur inferred, `placeholder="blur"` the default. The suffix is an explicit opt-in: bare image imports keep Vite's URL semantics untouched.
2397
3655
 
2398
- Transforms run through a persistent cache (`.framework/image-cache/`, bounded concurrency) — the second build re-encodes nothing (proven: `scripts/image-pipeline-e2e.mjs` asserts zero cache-file rewrites on build two, plus `<picture>`/srcSet/blur/dimensions in the prerendered HTML and a real chromium decoding a transformed WebP from the dev endpoint). In dev, `/_voltro/image/<assetId>` transforms on demand and answers ONLY for manifest-registered assets; `voltro start` serves build artifacts with no transform endpoint at all (deliberate — no production transform-DoS surface).
3656
+ Transforms run through a persistent cache (`.framework/image-cache/`, bounded concurrency) — the second build re-encodes nothing (proven: A build check asserts zero cache-file rewrites on build two, plus `<picture>`/srcSet/blur/dimensions in the prerendered HTML and a real chromium decoding a transformed WebP from the dev endpoint). In dev, `/_voltro/image/<assetId>` transforms on demand and answers ONLY for manifest-registered assets; `voltro start` serves build artifacts with no transform endpoint at all (deliberate — no production transform-DoS surface).
2399
3657
 
2400
3658
  sharp ships as an optional dependency of @voltro/cli — auto-available, install-failure-tolerant, and script-free since 0.33 (prebuilds ride `@img/*` platform packages, so pnpm 10's build-approval gate does not apply; measured, correcting the plan's assumption). Without a working sharp the pipeline serves originals with ONE loud warning naming the fix, and `voltro doctor` distinguishes "not installed" from "installed but platform binary missing" (the omit-optional install). Tunables: `images.{formats,quality}` in the web `app.config.ts`; per-`<Image>` `quality` flows into the CDN loader seam, which stays the answer for dynamic/remote `src`.
2401
3659
 
@@ -2406,7 +3664,7 @@ _Changes staged for the next release accumulate here (rolled up from
2406
3664
 
2407
3665
  Behaviour changes that ship with it: `useBlocker` now guards POPSTATE — the Back gesture is a modal's primary close, and it previously bypassed every blocker (the router reverts the moved URL via an entry-index delta and offers retry/reset; ESC in the overlay routes through the same path). `useSearchParams`/`useSetSearchParams` read and write the CALLING TREE's query — a background component can no longer decode the modal's query against its own schema or write onto the modal's URL. Navigation scrolling moved to the visual commit, so an overlay open never scrolls the background. The overlay slot is ALWAYS rendered (null when closed) through the shared provider tree, keeping server/client fiber arity identical (the useId class). The overlay chrome is a native `<dialog>` via `showModal()` — platform focus trap, backdrop and focus restoration; body scroll locked while open; deliberately unstyled (`dialog[data-vweb-overlay]`).
2408
3666
 
2409
- Declared non-goal: Next's parallel `@slot` routes — split panes are components in a layout, not a routing concept. Islands/zero-JS pages don't intercept (no client router). Proven by 6 jsdom router tests plus `scripts/intercept-e2e.mjs` on an ssr fixture: standalone SSR HTML with per-photo title and zero hydration warnings, overlay over a mounted background (typed input + mount counter survive open AND close), per-tree search params, nested modals with topmost-only Back, popstate blocking with discard, reload-renders-standalone, and a dev-parity smoke.
3667
+ Declared non-goal: Next's parallel `@slot` routes — split panes are components in a layout, not a routing concept. Islands/zero-JS pages don't intercept (no client router). Proven by 6 jsdom router tests plus A build check on an ssr fixture: standalone SSR HTML with per-photo title and zero hydration warnings, overlay over a mounted background (typed input + mount counter survive open AND close), per-tree search params, nested modals with topmost-only Back, popstate blocking with discard, reload-renders-standalone, and a dev-parity smoke.
2410
3668
 
2411
3669
  Measured price: the router group grew 1.5 KB gz (10.5 -> 12.0 KB, the whole first-load delta of this change) -- the overlay stack, popstate blocking and per-tree search params; every other bundle group moved by noise only. The bundle budget is re-pinned to that number.
2412
3670
  - **@voltro/local-first, @voltro/client, @voltro/cli, @voltro/plugin-presence** — The local-first sync engine. A `localFirst()` table's data is now offline readable, editable and convergently resynchronised — through the primitives apps already use, not a second data API.
@@ -2415,7 +3673,7 @@ _Changes staged for the next release accumulate here (rolled up from
2415
3673
 
2416
3674
  WRITES: `useOutbox` gains durability (`persistence:` seam; the one real implementation is `outboxPersistence()` over the same `PersistenceAdapter` the sync queue drains — one durable queue per device) and conflict resolution: `resolveConflict(id, input)` returns a conflicted entry to pending with the resolved input, typically computed by `resolveWithPolicy()` — `crdtText()` columns MERGE, scalars follow the declared `conflictPolicy()`, convergence proven side-symmetric. Multi-tab safety via `withDrainLock` (an exclusive per-partition Web Lock; a host without the API drains unlocked and reports it). Presence rides ONE wire: `usePresenceChannel` in plugin-presence/web adapts the local-first `PresenceChannel` onto the framework's existing presence lane instead of a second transport.
2417
3675
 
2418
- Proven end-to-end in a REAL chromium against a real `voltro serve` (`scripts/browser-local-first.mjs`): online seed → 5 offline edits → page RELOAD (queue survives in real IndexedDB, order preserved, mirrored rows render) → drain under the real Web Lock → server and a second browser context converge on all 6 rows; two tabs race the lock and exactly one drains; a foreign subject's binding reads nothing and purge empties exactly one partition.
3676
+ Proven end-to-end in a REAL chromium against a real `voltro serve`: online seed → 5 offline edits → page RELOAD (queue survives in real IndexedDB, order preserved, mirrored rows render) → drain under the real Web Lock → server and a second browser context converge on all 6 rows; two tabs race the lock and exactly one drains; a foreign subject's binding reads nothing and purge empties exactly one partition.
2419
3677
  - **@voltro/cli** — OG-image generation (plan 13). A page declares its `og:image` as a satori JSX template (`export const ogImage = ({ params, loaderData, locale }) => …`); `static` pages bake the PNG at build time — hashed into `dist/assets/og/`, `og:image`/`twitter:image`/`twitter:card` injected with the absolute `seo.siteUrl`, the page's own `og:image` meta winning over the generated tag — and `ssr` pages serve it on demand over `/_voltro/og`, ONE builder mounted by `voltro dev` AND `voltro start` (head injection lives in the SHARED head builder, so the streamed arm a plain ssr page takes cannot drift from the buffered one — it did, for one commit, and the parity e2e is what caught it).
2420
3678
 
2421
3679
  The on-demand URL is signed: HMAC-SHA256 (timing-safe compare) over route + params + tenant + locale — tampering answers 403, tenant/locale ride the signature AND the cache key, and the PNG caches in the same IsrCache backend as the page cache. Secret handling is conditional by design: a single process mints a per-boot secret (sign and verify happen in the same process); a DEPLOY boot with ssr `ogImage` pages and no `VOLTRO_OG_SECRET` refuses loudly — behind a load balancer the signing and the fetching replica differ, and a per-boot secret would 403 every cross-replica fetch. Never a default value.
@@ -2429,7 +3687,7 @@ _Changes staged for the next release accumulate here (rolled up from
2429
3687
 
2430
3688
  The shell render is fail-closed, not merely stripped: its loader context and `useServerRequest()` snapshot THROW by name on credential access (cookie, authorization, x-voltro-*; any cookie but voltro:locale) — the first request answers with an error naming the read and the fix ("move it into a deferred hole"), instead of baking silently-empty subject data into an artefact served to everyone. Holes are async functions; their credential reads happen inside the promise and see the real request only on the hole pass.
2431
3689
 
2432
- Static + ppr stays refused (a static file host cannot append anything — isr with a long revalidate is that page); ppr requires `interactive: 'full'`; layout loaders cannot defer on a ppr page (v1); csp nonces are refused as on isr. `voltro dev` mirrors the whole behaviour through the shared `pprRender.ts`. Proven end-to-end by `scripts/ppr-e2e.mjs` with a FILE-GATED hole (deterministic, no timing waits): shell chunk received while the hole is provably open, settle script after the gate opens, per-subject hole content with a byte-identical subject-free shell prefix across subjects, cache HIT on the second request, the named 500 for an eager credential read, dev parity, and a browser client-navigation rendering the hole via the client defer path.
3690
+ Static + ppr stays refused (a static file host cannot append anything — isr with a long revalidate is that page); ppr requires `interactive: 'full'`; layout loaders cannot defer on a ppr page (v1); csp nonces are refused as on isr. `voltro dev` mirrors the whole behaviour through the shared `pprRender.ts`. Proven end-to-end by A build check with a FILE-GATED hole (deterministic, no timing waits): shell chunk received while the hole is provably open, settle script after the gate opens, per-subject hole content with a byte-identical subject-free shell prefix across subjects, cache HIT on the second request, the named 500 for an eager credential read, dev parity, and a browser client-navigation rendering the hole via the client defer path.
2433
3691
  - **@voltro/plugin-presence** — `presencePlugin({ resolveMember })` — resolve the fields other channel members see about a caller (display name, avatar URL) server-side, from the authenticated subject. `meta` is client-supplied and handed to every channel member verbatim, which is the right contract for a cursor and the wrong one for identity: any member could present any name and any `<img src>` to everyone else. The resolver runs on every heartbeat and its result merges OVER the caller's `meta`, so a client cannot override what the server says about them; returning `undefined` declines and leaves `meta` untouched. The docs and the package description now say plainly that `meta` is unvalidated and relayed verbatim — identity does not belong in it.
2434
3692
  - **@voltro/plugin-queue, @voltro/cli, @voltro/devtools-ui, @voltro/plugin-cdc-out, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-mssql, @voltro/sql-sqlite** — `@voltro/plugin-queue` — interop with a Kafka an adopter already runs, as the door to foreign queues (the outbox stays the path for your OWN durable side-effects, workflows for your own orchestration). Kafka first; the `QueueProvider` contract is cut so SQS/RabbitMQ can be later implementations.
2435
3693
 
@@ -2449,7 +3707,7 @@ _Changes staged for the next release accumulate here (rolled up from
2449
3707
 
2450
3708
  Behavior per `interactive` mode is DECIDED: on `'none'` the bundle never ships so a `<Script>` can never fire — the build warns by name; on `'islands'` the page's static part never mounts — the build warns and the answer is moving the script into an `*.island.tsx` (it then loads when that island hydrates). CSP: an explicit `nonce` prop wins; otherwise the injector propagates the document's own nonce (SSR pages under the middleware's `cspNonce` get it automatically); static pages have no per-request nonce path — `'strict-dynamic'` or a hash policy is the documented answer.
2451
3709
 
2452
- Proven: 7 jsdom unit tests (dedupe, cached callbacks, id refusal, stubbed rIC + Safari fallback, nonce propagation) + a real-chromium e2e (`scripts/browser-script-component.mjs`, 11 checks: hydration-before-script ordering without sleeps, one request for two tags, remount onLoad without a second request, both build warnings asserted against a real `voltro build`).
3710
+ Proven: 7 jsdom unit tests (dedupe, cached callbacks, id refusal, stubbed rIC + Safari fallback, nonce propagation) + a real-chromium e2e (11 checks: hydration-before-script ordering without sleeps, one request for two tags, remount onLoad without a second request, both build warnings asserted against a real `voltro build`).
2453
3711
  - **@voltro/protocol, @voltro/runtime, @voltro/client** — Server-side FIELD validation, end to end. `@voltro/protocol` gains the browser-safe `ValidationError({ field, message, params? })` and `ValidationErrors({ issues })` — the constructors the docs promised for several versions while no package exported them — auto-merged into every mutation's and action's wire error union (exactly like `ScopeError` and `BusinessRuleViolation`), so no descriptor ever declares them. Executors raise them through the new, always-present `ctx.validation`:
2454
3712
 
2455
3713
  ```ts
@@ -2478,7 +3736,7 @@ _Changes staged for the next release accumulate here (rolled up from
2478
3736
 
2479
3737
  Also in the box: payload cap handling (over ~4 KB the payload SHRINKS — `data` first, then the body truncates — never dropped), click tracking (a per-delivery token rides the payload; the service worker's `notificationclick` reports it and the record gains `clickedAt` — the token itself never reaches a dashboard reader), quiet hours / digests / preferences applying unchanged (preference key `webPush`), subject-bound subscribe/unsubscribe RPC mutations, and the dashboards' delivery panel showing per-endpoint rows + a clicked badge.
2480
3738
 
2481
- Proven twice, per the plan's split: a mock-push-endpoint suite asserts the VAPID JWT verifies against the derived public key, the body decrypts with the subscriber's keys, TTL rides the request, the oversize payload shrinks, and the 2-endpoints-1-dead case delivers one and prunes one; a real-chromium e2e (`scripts/webpush-e2e.mjs`) registers the SHIPPED service worker, delivers a simulated push over CDP, and asserts the event fires with the payload intact — and not at all after unregistering.
3739
+ Proven twice, per the plan's split: a mock-push-endpoint suite asserts the VAPID JWT verifies against the derived public key, the body decrypts with the subscriber's keys, TTL rides the request, the oversize payload shrinks, and the 2-endpoints-1-dead case delivers one and prunes one; a real-chromium e2e registers the SHIPPED service worker, delivers a simulated push over CDP, and asserts the event fires with the payload intact — and not at all after unregistering.
2482
3740
 
2483
3741
  ### Changed
2484
3742
 
@@ -2605,7 +3863,7 @@ _Changes staged for the next release accumulate here (rolled up from
2605
3863
 
2606
3864
  Correctness edges built in: `revalidatePath` against a `static` route is a NAMED error on the web process (never a silent no-op); a purge landing while an SWR refresh or miss fill renders is guarded by a per-key generation counter on BOTH cache backends — the pre-purge page cannot be written back with a full TTL, and a refused write also suppresses the postgres backend's fire-and-forget upsert so no replica resurrects a deleted row. A content type declares `revalidate: { paths, tags }` and `publish()`/`unpublish()` fire them after commit.
2607
3865
 
2608
- Proven end-to-end (`scripts/revalidate-e2e.mjs`): 1 api + 2 `voltro start` replicas behind Redis (warm → purge → both fresh, with a negative control), the sqlite dialect leg, and a broker-less postgres leg where the NOTIFY line alone carries the purge to a LISTEN-only replica.
3866
+ Proven end-to-end: 1 api + 2 `voltro start` replicas behind Redis (warm → purge → both fresh, with a negative control), the sqlite dialect leg, and a broker-less postgres leg where the NOTIFY line alone carries the purge to a LISTEN-only replica.
2609
3867
 
2610
3868
  `apiSurface: compatible` — additive only: the new `@voltro/runtime` revalidation exports, an optional `revalidate` on `ContentTypeSpec`, the optional `onRevalidate`/`revalidateChannel` on the CDC invalidator options, a widened `BroadcastChannelKind`, and `IsrCache.set`'s new optional generation guard (plus `generation()`).
2611
3869
  - **@voltro/protocol, @voltro/plugin-openapi** — `defineRestRoute` takes an opt-in `version:` — versioned REST APIs with a sunset flow.
@@ -2621,7 +3879,7 @@ _Changes staged for the next release accumulate here (rolled up from
2621
3879
 
2622
3880
  Fallback is exact: a browser without the API, and any user with `prefers-reduced-motion: reduce`, gets today's untransitioned swap — same timing, nothing to feature-detect. Styling is plain `::view-transition-*` CSS (no animation DSL); cross-document transitions for static/MPA pages are a one-line `@view-transition` CSS opt-in with no framework involvement.
2623
3881
 
2624
- Proven in a real chromium (`scripts/browser-view-transitions.mjs`): called on navigation, silent under reduced-motion, harmless with the API deleted, one transition across a `defer()` commit, rapid double-navigation lands on the last target — plus the jsdom wiring suite and the generated-entry flag check shared by all three web boot paths.
3882
+ Proven in a real chromium: called on navigation, silent under reduced-motion, harmless with the API deleted, one transition across a `defer()` commit, rapid double-navigation lands on the last target — plus the jsdom wiring suite and the generated-entry flag check shared by all three web boot paths.
2625
3883
 
2626
3884
  `apiSurface: compatible` — additive only: a new optional `viewTransitions` on `RouterProps`, optional `transition` on `NavigateOptions`/`LinkProps`, and the optional `router` block on the web app config.
2627
3885
  - **@voltro/web, @voltro/cli** — Schema-typed search params — the query-string half of the URL is now part of the type graph.
@@ -3203,13 +4461,13 @@ _Changes staged for the next release accumulate here (rolled up from
3203
4461
 
3204
4462
  - **@voltro/protocol, @voltro/voltro** — A `_voltro_*` name in `source:` is no longer narrowed against the generated table declaration.
3205
4463
 
3206
- Which framework tables an app declares is DEPLOYMENT-dependent. The measurement is already in the maintainer notes: at one `NODE_ENV`, on one dialect, flipping a single flag adds or removes `_voltro_traces`, `_voltro_undo_log` or `_voltro_cdc_offsets` from the declared set. The generated `voltro-tables.generated.d.ts` is written by ONE `voltro dev` run, on one machine, with one set of those inputs.
4464
+ Which framework tables an app declares is DEPLOYMENT-dependent. The measurement: at one `NODE_ENV`, on one dialect, flipping a single flag adds or removes `_voltro_traces`, `_voltro_undo_log` or `_voltro_cdc_offsets` from the declared set. The generated `voltro-tables.generated.d.ts` is written by ONE `voltro dev` run, on one machine, with one set of those inputs.
3207
4465
 
3208
4466
  Narrowing framework names against it therefore made `source: '_voltro_traces'` compile for whoever generated the file and fail for a colleague — a type error decided by an environment variable, which is the exact class the declared-schema rule forbids one layer up. An app's own tables are unaffected: `_voltro_` is a reserved prefix, so every name a user writes for their own data narrows exactly as before.
3209
4467
 
3210
4468
  **How it surfaced is the part worth recording.** Until now `keyof VoltroTableNames` was always `never` inside this repo, so `TableName` was always `string`, so the narrow and wide types were the same type and every rule about them held vacuously. The first time an augmentation was ever present — a fixture that boots a real server writing the declaration beside its generated rpc group — four framework source files stopped compiling. The split had shipped without once being exercised in the direction that matters.
3211
4469
 
3212
- Two things now stop that from going quiet again. A type-test program compiles the framework's own sources under an augmentation that deliberately declares NONE of its tables, and it lives in its OWN tsconfig: `declare module` merging is program-global, so a sibling type test's augmentation had silently rescued the very assertion this one exists to make. And `scripts/check-type-tests.mjs` (CI + `pnpm gate`) DISCOVERS type-test programs and runs them — because nothing did. The existing narrowing assertions had never been compiled once: excluded from their package's tsconfig for a good reason, and picked up by nothing else. It refuses a zero-program run, and a program that compiles zero `*.test-d.ts` files, for the same reason every other check here carries a floor.
4470
+ Two things now stop that from going quiet again. A type-test program compiles the framework's own sources under an augmentation that deliberately declares NONE of its tables, and it lives in its OWN tsconfig: `declare module` merging is program-global, so a sibling type test's augmentation had silently rescued the very assertion this one exists to make. And A build check DISCOVERS type-test programs and runs them — because nothing did. The existing narrowing assertions had never been compiled once: excluded from their package's tsconfig for a good reason, and picked up by nothing else. It refuses a zero-program run, and a program that compiles zero `*.test-d.ts` files, for the same reason every other check here carries a floor.
3213
4471
 
3214
4472
  **Why `apiSurface: compatible`, and how to check it rather than take it.** The gate flagged the golden line as CHANGED and asked the right question — can this turn code that compiled into code that does not? Here it cannot, because the edit WIDENS a union, and every public position the type appears in is an INPUT: `source?:` on a descriptor, and `normalizeSource`'s parameter. Nothing in the published surface RETURNS `TableName` or `ReactivitySource`, which is the only direction in which widening breaks a consumer — an assignment FROM the type into something narrower. `@voltro/voltro` is listed beside `@voltro/protocol` because it re-exports the type, so its golden moved too; the gate matches per package, and one package's classification must not vouch for another's.
3215
4473
  - **@voltro/sql-mssql** — A `json()` column could not take a value on mssql. At all.
@@ -3273,7 +4531,7 @@ _Changes staged for the next release accumulate here (rolled up from
3273
4531
 
3274
4532
  No exception list, deliberately. An eager-loaded relation is composition by definition — its rows are IN the result — and its table comes from the relation registry, so the missing name is a fact rather than an inference. A many-to-many is reported twice when needed: adding or removing a link writes only the JUNCTION row, so declaring the target alone leaves the list stale on exactly the operation a user performs to change it. A computed `.with()` key yields nothing rather than a guess.
3275
4533
 
3276
- The general question — every table an executor reads — is NOT answered, on purpose: it needs a compose-versus-restrict judgement a scan can only infer from syntax, and a rule that guesses on a correct codebase teaches its reader to ignore it. Design in `plans/open/framework/source-completeness.md`.
4534
+ The general question — every table an executor reads — is NOT answered, on purpose: it needs a compose-versus-restrict judgement a scan can only infer from syntax, and a rule that guesses on a correct codebase teaches its reader to ignore it.
3277
4535
 
3278
4536
  Two things around it:
3279
4537
 
@@ -3327,12 +4585,12 @@ _Changes staged for the next release accumulate here (rolled up from
3327
4585
 
3328
4586
  `clusterColdStart` drops a per-run database, and the runners it spawned are killed with SIGKILL, so their backends never close — hence the deliberate `pg_terminate_backend` before the `DROP`. But `pool.end()` resolves once it has ASKED the pool to close, not once every socket is down, so the terminate could also land on a connection belonging to the test itself. `pg` reports that as an `error` event on the idle client, and an unhandled one takes down the process.
3329
4587
 
3330
- The shape it took on a release gate is the reason this is written down: **36 of 36 test files green, and the suite exiting 1.** Nothing points at the teardown — the failure is attributed to whichever suite happened to run last, which is a different one each time. A connection error while we are tearing the database down carries no signal, so it is handled where it arises.
4588
+ The shape it took is the reason this is written down: **36 of 36 test files green, and the suite exiting 1.** Nothing points at the teardown — the failure is attributed to whichever suite happened to run last, which is a different one each time. A connection error while we are tearing the database down carries no signal, so it is handled where it arises.
3331
4589
 
3332
4590
  Test-only; no product code changed.
3333
4591
  - **@voltro/plugin-auth** — The TOTP skew-window test uses a fixed secret. Test-only; no product code changed, and the assertion is unchanged.
3334
4592
 
3335
- It failed once on a release gate — `expected true to be false`, meaning a code two steps outside the ±1 window verified. That is the shape of a security defect, so it was treated as one until measured:
4593
+ It failed once — `expected true to be false`, meaning a code two steps outside the ±1 window verified. That is the shape of a security defect, so it was treated as one until measured:
3336
4594
 
3337
4595
  - `TOTP_SKEW` is 1 and the verify loop checks exactly three counters, compared with `timingSafeEqual`; - `T0` is a constant and the clock is injected, so the only varying input was `generateTotpSecret()`; - over **50 000 fresh secrets**: zero collisions between the ±2 codes and the ±1 window (pure chance predicts ~0.3), zero degenerate secrets, uniform length; - **60 consecutive runs** of the file: green.
3338
4596
 
@@ -3480,7 +4738,7 @@ _Changes staged for the next release accumulate here (rolled up from
3480
4738
 
3481
4739
  ### Internal (no consumer-facing effect)
3482
4740
 
3483
- - **@voltro/sql-mysql** — Two test-only defects in `sql-mysql`, both found by a release gate, both of the same family: a check that could not fail, and a failure reported in the wrong place. No product code changed.
4741
+ - **@voltro/sql-mysql** — Two test-only defects in `sql-mysql`, both found before release, both of the same family: a check that could not fail, and a failure reported in the wrong place. No product code changed.
3484
4742
 
3485
4743
  **An assertion that could not fail.** `dropCheckSyntax.integration.test.ts` fell back to a HAND-BUILT plan when the planner produced no operations — and the fabricated operation was a `drop-check`, which is exactly what the next line asserts the plan contains. So an engine whose planner stopped emitting it would have been handed one and reported green. The fallback is deleted; both engines produce the operation now, which is what this release fixed, and the assertion is load-bearing again (4/4 on mysql AND mariadb without it).
3486
4744
 
@@ -3632,7 +4890,7 @@ _Changes staged for the next release accumulate here (rolled up from
3632
4890
 
3633
4891
  MySQL puts a charset introducer before each literal, which the pattern — written against MariaDB's form — did not read. Both are pinned in `enumCheckParity.test.ts`.
3634
4892
 
3635
- Neither fix completes the round trip on MySQL: `.oneOf()` still comes back unclassified there. That is asserted as a known gap in `oneOfCheck.mariadb.integration.test.ts` (which fails the moment it starts working) and written up in `plans/open/framework/mysql-oneof-roundtrip.md`.
4893
+ Neither fix completes the round trip on MySQL: `.oneOf()` still comes back unclassified there. That is asserted as a known gap in `oneOfCheck.mariadb.integration.test.ts` (which fails the moment it starts working).
3636
4894
  - **@voltro/database** — `voltro db apply` works on MySQL. It could not create a table with an index, and could not drop a column, on that engine at all.
3637
4895
 
3638
4896
  `IF [NOT] EXISTS` outside `CREATE`/`DROP TABLE` is a MariaDB extension — MySQL rejects it with ER_PARSE_ERROR (measured on 8.4 for `CREATE INDEX IF NOT EXISTS`, `ALTER TABLE … DROP COLUMN IF EXISTS`, and `ADD COLUMN IF NOT EXISTS`). The applier emitted the first two, because `@effect/sql-mysql2` reports the dialect `mysql` for both engines and the shared branch had only ever run against MariaDB. A `reference()` column gets an index by default, so in practice most tables were affected.
@@ -3649,7 +4907,7 @@ _Changes staged for the next release accumulate here (rolled up from
3649
4907
 
3650
4908
  `migrate.ts` emits the prefixed index now, through the same `indexStmt` that already owns the per-dialect `IF NOT EXISTS` rule, and names it `<table>_<column>_key` to match the applier's — so the two paths produce the same object.
3651
4909
 
3652
- **Note the behaviour change on MariaDB.** It accepted the inline form by backing it with a HASH long-unique index, whose hidden `DB_ROW_HASH_n` column breaks the binlog CDC reader (documented in `packages/database/CLAUDE.md`). Uniqueness on such a column is now enforced on the first 191 characters rather than the whole value — which is what `voltro db apply` already did, and what MySQL can express at all. Bound the column with `text().maxLength(n)` if you need full-value uniqueness.
4910
+ **Note the behaviour change on MariaDB.** It accepted the inline form by backing it with a HASH long-unique index, whose hidden `DB_ROW_HASH_n` column breaks the binlog CDC reader. Uniqueness on such a column is now enforced on the first 191 characters rather than the whole value — which is what `voltro db apply` already did, and what MySQL can express at all. Bound the column with `text().maxLength(n)` if you need full-value uniqueness.
3653
4911
  - **@voltro/sql-mysql** — `insertIgnore` on MySQL was a different feature from `insertIgnore` on MariaDB — and the difference could turn a conflict into an error.
3654
4912
 
3655
4913
  The whole diagnostic apparatus — the refusal to report a REJECTED write as a conflict, and the message naming the constraint that actually fired — sat behind a `variant === 'mariadb'` branch. MySQL took an `else` that used no `INSERT IGNORE` at all: look for a row matching the conflict columns, insert if there is none. That cannot hold the one property the method exists for. A caller that looks before anyone else writes sees nothing, so the write it then makes is the one that raises the duplicate-key error `insertIgnore` promises never to raise — reproduced deterministically against both engines with an uncommitted holder (the lookup cannot see the holder's row; the insert cannot proceed until it commits).
@@ -3954,7 +5212,7 @@ _Changes staged for the next release accumulate here (rolled up from
3954
5212
 
3955
5213
  - **@voltro/cache, @voltro/kv** — The RESP TTL tests in `@voltro/cache` and `@voltro/kv` stopped measuring the machine. Test-only; no product code changed.
3956
5214
 
3957
- Both wrote a key with `ttlMs: 150`, asserted it was still there, then slept 300 ms and asserted it was gone. The second half is fine — waiting LONGER only strengthens "it expired". The first half was a race: the write and the read are two round-trips, so on a loaded runner the key legitimately expired before the liveness assertion, and the test reported a defect that was not there. It failed exactly that way on a release gate, in the keydb engine, while passing locally with 24/24 green.
5215
+ Both wrote a key with `ttlMs: 150`, asserted it was still there, then slept 300 ms and asserted it was gone. The second half is fine — waiting LONGER only strengthens "it expired". The first half was a race: the write and the read are two round-trips, so on a loaded runner the key legitimately expired before the liveness assertion, and the test reported a defect that was not there. It failed exactly that way in the keydb engine, while passing locally with 24/24 green.
3958
5216
 
3959
5217
  Now: a 2 s window, so liveness has real headroom rather than 150 ms of it, and the expiry is awaited as a CONDITION (`awaitGone` polls) rather than as a duration. Idle machines finish in about the TTL; loaded ones take as long as they need; a key that never expires still fails, because the ceiling is a failure mode and not a timing assumption.
3960
5218
 
@@ -4172,10 +5430,10 @@ _Changes staged for the next release accumulate here (rolled up from
4172
5430
 
4173
5431
  ### Internal (no consumer-facing effect)
4174
5432
 
4175
- - **@voltro/datetime, @voltro/local-first, @voltro/react-native** — `@voltro/datetime`, `@voltro/local-first` and `@voltro/react-native` shipped with no api-extractor golden, so the public-surface drift tripwire did not cover them — and the docs-audit finding that motivated this landed in exactly that gap (the docs promised a `useTimezone()` hook that `@voltro/datetime` never exported, and no gate could see it). All three are wired now, root + subpath entry (`./context`, `./react`, `./schema`): six goldens, `api:check` green on each, and no existing golden changed (the new `paths` entries every sibling map gained are purely additive).
5433
+ - **@voltro/datetime, @voltro/local-first, @voltro/react-native** — `@voltro/datetime`, `@voltro/local-first` and `@voltro/react-native` shipped with no api-extractor golden, so the public-surface drift tripwire did not cover them — and the documentation gap that motivated this landed in exactly that gap (the docs promised a `useTimezone()` hook that `@voltro/datetime` never exported, and no gate could see it). All three are wired now, root + subpath entry (`./context`, `./react`, `./schema`): six goldens, `api:check` green on each, and no existing golden changed (the new `paths` entries every sibling map gained are purely additive).
4176
5434
 
4177
5435
  Root cause fixed in the generator rather than by hand: `gen-api-extractor.mjs` now creates the package's `etc/` directory with the wiring. api-extractor refuses to create its own report folder, so a package wired without one failed at its first `api:report` instead of at generation — which is how these three went live uncovered. Internal: no consumer-facing behaviour changes.
4178
- - **@voltro/cli** — `gen-api-extractor.mjs --check` verifies the api-surface wiring instead of writing it, and runs in CI (and therefore in `pnpm gate`, which derives its steps from `ci.yml`). It fails when a published entry point has no api-extractor config, no golden, an EMPTY golden, a stale config/golden for a dropped export, or no `api:check` script.
5436
+ - **@voltro/cli** — `gen-api-extractor.mjs --check` verifies the api-surface wiring instead of writing it, and runs in CI. It fails when a published entry point has no api-extractor config, no golden, an EMPTY golden, a stale config/golden for a dropped export, or no `api:check` script.
4179
5437
 
4180
5438
  It is derived from `publishConfig.exports` inside the generator's own loop — not a curated list and not a second copy of the derivation — so a package that joins the workspace is covered without anyone remembering to add it. It carries a floor (60 packages) for the reason every check in `scripts/` has one: the failure mode of a wiring check is a green line over a walk that found nothing.
4181
5439
 
@@ -4184,7 +5442,7 @@ _Changes staged for the next release accumulate here (rolled up from
4184
5442
 
4185
5443
  It matters because of what the byte does to the FILE rather than to the hash: a source file containing a NUL is binary to every text tool, so `grep` skips it and prints nothing, which is indistinguishable from a clean file. This repo has been bitten by exactly that — a 1020-line module that every grep-based audit had silently skipped, including one searching for a string that file declares.
4186
5444
 
4187
- The guard (`noLiteralNulInSources.test.ts`) caught it on the release gate, in a file added earlier in this same release. The rule was already written down; what enforced it was the test.
5445
+ The guard (`noLiteralNulInSources.test.ts`) caught it in a file added earlier in this same release. The rule was already written down; what enforced it was the test.
4188
5446
 
4189
5447
  ---
4190
5448
 
@@ -4352,7 +5610,7 @@ _Changes staged for the next release accumulate here (rolled up from
4352
5610
  `inspectGateHint` is shared by both call sites and distinguishes the two statuses, because they call for different actions: a 401 means no credential was sent (set the variable), a 403 means the one sent was not accepted (the two values differ). Both halves of the sentence name the server AND the calling shell — naming one side produces a second failed attempt.
4353
5611
  - **@voltro/cli** — `VOLTRO_TEMPLATES_DIR` is authoritative when set. It used to be a HINT: if the path it named held no `apps/` (or no `baselines/`), both resolvers fell through to the sibling-checkout walk-up and quietly used a different tree — or none.
4354
5612
 
4355
- A pointer that silently isn't followed is worse than a wrong one. A CI job aimed at the wrong path scaffolded from whatever it happened to find, and a job whose checkout had failed reported an empty template catalogue with nothing connecting that emptiness to the variable it was given. `scripts/lib/docsSite.mjs` states the same rule for `VOLTRO_DOCS_DIR`, and arrived at it the same way: you said where it is; it is not there.
5613
+ A pointer that silently isn't followed is worse than a wrong one. A CI job aimed at the wrong path scaffolded from whatever it happened to find, and a job whose checkout had failed reported an empty template catalogue with nothing connecting that emptiness to the variable it was given. A build check states the same rule for `VOLTRO_DOCS_DIR`, and arrived at it the same way: you said where it is; it is not there.
4356
5614
 
4357
5615
  Behaviourally this only changes the misconfigured case — a correct `VOLTRO_TEMPLATES_DIR` resolved to the same place before and after. What changes is that a wrong one now shows up as "not found, here is the path I was told" at the first thing that reads it, instead of as a different tree three steps later.
4358
5616
 
@@ -4601,7 +5859,7 @@ _Changes staged for the next release accumulate here (rolled up from
4601
5859
  `@voltro/database`'s `coreTablesRegistry` carries this exact fix with a comment describing this exact failure, for a value whose worst case is a crash at boot. This one's worst case is silent data exposure and it did not have it.
4602
5860
 
4603
5861
  The second half answers the reporter's second ask directly: a filter that cannot be applied must fail loudly rather than pass quietly. `undefined` and "we could not tell" had collapsed into one value, and the doc comment on that option already asserted they must not. Every deliberately unfiltered path — system sweeps, `runAsSystem`, change-stream subscribers, the seeding store in a test — now passes `NO_ROW_FILTER` explicitly, because "this app has no filter" and "this path is unfiltered on purpose" are different claims and only the second is a decision somebody made.
4604
- - **@voltro/cli** — A bare `voltro serve` under docker compose now drains on SIGTERM — in-flight requests complete against a fully-alive app, the listener refuses new work, live WebSockets are ended cleanly, and the process exits on its own, well inside `VOLTRO_SHUTDOWN_GRACE_MS`. No preStop hook or endpoint removal required. Two real defects closed (both measured against a live server): a single connected WebSocket wedged `nodeServer.close()` — node's `closeAllConnections()`/`closeIdleConnections()` cannot end an upgraded socket while `close()` still waits on it — so EVERY shutdown with a connected web client ran to the 10s deadline cut and the steps queued behind the close (the store's connection-pool close included) silently never ran; and the shutdown hook deactivated plugins and drained the analytics mirror BEFORE the request drain, so a request finishing during shutdown hit dead services and its writes were never mirrored. The drain is bounded: in-flight requests get 60% of the shutdown grace (floor 500ms), stragglers are then destroyed, and idle keep-alive sockets are swept continuously so a finished response never delays exit. The stale `serveApi` comment claiming `NodeRuntime.runMain` owns SIGTERM (and pointing at a k8s preStop hook as the fix) is rewritten to describe the drain that actually runs. Verify against a real serve with `node scripts/serve-drain-check.mjs`.
5862
+ - **@voltro/cli** — A bare `voltro serve` under docker compose now drains on SIGTERM — in-flight requests complete against a fully-alive app, the listener refuses new work, live WebSockets are ended cleanly, and the process exits on its own, well inside `VOLTRO_SHUTDOWN_GRACE_MS`. No preStop hook or endpoint removal required. Two real defects closed (both measured against a live server): a single connected WebSocket wedged `nodeServer.close()` — node's `closeAllConnections()`/`closeIdleConnections()` cannot end an upgraded socket while `close()` still waits on it — so EVERY shutdown with a connected web client ran to the 10s deadline cut and the steps queued behind the close (the store's connection-pool close included) silently never ran; and the shutdown hook deactivated plugins and drained the analytics mirror BEFORE the request drain, so a request finishing during shutdown hit dead services and its writes were never mirrored. The drain is bounded: in-flight requests get 60% of the shutdown grace (floor 500ms), stragglers are then destroyed, and idle keep-alive sockets are swept continuously so a finished response never delays exit. The stale `serveApi` comment claiming `NodeRuntime.runMain` owns SIGTERM (and pointing at a k8s preStop hook as the fix) is rewritten to describe the drain that actually runs.
4605
5863
 
4606
5864
  ---
4607
5865
 
@@ -4980,7 +6238,7 @@ _Changes staged for the next release accumulate here (rolled up from
4980
6238
  Migration — **`codemod: none`, and here is why no user-authored code needs rewriting**: nothing in the new refusals is reachable from code a codemod could find. `queryableFields` and `allowedEngineParams` are additive options. What changes is what the SERVER answers at runtime, in three cases that were all bugs: querying an index you never declared, naming a field that is not a field, and passing an `engineParams` key that overrode a filter. If your app depended on one of them, the fix is a declaration, not an edit to a call site — declare the index in `searchPlugin({ indexes })`, or add the key to `allowedEngineParams`. `search.query` also carries a wire error union now (`SearchIndexNotFound | SearchFieldRejected`), which existing callers decode as a rejected promise exactly as they already do for any other typed error.
4981
6239
 
4982
6240
  The vendor backend factories take an optional second argument (`typesenseBackend(cfg, hooks)`) carrying the app's allowlist widening and the warn sink; the plugin wires it from `app.config.ts`, so a hand-constructed backend keeps working unchanged with the defaults.
4983
- - **@voltro/runtime, @voltro/voltro** — **Security response headers ship by default (SEC-9).** The framework sent exactly one, and only on served storage blobs (`X-Content-Type-Options: nosniff`, `@voltro/plugin-storage`). No HSTS, no CSP, no `X-Frame-Options`, no `Referrer-Policy` anywhere on the general serve path — while `plans/product/08-security-and-compliance.md` claimed all four as day-one defaults. Every response from the api listener now carries:
6241
+ - **@voltro/runtime, @voltro/voltro** — **Security response headers ship by default (SEC-9).** The framework sent exactly one, and only on served storage blobs (`X-Content-Type-Options: nosniff`, `@voltro/plugin-storage`). No HSTS, no CSP, no `X-Frame-Options`, no `Referrer-Policy` anywhere on the general serve path — while the shipped security posture claimed all four as day-one defaults. Every response from the api listener now carries:
4984
6242
 
4985
6243
  content-security-policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none' x-frame-options: DENY referrer-policy: no-referrer x-content-type-options: nosniff strict-transport-security: max-age=15552000; includeSubDomains (https only)
4986
6244
 
@@ -5528,9 +6786,9 @@ _Changes staged for the next release accumulate here (rolled up from
5528
6786
 
5529
6787
  The web bundle is a **pinned number** instead of an anecdote, islands mode says what it actually costs, and `prefetch` warms the page chunk as well as the loaders.
5530
6788
 
5531
- **A bundle-size gate.** `node packages/web/scripts/bundle-budget.mjs` builds the reference web fixture and compares gzipped first-load + per-chunk sizes against the committed `packages/web/bundle-budget.json`. Wired into CI's Build job, selftest first. It fails in **both** directions: over budget is a regression, and materially under is also red with "run `--update`", because a ceiling nobody ratchets down silently re-permits inflating back to the old number. First load today is **190.4 KB gz** on that fixture.
6789
+ **A bundle-size gate.** `node packages/web/scripts/bundle-budget.mjs` builds the reference web fixture and compares gzipped first-load + per-chunk sizes against the committed `packages/web/bundle-budget.json`. It fails in **both** directions: over budget is a regression, and materially under is also red with "run `--update`", because a ceiling nobody ratchets down silently re-permits inflating back to the old number. First load today is **190.4 KB gz** on that fixture.
5532
6790
 
5533
- The number that motivated the work — "252 KB, Effect ~43%" — was half right and nothing could re-derive it. It came from a stale, gitignored fixture build carrying **development** React (a 392 KB raw react chunk); a production build of the same fixture ships 185.8 KB raw / 57.6 KB gz of React. The ratio survived the correction: `--attribute` attributes shipped bytes through the build's own sourcemaps and puts the Effect runtime at 78.3% of the `index` chunk — ~41% of the whole first load. Two earlier attempts at that attribution were wrong in ways that still printed a table (vite externalises a scratch entry's imports; rollup's `renderedLength` is pre-minify and sums to 221% of the emitted chunk), so the working method is documented in the script and its selftest pins the inlined VLQ decoder.
6791
+ The number that motivated the work — "252 KB, Effect ~43%" — was half right and nothing could re-derive it. It came from a stale, gitignored fixture build carrying **development** React (a 392 KB raw react chunk); a production build of the same fixture ships 185.8 KB raw / 57.6 KB gz of React. The ratio survived the correction: `--attribute` attributes shipped bytes through the build's own sourcemaps and puts the Effect runtime at 78.3% of the `index` chunk — ~41% of the whole first load. Two earlier attempts at that attribution were wrong in ways that still printed a table (vite externalises a scratch entry's imports; rollup's `renderedLength` is pre-minify and sums to 221% of the emitted chunk), so the working method is documented in the script and its own fixture pins the inlined VLQ decoder.
5534
6792
 
5535
6793
  **That floor is structural, and the fixture proves it**: it declares zero rpc procedures and still ships 85 KB gz of Effect, because `@voltro/web` re-exports `@voltro/client` at value level. One non-structural item is measured and deliberately left: `msgpackr` is 9.8 KB gz of every browser bundle for a serializer the framework never selects — `@effect/rpc` imports it at module top level and it declares no `sideEffects: false`. Fixing that is a dependency patch.
5536
6794
 
@@ -5600,7 +6858,7 @@ _Changes staged for the next release accumulate here (rolled up from
5600
6858
  They are env vars rather than `app.config.ts` fields, unlike most framework tunables. The reason is the SHAPE of this particular knob, not precedent: the connection config is assembled by `connFromEnv` at fifteen call sites across four commands, several of them (the workflow SqlClient, the analytics mirror, the replica pools) nowhere near a loaded `app.config.ts`. A field readable at some of those sites and not others would produce exactly the per-call-site divergence the rest of this change set is removing — the DB connection is one decision per deployment, and the environment is where the rest of it (`DB_URL`, `DB_MAX_CONNECTIONS`, `DB_SCHEMA`, `PG_SSL`) already lives.
5601
6859
  - **@voltro/plugin-cdc-out** — `cdc-out`'s enqueue guarantee is stated correctly. It read "exactly-once enqueue per observed change, fleet-wide, across leadership handovers", and the wiring does not support the "exactly-once": `drainHandoff` wins the claim and inserts the outbox row in **two** statements, with no transaction and no orphan rescan around them, so a replica that dies between them leaves a claim every survivor reads as "already enqueued" and the change is gone. The claim's re-entrancy (reading `claimedBy` back) recovers a failed insert only within the same process.
5602
6860
 
5603
- The guarantee, everywhere it is written — `src/index.ts`, the package's maintainer note, this changelog, and the docs page — is now: **Enqueue is de-duplicated per observed change, fleet-wide, across leadership handovers — with one hole: a replica that dies between winning a change's claim and inserting its outbox row loses that change, because the claim survives and nothing rescans orphan claims.** No behaviour changed; the claim did. Closing the hole needs the claim and the insert to become one statement (fold `changeKey` onto the outbox row under `unique(pipe, changeKey)`, or wrap both in a transaction) — an orphan-claim rescan alone cannot recover the change, since a claim row carries no payload and the only copy sits in a replica's in-memory handoff buffer.
6861
+ The guarantee, everywhere it is written — `src/index.ts`, this changelog, and the docs page — is now: **Enqueue is de-duplicated per observed change, fleet-wide, across leadership handovers — with one hole: a replica that dies between winning a change's claim and inserting its outbox row loses that change, because the claim survives and nothing rescans orphan claims.** No behaviour changed; the claim did. Closing the hole needs the claim and the insert to become one statement (fold `changeKey` onto the outbox row under `unique(pipe, changeKey)`, or wrap both in a transaction) — an orphan-claim rescan alone cannot recover the change, since a claim row carries no payload and the only copy sits in a replica's in-memory handoff buffer.
5604
6862
  - **@voltro/cli** — **Every `voltro` command paid for every other command's module graph before it printed anything.** `commands.ts` statically imported all ~50 `run*` functions, so `voltro version` loaded `dev.ts` (~7 000 lines), the Effect runtime, vite, chokidar and the build toolchain in order to print one line.
5605
6863
 
5606
6864
  Measured end to end with `node packages/cli/bin/voltro.mjs version`, both `dist` builds present at once and the two invocations INTERLEAVED so they share the same machine load (a 12-core dev machine at load ~24 — a full gate was running alongside, which inflates both columns and not the ratio). Two independent batches, 9 and 11 alternating pairs, agreed to within 3 ms:
@@ -5777,9 +7035,9 @@ _Changes staged for the next release accumulate here (rolled up from
5777
7035
 
5778
7036
  is a one-line edit that installs perfectly here. Measured, not assumed: under exactly that drift, all 13 tests across `mssqlClusterPatch.test.ts`, `clusterPatchDialectGuard.test.ts` and `addMssql.test.ts` stay green.
5779
7037
 
5780
- `scripts/check-cluster-patch-sync.mjs` (CI + `pnpm gate`) now compares all five declarations against the CONSTANT — the one the consumer actually receives, so it is the one that defines correct — plus two adjacent packaging facts: that the workspace paths point at the CLI-shipped asset rather than a second copy, that no superseded `.patch` is left beside the current one, that the catalog range still admits the patched version, and that `templates` is in the CLI's published `files` (an asset that misses the tarball produces the same consumer-visible failure by a different route).
7038
+ A build check compares all five declarations against the CONSTANT — the one the consumer actually receives, so it is the one that defines correct — plus two adjacent packaging facts: that the workspace paths point at the CLI-shipped asset rather than a second copy, that no superseded `.patch` is left beside the current one, that the catalog range still admits the patched version, and that `templates` is in the CLI's published `files` (an asset that misses the tarball produces the same consumer-visible failure by a different route).
5781
7039
 
5782
- It ships a `--selftest` that runs first, for the reason every check here has one, and it earned it immediately: the selftest caught a destructuring bug in the range comparator on its first run, which would have made the range rule answer confidently and wrongly.
7040
+ .
5783
7041
 
5784
7042
  What it deliberately does NOT check: whether the patch still APPLIES to the named version. That needs an install, and `pnpm install` answers it definitively. The one content invariant that has actually regressed — the `deliver_at` cast must stay mssql-conditional or the sqlite workflow engine hangs — is already owned by `clusterPatchDialectGuard.test.ts`.
5785
7043
 
@@ -6055,7 +7313,7 @@ _Changes staged for the next release accumulate here (rolled up from
6055
7313
 
6056
7314
  - **`startOutboxRunner` now takes a REQUIRED `onShutdown`.** An optional hook would have been the same defect with a nicer name — the serve path's mistake was omission, and an optional field is omissible. Required means the compiler asks the question at every call site, including ones nobody has written yet. dev passes `onProcessShutdown`; `serveApi` registers into the teardown list its `close()` drains, which `serveCommand`'s SIGTERM hook awaits BEFORE it closes the pool. - **`stop()` SETTLES.** It returns a promise that resolves once the pass in flight has finished. Clearing the timer stops the NEXT pass; the delivery already talking to the database is the half a `clearTimeout` cannot reach — the same abandoned-work shape as the analytics mirror's bare `detach()`.
6057
7315
 
6058
- `bootPathParity.test.ts` now models SHUTDOWN as parity, which it did not, although `packages/cli/CLAUDE.md` has said it is for a long time: every receiver dev tears down inside an `onProcessShutdown` body must be torn down by the serve path too, or aliased to the serve-side expression (asserted to exist), or listed with a reason. Red-verified against the shipped state — it names `outboxRunner`.
7316
+ `bootPathParity.test.ts` now models SHUTDOWN as parity, which it did not: every receiver dev tears down inside an `onProcessShutdown` body must be torn down by the serve path too, or aliased to the serve-side expression (asserted to exist), or listed with a reason. Red-verified against the shipped state — it names `outboxRunner`.
6059
7317
  - **@voltro/runtime** — An over-cap `POST /rpc` body sent with `Transfer-Encoding: chunked` now returns `413 Payload Too Large` instead of dropping the connection. The client saw `curl: (56) Recv failure: Connection reset by peer` (or `curl: (52) Empty reply from server`) where the same bytes with a `Content-Length` got a clean 413.
6060
7318
 
6061
7319
  The memory bound was never the problem — the body was cut, nothing buffered without limit, the process stayed healthy and the health probes kept answering. The 413 was *produced* and had nowhere to go: the streaming ceiling (`withMaxBodySize`) destroys the request stream when the count is exceeded, destroying an incomplete Node `IncomingMessage` destroys its socket, and the refusal was then written into a closed connection. Nothing was logged either, so from outside there was no way to tell a refusal from a crash.
@@ -6129,7 +7387,7 @@ _Changes staged for the next release accumulate here (rolled up from
6129
7387
  The four `db apply` return points now share ONE tail (`finishSchemaApply`: plugin migrations, then the reactive-trigger convergence). Adding a second call beside each `await convergeReactiveTriggers(...)` would have reproduced the shape that lost the step in the first place; `schemaApplyParity.test.ts` fails if the convergence call escapes that tail again.
6130
7388
  - **@voltro/plugin-presence** — **Every short description of `@voltro/plugin-presence` described the architecture it replaced, and softened the one condition that matters.** No code changed; the claims did.
6131
7389
 
6132
- Two errors, both propagated from the package's own `description` field into the generated README, the maintainer note, the docs plugin index in both languages and the voltro.dev real-time feature page:
7390
+ Two errors, both propagated from the package's own `description` field into the generated README, the docs plugin index in both languages and the voltro.dev real-time feature page:
6133
7391
 
6134
7392
  - **"Backed by a swept presence table."** It is not. Presence lives in the owner-partitioned in-memory `PresenceTracker`; `_voltro_presence` is DECLARED and deliberately never written, because the name is the reactivity key — `presence.list` declares `source: '_voltro_presence'` and the plugin injects a synthetic change on that name so every subscribed roster re-runs through the path a real write used to take. The sweep sweeps the tracker. A reader reasoning about write amplification, retention or a table scan was reasoning about a table that has no rows. - **"Works cross-instance", unqualified.** It works cross-instance *with* `@voltro/plugin-broadcast`. Without a broker every replica keeps a CORRECT roster of its own clients — nothing errors, and a single-replica staging box is indistinguishable from a working fleet, while in production each screen shows a fraction of the room and which fraction depends on the load balancer. The plugin's own boot already warns about exactly this, and the presence docs page already carried the callout; the index rows and the marketing page did not, so the surfaces a reader hits FIRST were the ones that overclaimed.
6135
7393
 
@@ -6358,15 +7616,15 @@ _Changes staged for the next release accumulate here (rolled up from
6358
7616
 
6359
7617
  **A compiled-SQL-shape cache is not worth building.** Measured with `node packages/sql-postgres/scripts/db-path-cost.mjs` against postgres on loopback — the fastest denominator that exists, so these are upper bounds: `compileSelect` on a realistic 4-leaf predicate is 5.4–5.9 µs, `compileEagerJson` 5.9–7.0 µs, and the CACHE KEY such a lookup needs first is 1.3 µs, against a round trip of 300–2600 µs. So the cache nets ~4.6 µs on a query costing at least 300 — 0.2%–1.5% — and every call site compiles exactly once per round trip, so there is no hidden multiplier. Against that: params are captured per call, so the cache must store the shape and re-bind, and a bug there leaks one caller's values into another caller's query. Recorded in `sqlCompiler.ts` so the next audit re-measures instead of re-proposing.
6360
7618
 
6361
- Both scripts carry a `--selftest` that runs first and fails in both directions, for the reason the bundle-budget gate does: a harness that has quietly stopped measuring still prints a table, and a table gets quoted. Each records the methods that were WRONG — including the one that cost two minutes and a load average of 143: the measurement body imported its statistics helpers from the runner, the runner ends in `await main()`, and `main()` spawns the body.
6362
- - Seven gate-breadth gaps closed (plans/optimizations/07, GATE-1..7). No package source changed: two test files, six scripts, `ci.yml`, `.gitleaks.toml`, `.github/dependabot.yml`, root `package.json` scripts, and a one-word comment fix in `pnpm-workspace.yaml`.
7619
+ Both checks verify themselves first and fail in both directions, for the reason the bundle-budget gate does: a harness that has quietly stopped measuring still prints a table, and a table gets quoted. Each records the methods that were WRONG — including the one that cost two minutes and a load average of 143: the measurement body imported its statistics helpers from the runner, the runner ends in `await main()`, and `main()` spawns the body.
7620
+ - Seven gate-breadth gaps closed. No package source changed: two test files, six build checks and their configuration scripts, and a one-word comment fix in `pnpm-workspace.yaml`.
6363
7621
 
6364
7622
  - **The migration differ is property-tested.** It was the strongest subsystem in the tree and example-based only — every case a schema somebody thought of, while the bugs that got out were combinations nobody did. `plannerProperties.test.ts` generates schema pairs and asserts convergence ("re-plan after apply is EMPTY", the oracle `applyPlan` already refuses to record a fingerprint without), plus determinism, ordering, additive-never- blocks, refusal-carries-a-fix and summary-matches-ops, across all six dialects. `sql-sqlite/__tests__/plannerConvergence.property.test.ts` closes the same loop against a REAL catalog: real CREATE TABLE DDL into an in-process sqlite, real introspection, re-plan empty. fast-check needed no new dependency — it ships inside `effect` as `effect/FastCheck`. 200 cases by default (under a second); `VOLTRO_PROPERTY_RUNS=5000` in the nightly. Both carry a cases-checked floor and run the properties against deliberately broken planners, because `fc.assert` over a corpus that generates nothing passes at full speed. - **`publint` + `@arethetypeswrong/cli` over the staged tarballs** — 77 packages, most with four or five subpath entries, and nothing had ever checked whether the published exports map resolves. It runs in the Package job against `.publish/`, never against `packages/*` (whose exports point at `./src/*.ts` — a manifest no user receives). attw's JSON is read rather than its exit code: the obvious config for an ESM-only repo, `--profile esm-only`, passes a package that ships no types at all. - **Coverage is collected on the pure surface, reported, and gates no number.** `@vitest/coverage-v8` had been a root devDep that nothing invoked. 61 packages measured, 17 declared integration-heavy WITH what covers them instead; the failures are structural (a package that measured nothing; a package that crossed the IO threshold undeclared). - **Secret scanning, in two halves.** `check-secrets.mjs` asks "did WE invent a secret value and ship it" — the thing that has happened twice — scoped to shipped files, with the private-key fixtures pinned by count. gitleaks answers "is there a credential to somebody else's system here"; `.gitleaks.toml` records the measurement behind every exclusion. - **The full matrix runs nightly.** Every heavy job carried `if: github.event_name != 'push'` on the assumption that a PR gates first — but work lands by pushing to main, so the matrix effectively ran only inside a release. That is how two stale aggregate goldens survived three releases. A `schedule:` trigger needs no other change (`schedule` is not `push`); the cost is written down beside it. - **Supply-chain residuals are assertions.** Every uncapped override floor must name the advisory it closes, the two capped ones must keep their caps, every cooldown exemption must be backed by an override, and dependabot's cooldown may not be shorter than pnpm's `minimumReleaseAge`. The check found the drift the audit named: a comment reading "these four" over a list of three. - **The five real-browser checks run in CI.** They were unwired for a mechanical reason — each resolved playwright from `<repo>/../e2e`, a sibling checkout `actions/checkout` cannot create — now a `VOLTRO_PLAYWRIGHT_DIR` seam. The runner judges exit status, `FAIL` lines AND a `PASS` floor, because a fixture that fails to boot quietly exits 0 having asserted nothing.
6365
7623
 
6366
7624
  Every gate was red-verified by planting its defect and watching it fail. Two of them found real problems on their first run, reported rather than fixed here: two shipped secret VALUES in the generated agent-docs template (source: the bilingual docs site), and — from a semgrep measurement that is reported, not wired — 7 `bypass-tls-verification` and 2 `gcm-no-tag-length` findings.
6367
- - Five quality gates that could pass without checking anything are closed (plans/optimizations/07, GATE-8..12). No package source changed; 40 `package.json` `test` scripts lost `--passWithNoTests`.
7625
+ - Five quality gates that could pass without checking anything are closed. No package source changed; 40 `package.json` `test` scripts lost `--passWithNoTests`.
6368
7626
 
6369
- - **Doc samples could not see a phantom third-party teach.** Only `@voltro/*` imports could miss; every other bare specifier resolved to an untyped stub, so a docs page importing `@playwright/test` — a runner the CLI does not have — typechecked clean by design. Bare specifiers no workspace package depends on are now TS2307 unless listed in an auditable `THIRD_PARTY_ALLOWLIST` (unused entries fail the run). - **The docs SITE is now checked in CI.** `check-doc-samples` / `check-docs-code-parity` / `check-docs-structure` locate the site via `VOLTRO_DOCS_DIR` → `<repo>/.voltro-dev` → `../voltro-dev`, and CI checks `SinPP/voltro-dev` out into the workspace. Absent, they skip with a `::warning::` annotation + job-summary line; with the deploy key configured (`VOLTRO_DOCS_REQUIRED`) the absence is a failure. Measured while fixing it: the README-only mode CI had been running typechecked **zero** samples, because generated package READMEs carry `sh` fences only. - **Sample-count floor + selftest-first** for `check-doc-samples` (1200; the corpus is ~1465). - **`check-claimed-wirings` gained a `--selftest`, a claims-scanned count and a floor.** It reported the FILE count (1462), which does not move when the CLAIM regex rots; it verifies 3 anchored claims of 14 matched lines. - **`--passWithNoTests` is gone from all 40 packages that carried it** — every one of them has tests, so the flag was blanket forgiveness for a suite that stops being found. New `scripts/check-test-scripts.mjs` (with `--selftest`) enforces both directions: a package with tests may not carry the flag, and a package with none must be declared in `NO_TESTS` (currently empty).
7627
+ - **Doc samples could not see a phantom third-party teach.** Only `@voltro/*` imports could miss; every other bare specifier resolved to an untyped stub, so a docs page importing `@playwright/test` — a runner the CLI does not have — typechecked clean by design. Bare specifiers no workspace package depends on are now TS2307 unless listed in an auditable `THIRD_PARTY_ALLOWLIST` (unused entries fail the run). - **The docs SITE is now checked in CI.** `check-doc-samples` / `check-docs-code-parity` / `check-docs-structure` locate the site via `VOLTRO_DOCS_DIR` → `<repo>/.voltro-dev` → `../voltro-dev`, and CI checks `SinPP/voltro-dev` out into the workspace. Absent, they skip with a `::warning::` annotation + job-summary line; with the deploy key configured (`VOLTRO_DOCS_REQUIRED`) the absence is a failure. Measured while fixing it: the README-only mode CI had been running typechecked **zero** samples, because generated package READMEs carry `sh` fences only. - **Sample-count floor** for the doc-sample check (1200; the corpus is ~1465). - **The claimed-wiring check gained a self-check, a claims-scanned count and a floor.** It reported the FILE count (1462), which does not move when the CLAIM regex rots; it verifies 3 anchored claims of 14 matched lines. - **`--passWithNoTests` is gone from all 40 packages that carried it** — every one of them has tests, so the flag was blanket forgiveness for a suite that stops being found. A new build check enforces both directions: a package with tests may not carry the flag, and a package with none must be declared in `NO_TESTS` (currently empty).
6370
7628
  - **@voltro/database** — **`residency.ts`'s header described wiring that does not exist, and now a test holds it to the truth.** It read *"the serve pipeline binds the per-region store per request; the cloud control-plane manages the home mapping + provisions per-region infra"* — present tense, both halves untrue. `setResidencyConfig` / `bindResidentStore` / `provisionResidentTenant` have zero call sites outside their own tests, in every repo, and `@voltro/runtime`'s `localityAwareSelector` is in the same state: exported, tested, public, unwired.
6371
7629
 
6372
7630
  That is a defensible position and the header now argues it rather than misstating it. `servableRegions` collapses to one element until a deployment actually holds stores in more than one region, and with one element every path in this module is equivalent to the single-store path the framework already takes — so wiring it today buys a map lookup and a new way to fail closed on a correct request. The primitive earns its keep when a second region exists.
@@ -6379,26 +7637,26 @@ _Changes staged for the next release accumulate here (rolled up from
6379
7637
 
6380
7638
  `plugin-ai-flows/engine.ts` carried "not yet wired (task #35)" beside a feature that had shipped, plus `#31`/`#34` beside two more. A whole-framework audit read those comments, believed them, and filed HITL as unbuilt. A stale comment is a false statement in the place a reader trusts most, and it survives every test in the repository.
6381
7639
 
6382
- `scripts/check-stale-task-comments.mjs` (CI + `pnpm gate`, `--selftest` first) checks the half a machine can decide — a comment citing an EXTERNAL RECORD:
7640
+ A build check checks the half a machine can decide — a comment citing an EXTERNAL RECORD:
6383
7641
 
6384
7642
  - **PLAN-REF** — a `plans/**.md` path cited in a comment must exist. - **TASK-ID** — a `task #NN` / `TODO(#NN)` must have its number appear somewhere under `plans/`.
6385
7643
 
6386
- **It found 19 dead references in this repo on its first run** (23 more across the sibling repos), all fixed here. `plans/architecture/` and `plans/awb/` were deleted wholesale in one 243-file reconcile commit and every comment citing them has been dangling ever since, in files that are otherwise correct.
7644
+ **It found 19 dead references in this repo on its first run** (23 more across the sibling repos), all fixed here. Two whole planning trees were removed and every comment citing them has been dangling ever since, in files that are otherwise correct.
6387
7645
 
6388
7646
  **What is NOT implemented, and why that is a finding rather than an omission.** The obvious phrase list is worse than nothing. Measured over this monorepo's own comment lines: `lands in` 81 hits, ~0 of them temporal ("the row lands in the table"); `TODO` 1288 hits, ~6 real (a CDC fixture declares a table called `todos`); `not (yet) wired` 12 hits, ~4 real — and those four are unfixable by rule, being either permanent DECISIONS with reasons or descriptions of a RUNTIME state. One of the twelve is the comment recording the fix for this very defect class. A rule that is two-thirds false positives gets switched off, and a switched-off rule is indistinguishable from a green one.
6389
7647
 
6390
- **The floor is on FILES WALKED, not on matches**, and that choice is load-bearing: this check's corpus is supposed to go to ZERO, so a floor on matches would go red on success and the pressure would be to lower it. The extractor half is guarded by `--selftest` against a fixture with known-dead citations instead — including a false-positive control (the same path in a string literal must be ignored). The selftest earned its place immediately: it caught a cheap pre-filter in the first draft that silently disabled the whole TASK-ID rule while the check still printed green.
7648
+ **The floor is on FILES WALKED, not on matches**, and that choice is load-bearing: this check's corpus is supposed to go to ZERO, so a floor on matches would go red on success and the pressure would be to lower it. The extractor half is guarded against a fixture with known-dead citations instead — including a false-positive control (the same path in a string literal must be ignored). That self-check earned its place immediately: it caught a cheap pre-filter in the first draft that silently disabled the whole TASK-ID rule while the check still printed green.
6391
7649
 
6392
7650
  `plans/` lives in the meta repo, so a `voltro`-only checkout genuinely cannot run this — it SKIPS LOUDLY (`::warning::`) rather than passing, and a wrong `VOLTRO_PLANS_DIR` is a hard failure.
6393
- - The request path, cold boot and resident memory now have numbers, produced by named commands and gated nightly (plans/optimizations/03, PERF-1/2/3). No package source changed; two e2e fixtures gained a `notes.add` mutation.
7651
+ - The request path, cold boot and resident memory now have numbers, produced by named commands and gated nightly. No package source changed; two e2e fixtures gained a `notes.add` mutation.
6394
7652
 
6395
7653
  The gap this closes is one shape: **the framework measured the part nobody doubts and did not measure the part everyone attacks.** Five `*.perf.test.ts` pin the reactive engine's costs to counted operations, while HTTP → `@effect/rpc` dispatch → JSON decode → txn wrap → handler → encode — the path a TechEmpower-style comparison actually benchmarks, over `RpcSerialization.layerJson` with no binary option — had no req/s and no p99 anywhere in the repo.
6396
7654
 
6397
- - **`scripts/rpc-bench.mjs`** boots a real `voltro serve` (from the precompiled serve bundle — the production path) on `e2e-fixtures/memory-api` and `postgres-api`, drives query / mutation / subscription-open over real sockets with a closed-loop `node:http` generator, and reports p50/p95/p99 + req/s. - **`scripts/boot-budget.mjs`** pins cold `voltro serve`. `VOLTRO_BOOT_TIMING=1` has printed a per-phase breakdown for a while and nothing asserted anything about it. - **Resident memory + schema decode** ride the same suite: `scripts/lib/memory-probe.mjs` (forced GC, then `heapUsed`) and `packages/protocol/scripts/schema-decode-bench.mjs` (the real fixture descriptors, against `JSON.parse`/`stringify` in the same process).
7655
+ - A benchmark boots a real `voltro serve` (from the precompiled serve bundle — the production path) on `e2e-fixtures/memory-api` and `postgres-api`, drives query / mutation / subscription-open over real sockets with a closed-loop `node:http` generator, and reports p50/p95/p99 + req/s. - A benchmark pins cold `voltro serve`. `VOLTRO_BOOT_TIMING=1` has printed a per-phase breakdown for a while and nothing asserted anything about it. - **Resident memory + schema decode** ride the same suite: a memory probe (forced GC, then `heapUsed`) and a schema-decode benchmark (the real fixture descriptors, against `JSON.parse`/`stringify` in the same process).
6398
7656
 
6399
7657
  **What is gated is not the milliseconds**, and that is the design rather than a concession. Wall-clock on a shared runner cannot be held to a bound that is tight enough to be worth having, so what goes red is: any failed request (an `@effect/rpc` failure answers HTTP 200, so a status-code check would benchmark the error path), too few samples, non-monotone percentiles, the framework's latency as a MULTIPLE of a bare `node:http` floor measured in the same run on the same box, the per-subscription heap after a forced GC, and the number of MODULES a cold boot loads. The last two are absolutes because they are properties of the code, not of the clock. The wall-clock boot ceiling is pinned per machine label and SKIPS loudly anywhere else.
6400
7658
 
6401
- Both benches ship a `--selftest` that runs on every PR in Static checks while the benchmarks themselves run nightly — a benchmark whose percentile maths or success predicate has rotted still prints a beautiful table, and a table is what gets quoted.
7659
+ Both benches verify themselves on every change while the benchmarks run nightly — a benchmark whose percentile maths or success predicate has rotted still prints a beautiful table, and a table is what gets quoted.
6402
7660
 
6403
7661
  Two measurements that were wrong before they were right, recorded in-source so they are not re-derived: reading per-subscription memory with `ps -o rss=` reported the process getting SMALLER after 200 subscribers each took a delivery (a GC between two samples), and timing a one-field schema decode without batching reported it beating `JSON.parse` (inside the clock's resolution).
6404
7662
 
@@ -6684,7 +7942,7 @@ _Changes staged for the next release accumulate here (rolled up from
6684
7942
 
6685
7943
  - **@voltro/cli** — **`voltro update --dry-run` could never preview a jump's codemods — the manifest it reads has never been published.** Checked against the registry rather than inferred: `@voltro/cli` at 0.25.0, 0.29.0, 0.30.2 and 0.31.0 all ship no `voltro` field at all, while the repo's own `package.json` carries 65 entries.
6686
7944
 
6687
- `scripts/prepare-publish.mjs`'s `cleanManifest` builds a fresh publish manifest field by field rather than deleting from a copy — an allowlist by construction — so a new top-level key is dropped in silence and nothing downstream mentions it.
7945
+ The publish step's `cleanManifest` builds a fresh publish manifest field by field rather than deleting from a copy — an allowlist by construction — so a new top-level key is dropped in silence and nothing downstream mentions it.
6688
7946
 
6689
7947
  **What this did NOT cost, stated because the alarming reading is the wrong one:** no user has ever missed a codemod that should have run. The manifest feeds the PREVIEW only; the actual run happens after the install, out of the target CLI's own registry, which is present by then. And the missing case was already handled loudly — the preview printed *"could not preview … This is NOT the same as 'no codemods'"* rather than an confident "none". An honest "I could not look" for four releases, where the feature was built to look.
6690
7948
 
@@ -6784,7 +8042,7 @@ _Changes staged for the next release accumulate here (rolled up from
6784
8042
 
6785
8043
  The names live in `@voltro/i18n` now (the package that resolves the locale from a request already hardcoded the literal), doctor's remedy names it, and `cookieNameParity.test.ts` asserts every declaration in the repo agrees AND that the remedy names a package which actually exports what it names.
6786
8044
 
6787
- That second assertion is one `scripts/check-message-apis.mjs` structurally cannot make: it verifies a member EXISTS in the published surface, and `LOCALE_COOKIE` always did — just not anywhere the reader could import it from. A reachability claim needs its own check.
8045
+ That second assertion is one A build check structurally cannot make: it verifies a member EXISTS in the published surface, and `LOCALE_COOKIE` always did — just not anywhere the reader could import it from. A reachability claim needs its own check.
6788
8046
  - **@voltro/cli** — **`voltro doctor` reports two things that were decidable and unreported: dead endpoints and never-fired events.**
6789
8047
 
6790
8048
  **A procedure requiring a scope no declared role grants.** A consumer shipped `webhooks.test` guarded by `webhooks:test` while their roles granted `webhooks:read` and `webhooks:write` and nothing else — uncallable by every user in every team, with nothing failing at boot to say so — and asked us to build the rule. The rule already existed: `rbac/unknown-scope`, in `voltro check`. It was in the wrong PLACE for them. `check` is the CI gate; `doctor` is what someone runs when something feels wrong, and a rule that only fires where you already suspect the problem fires for the people who did not need it. Doctor now surfaces the same finding from the same function — reused, not re-derived, because two answers to "which scope is ungranted" would diverge on the day it mattered.
@@ -6953,7 +8211,7 @@ _Changes staged for the next release accumulate here (rolled up from
6953
8211
  **A hint that named 41% of the files was not a hint.** One hand-roll finding listed 2 641 of 6 374 files, and the reporter skipped the whole section because of it — including the lines pointing at 5 and 13 files, which were worth acting on that day. Findings now print FEWEST files first, and a finding above both a share (20%) and a floor (50 files) prints its ADVICE without the enumeration, marked as a codebase-wide pattern. Both bounds matter: the share is what makes it a pattern, the floor keeps a small app — where "3 of 8 files" is a large share and a perfectly readable list — out of it. The paths stay in `--json`.
6954
8212
 
6955
8213
  **A translation catalog that is never loaded said nothing.** `src/locales/{code}.ts` is imported by the web codegen only when the app declares `locales:`. Without that line the files are inert — no import, no provider, no error — and from the inside a catalog that is never loaded looks exactly like one that works. The reporter carried `de.ts` + `en.ts` in TWO apps for months, never wired, and measured that neither boot nor doctor mentioned it. Doctor now names the orphaned codes and offers both ways out: the exact `locales: [...]` line to paste, or delete the files (which is what they did). It deliberately does NOT report the reverse — a declared locale with no file already fails loudly at codegen, and a second, weaker voice for a problem that has a loud one is noise.
6956
- - **@voltro/cli** — **`encryptSteps` was derived twice, once per boot path.** Six hand-mirrored lines in `dev.ts` and in `serveApi.ts` — read the flow control off the definition, compare `=== true`, build the cipher, spread the result or nothing. They agreed today and nothing kept them agreeing, which is the shape that produced the `_voltro_outbox` error loop and every dev/serve scar in `packages/cli/CLAUDE.md`. The asymmetry a drift would produce here is the bad direction: step payloads encrypted under `voltro dev` and plaintext under `voltro serve`, with the declaration reading as protection in both.
8214
+ - **@voltro/cli** — **`encryptSteps` was derived twice, once per boot path.** Six hand-mirrored lines in `dev.ts` and in `serveApi.ts` — read the flow control off the definition, compare `=== true`, build the cipher, spread the result or nothing. They agreed today and nothing kept them agreeing, which is the shape that produced the `_voltro_outbox` error loop and every dev/serve scar behind it. The asymmetry a drift would produce here is the bad direction: step payloads encrypted under `voltro dev` and plaintext under `voltro serve`, with the declaration reading as protection in both.
6957
8215
 
6958
8216
  `stepPayloadCipherOptions(definition)` is the one derivation now, and it is slightly better than either copy it replaced: the workflow NAME in the boot-refusal message comes from the resolved control rather than from a second argument, so the flag and the name it is reported under are the same object. `flowControlParity.test.ts` pins that both paths call it AND that neither re-derives `encryptSteps === true` inline.
6959
8217
  - **@voltro/runtime, @voltro/cli, @voltro/workflow** — **`debounce` never ran. Neither did a `batch` that flushed on its timeout — and `batch` could not be started at all.** Three defects, one boundary, all found by a consumer who adopted flow control against a live API and measured `attempts: 13, collapsed: 14, runs: 0` on a debounced workflow that never produced a run.
@@ -6982,7 +8240,7 @@ _Changes staged for the next release accumulate here (rolled up from
6982
8240
 
6983
8241
  The irony is worth keeping, because it is what made the defect invisible: the `chromeMounted` gate was added so the first client render matches the server DOM. It does — and that is exactly why hydration SUCCEEDS, React keeps the server markup, nothing throws, and the only casualty is the ids. The gate did not cause the fork; the slot forks whether or not it renders anything.
6984
8242
 
6985
- **What must not change without re-measuring:** the number of forks above the page on each side. Nesting DEPTH is free — measured, any number of single-child providers above the router keeps ids aligned — but adding a sibling to the app on one path only (an overlay, a portal host, a second root element) reintroduces this. `ssrTreeIdParity.test.tsx` holds it in jsdom; `scripts/browser-ssr-hydration-ids.mjs` holds it in a real chromium against a real `voltro dev`.
8243
+ **What must not change without re-measuring:** the number of forks above the page on each side. Nesting DEPTH is free — measured, any number of single-child providers above the router keeps ids aligned — but adding a sibling to the app on one path only (an overlay, a portal host, a second root element) reintroduces this. `ssrTreeIdParity.test.tsx` holds it in jsdom; A build check holds it in a real chromium against a real `voltro dev`.
6986
8244
  - **@voltro/web** — **Every `useId` in an SSR app mismatched on hydration, on every page, since the route announcer was added.** Reported by a consumer against 0.30.0 and 0.29.0 with the two `dist` bundles read side by side — not a regression, and not something any of our tests could see.
6987
8245
 
6988
8246
  The router provider took ONE child on the server (`createElement(RouterContext.Provider, { value }, tree)`) and TWO on the client (JSX with `{content}` and the announcer, which compiles to `jsxs` with a 2-element array). React derives `useId` from the path of ARRAY SLOTS down to a fiber: a single child does not fork, a 2-element array forks and places the subtree at index 0. So the entire tree below the router sat at a different tree id on the two sides, and every id generated beneath it differed.
@@ -6995,7 +8253,7 @@ _Changes staged for the next release accumulate here (rolled up from
6995
8253
 
6996
8254
  **The methodological trap is carried in the test, because it cost the reporter an hour and would cost the next person one:** reading the id back from the DOM shows the SERVER's value on both sides — hydration deliberately does not patch ids, which is the very thing the warning says. A harness built that way reports the bug as absent. The id has to be captured from the render that computed it, and a second test proves the harness would still catch a fork.
6997
8255
 
6998
- **Verified in a real browser, not only in jsdom.** `scripts/browser-ssr-hydration-ids.mjs` boots `voltro dev` on the SSR fixture from SOURCE, loads a `renderMode: 'ssr'` page, and asserts the client computes the same `useId` the server wrote AND that react-dom logs no mismatch. Removing either half of the fix makes it print the reporter's exact string — `A tree hydrated but some attributes of the server rendered HTML didn't match the client properties` — which is the only place that message can be observed at all: the DOM is identical, hydration succeeds, nothing throws, and there is no server-side signal.
8256
+ **Verified in a real browser, not only in jsdom.** A build check boots `voltro dev` on the SSR fixture from SOURCE, loads a `renderMode: 'ssr'` page, and asserts the client computes the same `useId` the server wrote AND that react-dom logs no mismatch. Removing either half of the fix makes it print the reporter's exact string — `A tree hydrated but some attributes of the server rendered HTML didn't match the client properties` — which is the only place that message can be observed at all: the DOM is identical, hydration succeeds, nothing throws, and there is no server-side signal.
6999
8257
  - **@voltro/web, @voltro/cli** — **The server render discarded the request's query string.** `RenderPageOptions` had no `search`, and the SSR router context hardcoded `search: ''` with a comment noting that the client reads `window.location.search` on hydration. That is true, and it is precisely why the hardcoding was wrong: the client reading the real value is what turns a discarded query string into a divergence in an exported context value. The value was already computed in both per-request boot paths — the loaders receive it — and simply never reached the renderer.
7000
8258
 
7001
8259
  `renderPageToHtml` / `renderPageToStream` take `search` now, and `voltro start` and `voltro dev` both pass it. `build.ts` deliberately does not: a static prerender has no request and one artefact serves every visitor, so `''` is the truthful value there rather than a missing wire — and `ssrI18nParity.test.ts` encodes that difference, asserting the two per-request renderers pass it while leaving the prerender out on purpose.
@@ -7004,7 +8262,7 @@ _Changes staged for the next release accumulate here (rolled up from
7004
8262
 
7005
8263
  ### Internal (no consumer-facing effect)
7006
8264
 
7007
- - **@voltro/database** — `pendingAttribution`'s boundedness test no longer scores its property on the wall clock. It asserts that 12 000 registrations leave at most 10 000 entries and says nothing about how long 12 000 iterations take — but under the default 5 s timeout it had quietly become an assertion about the machine as well, and went red inside a 24-task parallel run while the whole file finishes in 480 ms on its own. That is the "a test that measures the machine" producer recorded in `packages/cli/CLAUDE.md`, and the fix is to decouple the property from the clock (an explicit generous timeout) rather than to shrink the loop — 12 000 is chosen to overrun the 10 000 cap, so a smaller burst would weaken the only thing under test. A non-vacuity assertion came with it: a cap of zero satisfies `<= 10 000` while proving nothing.
8265
+ - **@voltro/database** — `pendingAttribution`'s boundedness test no longer scores its property on the wall clock. It asserts that 12 000 registrations leave at most 10 000 entries and says nothing about how long 12 000 iterations take — but under the default 5 s timeout it had quietly become an assertion about the machine as well, and went red inside a 24-task parallel run while the whole file finishes in 480 ms on its own. That is the "a test that measures the machine" producer, and the fix is to decouple the property from the clock (an explicit generous timeout) rather than to shrink the loop — 12 000 is chosen to overrun the 10 000 cap, so a smaller burst would weaken the only thing under test. A non-vacuity assertion came with it: a cap of zero satisfies `<= 10 000` while proving nothing.
7008
8266
 
7009
8267
  ---
7010
8268
 
@@ -7142,8 +8400,8 @@ _Changes staged for the next release accumulate here (rolled up from
7142
8400
  A cookie name the FRAMEWORK reads and the APP writes is a public API — and the only kind where both sides can disagree with nothing failing. Nothing throws, no page breaks: the resolver finds nothing and falls back to `Accept-Language`, so the symptom is a language preference that quietly stops working for the subset of users whose browser language differs from their choice. The least likely thing anyone tests.
7143
8401
 
7144
8402
  Raised by a consumer ahead of the `voltro:lang` → `voltro:locale` rename, in their words: *"your codemod will presumably rewrite the literal. Ours were two bare strings in two components, which is exactly the shape a codemod misses one of."* The codemod does rewrite every literal it can see. This rule covers what a codemod structurally cannot — and, more usefully, the NEXT rename, for which no codemod has been written yet.
7145
- - **@voltro/cli** — New `mobile` template kind + scaffolder support for Expo (React Native) apps. `voltro create-project <name> --mobile` (defaults to the `mobile-app` template) and `voltro add-app <name> --template=mobile-app` scaffold an Expo app that consumes your api with the same typed hooks. A `mobile` app deliberately gets NO port and is NOT part of `voltro dev`'s orchestration — Expo owns Metro (`expo start` / `expo run:ios`); the app connects to the sibling api over the network. `list-templates` shows the new kind; the template validation harness (`test-templates.mjs`) skips `kind: mobile` from its default sweep LOUDLY (the Expo/RN toolchain is heavy and simulator-bound — the template's pure logic is covered by its own tests). codemod: none — additive, no user-authored code changes. (The forward-looking design + the M0 gap — no RN-safe client boot yet — are in `plans/open/mobile/`.)
7146
- - **@voltro/client, @voltro/web** — `@voltro/client` now exports `buildApiRuntime` — the transport-level construction of one api's client stack (an rpc-client-over-WebSocket, its ManagedRuntime, a SubscriptionCache, an error bus, per-connection auth-header seeding). The WebSocket constructor is an INJECTED dependency, so React Native can build the SAME `ApiHandle` pieces the web client uses without pulling in `@voltro/web` — the keystone for mobile support (plans/open/mobile M0). `@voltro/web`'s `buildRuntimeAndClient` now DELEGATES to it (one implementation, no duplicate path; the web client-builder test suite stays green), and its `ResolvableHeaders` type is re-exported from `@voltro/client` (the owning lower layer) rather than defined locally. Also exported: `BuildApiRuntimeOptions`, `BuiltApiRuntime`, `ResolvableHeaders`. Additive — no consumer migration.
8403
+ - **@voltro/cli** — New `mobile` template kind + scaffolder support for Expo (React Native) apps. `voltro create-project <name> --mobile` (defaults to the `mobile-app` template) and `voltro add-app <name> --template=mobile-app` scaffold an Expo app that consumes your api with the same typed hooks. A `mobile` app deliberately gets NO port and is NOT part of `voltro dev`'s orchestration — Expo owns Metro (`expo start` / `expo run:ios`); the app connects to the sibling api over the network. `list-templates` shows the new kind; the template validation harness (`test-templates.mjs`) skips `kind: mobile` from its default sweep LOUDLY (the Expo/RN toolchain is heavy and simulator-bound — the template's pure logic is covered by its own tests). codemod: none — additive, no user-authored code changes.
8404
+ - **@voltro/client, @voltro/web** — `@voltro/client` now exports `buildApiRuntime` — the transport-level construction of one api's client stack (an rpc-client-over-WebSocket, its ManagedRuntime, a SubscriptionCache, an error bus, per-connection auth-header seeding). The WebSocket constructor is an INJECTED dependency, so React Native can build the SAME `ApiHandle` pieces the web client uses without pulling in `@voltro/web` — the keystone for mobile support. `@voltro/web`'s `buildRuntimeAndClient` now DELEGATES to it (one implementation, no duplicate path; the web client-builder test suite stays green), and its `ResolvableHeaders` type is re-exported from `@voltro/client` (the owning lower layer) rather than defined locally. Also exported: `BuildApiRuntimeOptions`, `BuiltApiRuntime`, `ResolvableHeaders`. Additive — no consumer migration.
7147
8405
  - **@voltro/cli** — `voltro dev` now tells you WHICH of two causes produced *"[React Intl] Could not find required `intl` object"*.
7148
8406
 
7149
8407
  That error is byte-identical whether there is no `<I18nProvider>` above the consumer or a provider built from a SECOND physical `react-intl` copy — React contexts are identified by object identity, so a duplicate library has a duplicate context and the provider is present and invisible. The two causes have opposite fixes, and no red/green experiment in the app can separate them: the app's own provider comes from the app's own import, i.e. the instance its `useT()` already uses.
@@ -7153,7 +8411,7 @@ _Changes staged for the next release accumulate here (rolled up from
7153
8411
 
7154
8412
  The obstacle was not the one we thought. The codemods for a jump ship INSIDE the target `@voltro/cli`, which is not installed when the preview runs — so the target VERSION is known before installing and the target REGISTRY is not. A preview that confused the two would list the codemods of the version you are leaving.
7155
8413
 
7156
- The registry is therefore republished as package METADATA (`voltro.codemods` in the published `package.json`, generated by `scripts/gen-codemod-manifest.mjs`, drift-checked in CI) and read with the SAME registry query that already resolves the latest version — project package manager first, `npm view` last. No tarball fetch, no temp install, no second package-manager surface. yarn and bun fall straight through to npm on purpose: `yarn npm info …` parses as `yarn run npm` on yarn classic and executes a same-named script, and that risk is not worth taking for a preview.
8414
+ The registry is therefore republished as package METADATA (`voltro.codemods` in the published `package.json`, generated at publish time and drift-checked in CI) and read with the SAME registry query that already resolves the latest version — project package manager first, `npm view` last. No tarball fetch, no temp install, no second package-manager surface. yarn and bun fall straight through to npm on purpose: `yarn npm info …` parses as `yarn run npm` on yarn classic and executes a same-named script, and that risk is not worth taking for a preview.
7157
8415
 
7158
8416
  Two honesty properties, both load-bearing:
7159
8417
 
@@ -7368,7 +8626,7 @@ _Changes staged for the next release accumulate here (rolled up from
7368
8626
 
7369
8627
  Our codegen and agent suites create scratch fixtures INSIDE `packages/cli/src` (`mkdtemp(join(here, '.agent-fixtures-…'))`) because the codegen imports them through vite's module graph, which is rooted at the package. A guard that walks `src/` concurrently races them, and the failure is always the same shape: the whole FILE dies at COLLECTION time with `ENOENT` on a path nobody recognises, and it is green when re-run alone — the signature people write off as flake.
7370
8628
 
7371
- **Third occurrence, and that is why this is a function rather than another paragraph.** `ledgerReadPortability` hit it with `readdirSync` + `statSync` (two syscalls, one gap) and `packages/cli/CLAUDE.md` gained "any new guard that walks a source tree must do both". `broadcastNamespaceCoverage` then hit it while that rule was written down and current: it had `withFileTypes` — half the rule — and descended into a `.scan-fixtures-…` directory another suite had just removed.
8629
+ **Third occurrence, and that is why this is a function rather than another paragraph.** `ledgerReadPortability` hit it with `readdirSync` + `statSync` (two syscalls, one gap) and the rule is now that any new guard walking a source tree must do both. `broadcastNamespaceCoverage` then hit it while that rule was written down and current: it had `withFileTypes` — half the rule — and descended into a `.scan-fixtures-…` directory another suite had just removed.
7372
8630
 
7373
8631
  `walkSourceFiles` has three properties, each load-bearing: one syscall per entry, dot-directories skipped (a scratch dir is never source, so this is right on its own terms), and a directory that vanishes mid-walk is skipped rather than fatal.
7374
8632
 
@@ -7485,7 +8743,7 @@ _Changes staged for the next release accumulate here (rolled up from
7485
8743
 
7486
8744
  `isUnresolvedApi(useFrameworkApi(name))` still composes for a caller who wants the readiness bit itself.
7487
8745
 
7488
- **Measured in a real browser against a real api process**, not only unit-covered — `node scripts/browser-action-boot-window.mjs` (chromium, `e2e-fixtures/web-action-boot` → `memory-api`), with the pre-fix shape restored as a negative control:
8746
+ **Measured in a real browser against a real api process**, not only unit-covered — in chromium (`e2e-fixtures/web-action-boot` → `memory-api`), with the pre-fix shape restored as a negative control:
7489
8747
 
7490
8748
  | | on a natural cold load | with a 3s `authHeaders` resolver | |---|---|---| | before | `ERR: rpc / cache calls are not invokable on a not-yet-resolved api` after **17 ms** | same error after **3 ms** | | after | `OK: {"ok":true}` after **34 ms** | `OK: {"ok":true}` after **3039 ms** |
7491
8749
 
@@ -7519,7 +8777,7 @@ _Changes staged for the next release accumulate here (rolled up from
7519
8777
 
7520
8778
  **Why this is worse than a syntax error, which is the part worth keeping:** a query that fails to run gets fixed. A query that runs and returns good news when the answer is wrong is read as an all-clear — in the security-relevant half of a security-relevant codemod.
7521
8779
 
7522
- `codemodSqlPortability.test.ts` now scans every codemod note for postgres-only spellings (`::text`, `ILIKE`, `table_schema = 'public'`). It distinguishes SQL a user would copy from prose ABOUT sql by the backtick, because the first version fired on the very sentence warning against the construct — and it carries a selftest, since a scan that silently stopped matching reads exactly like a clean tree.
8780
+ `codemodSqlPortability.test.ts` now scans every codemod note for postgres-only spellings (`::text`, `ILIKE`, `table_schema = 'public'`). It distinguishes SQL a user would copy from prose ABOUT sql by the backtick, because the first version fired on the very sentence warning against the construct — and it verifies itself, since a scan that silently stopped matching reads exactly like a clean tree.
7523
8781
  - **@voltro/cli** — **Outgoing webhooks never delivered on the cluster engine — i.e. in every deployment.**
7524
8782
 
7525
8783
  `voltro.deliverWebhook` was provided per-emit: `execute(input).pipe(Effect.provide(deliverWebhookWorkflow.toLayer(…)))`, built fresh inside the emit callback. The in-memory engine tolerates that, because there the layer IS the registry. The **cluster** engine does not: a workflow must be registered as an entity type while the runtime is constructed, and an emit happens long afterwards. So every delivery died with
@@ -7659,7 +8917,7 @@ _Changes staged for the next release accumulate here (rolled up from
7659
8917
 
7660
8918
  The lookahead is the part worth recording: matching the generic precisely does **not** work, because `<const F extends … Array<…>>` nests `>`, so a `<[^>]*>` character class stops inside it. The first version of the branch therefore matched nothing and looked like a fix. A call signature simply *starts* with `<` or `(` once trimmed; a data member starts with an identifier or `readonly` — which is what keeps `index?: { readonly where: string }` out, and with it the data-property bug the original rules exist to reject.
7661
8919
 
7662
- Both directions are now selftest cases, since a rule that quietly stops matching prints exactly like a clean tree.
8920
+ Both directions are now self-check cases, since a rule that quietly stops matching prints exactly like a clean tree.
7663
8921
 
7664
8922
  ---
7665
8923
 
@@ -7971,7 +9229,7 @@ _Changes staged for the next release accumulate here (rolled up from
7971
9229
 
7972
9230
  Also measured and NOT published as a headline: socket.io's `emit` to 100 subscribers costs 13.7 µs against our 2.7 µs, but at that point ours has already run every listener while socket.io has only enqueued to 100 sockets — zero had arrived when the measurement ended. Two different quantities; comparing them would have been the same mistake in the other direction.
7973
9231
 
7974
- `scripts/bench/socketio-cross-replica.mjs` carries the method and the numbers so they can be re-taken. Deliberately a script, not a test: keeping a competitor in the dependency tree to hold a number green is the wrong trade.
9232
+ A build check carries the method and the numbers so they can be re-taken. Deliberately a script, not a test: keeping a competitor in the dependency tree to hold a number green is the wrong trade.
7975
9233
 
7976
9234
  codemod: none
7977
9235
  - **@voltro/plugin-webhooks** — **A subscription is a SET of events, and the service now has a word for it.**
@@ -8784,7 +10042,7 @@ _Changes staged for the next release accumulate here (rolled up from
8784
10042
 
8785
10043
  `netHarnessPackages.test.ts` walked with `readdirSync(dir)` then `statSync(p)` — two syscalls with a gap. The codegen suites create their fixture modules inside `src/` (`mkdtemp(join(here, '.codegen-…'))`) and remove them in `afterEach`, and they have to live there: the codegen imports them through vite's module graph, which is rooted at the package. A directory removed inside that gap makes `statSync` throw `ENOENT`, which fails the file at COLLECTION time — no assertion, a path nobody recognises, and green the moment you re-run it alone.
8786
10044
 
8787
- This is the FOURTH file to grow that shape, and the rule was already written up in `packages/cli/CLAUDE.md` for `ledgerReadPortability.test.ts`. It surfaced now because two new codegen suites landed in the same directory, which is the point: the latent version was indistinguishable from machine load.
10045
+ This is the FOURTH file to grow that shape, and the rule was already written down for `ledgerReadPortability.test.ts`. It surfaced now because two new codegen suites landed in the same directory, which is the point: the latent version was indistinguishable from machine load.
8788
10046
 
8789
10047
  Fixed on the reader, per that rule: `readdirSync(dir, { withFileTypes: true })` gives the name and the kind from ONE syscall, so there is no gap; and dot-directories are skipped, which is right regardless — a scratch directory is never source.
8790
10048
  - **@voltro/plugin-presence** — **A client that vanished stayed in the presence roster forever, and `presencePlugin({ timeoutMs })` did nothing.**
@@ -8806,7 +10064,7 @@ _Changes staged for the next release accumulate here (rolled up from
8806
10064
 
8807
10065
  The comparison now carries a one-second tolerance. The two directions are not symmetric — too small deletes a fresh artefact, too large lets an orphan survive until the next prune — so the margin sits on the side of keeping. Real orphans are minutes or builds old.
8808
10066
 
8809
- Found by the release gate on Linux, where the suite's own concurrency case failed while asserting a precondition that held: the file it checked was fine, a different one was pruned. It had never failed on macOS, whose timestamp granularity differs. The suite now pins the tolerance directly — a file stamped just before the cutoff must survive, and one past the tolerance must still go, so the margin cannot quietly widen into a no-op.
10067
+ Found on Linux, where the suite's own concurrency case failed while asserting a precondition that held: the file it checked was fine, a different one was pruned. It had never failed on macOS, whose timestamp granularity differs. The suite now pins the tolerance directly — a file stamped just before the cutoff must survive, and one past the tolerance must still go, so the margin cannot quietly widen into a no-op.
8810
10068
  - **@voltro/cli** — **A `source:` that names no table is now reported at boot.** It was silent, and the silence is the defect: `source` is matched BY NAME against change events, so one naming a table that does not exist matches nothing — the query returns its first result and never updates again. Not a broken subscription, a permanently silent one, which from the outside is indistinguishable from "nothing has changed".
8811
10069
 
8812
10070
  ```text
@@ -8901,14 +10159,14 @@ _Changes staged for the next release accumulate here (rolled up from
8901
10159
 
8902
10160
  Both handler types now accept sync, Promise **and** Effect forms, and one shared `settleHandlerBody` decides what a body IS, so the two call sites can no longer disagree about it. They keep their different DISPOSAL, deliberately: a schedule AWAITS its body (a firing that failed must not record as a success), a subscriber does not (a slow body must not back-pressure the change stream).
8903
10161
 
8904
- Reported four releases ago. It sat because it was listed as "open" at the bottom of a feedback round and never entered the backlog — the register that now holds that tail is `plans/open/framework/consumer-reported-tail.md`.
10162
+ Reported four releases ago. It sat because it was listed as "open" at the bottom of a feedback round and never entered the backlog.
8905
10163
  - **@voltro/plugin-auth** — **Brute-force lockout could be entirely inert, on by default, with nothing in the log to say so.** The postgres store fails OPEN on a store error — correct, a DB hiccup must not lock every user out of an app — but it failed open in silence: `recordLoginFailure` swallowed its write error, `isLockedOut` then read "not locked", and the security control that the release notes describe as **on by default** counted nothing at all.
8906
10164
 
8907
10165
  The reachable case is not a hiccup. An app that enumerates its tables by hand instead of spreading `authTables` never migrates `loginAttempts`, so every write fails with `relation "loginAttempts" does not exist` — permanently, invisibly.
8908
10166
 
8909
10167
  Behaviour is unchanged: still open, still no throw into the login flow. What is new is that each failure logs `[auth] lockout … failed — brute-force protection is not counting`, which also separates the two cases by hand: a transient error logs once, a missing table logs on every failed sign-in.
8910
10168
 
8911
- Found by the release gate, and the finding is uncomfortable in a useful way — the contract suite that runs against a LIVE postgres had been extended for lockout, and its hand-written fixture DDL was never given the new table. The fail-open then converted "relation does not exist" into a plain assertion failure, which is the only reason it was visible at all. The fixture now asserts that it covers every table the plugin declares, in both directions, and that assertion runs without postgres so the drift cannot be introduced on a machine where the pg half skips.
10169
+ The finding is uncomfortable in a useful way — the contract suite that runs against a LIVE postgres had been extended for lockout, and its hand-written fixture DDL was never given the new table. The fail-open then converted "relation does not exist" into a plain assertion failure, which is the only reason it was visible at all. The fixture now asserts that it covers every table the plugin declares, in both directions, and that assertion runs without postgres so the drift cannot be introduced on a machine where the pg half skips.
8912
10170
  - **@voltro/cli, @voltro/database** — **`voltro db apply` now installs the change triggers the boot diagnostic tells you to install.** It did not, and said it did.
8913
10171
 
8914
10172
  0.23.0 added a check that compares declared reactivity against the triggers actually in the database, and it works — a consumer's first boot on 0.23.0 reported 500 of their 525 tables as having no change trigger. The remedy it named was `voltro db apply`, and `db apply` answered:
@@ -8962,7 +10220,7 @@ _Changes staged for the next release accumulate here (rolled up from
8962
10220
 
8963
10221
  Not breaking: the error channel already carried `HttpClientError`. What changed is that the failure now arrives on it.
8964
10222
 
8965
- Reported three releases ago. It sat because it was listed as open at the bottom of a feedback round and never entered the backlog — see `plans/open/framework/consumer-reported-tail.md`.
10223
+ Reported three releases ago. It sat because it was listed as open at the bottom of a feedback round and never entered the backlog.
8966
10224
 
8967
10225
  ### Internal (no consumer-facing effect)
8968
10226
 
@@ -9246,7 +10504,7 @@ _Changes staged for the next release accumulate here (rolled up from
9246
10504
  Re-verified after: `devHealthServer` 5 passed, `mcp` 13, `protocol` 301, `runtime` 1038, and an instrumented run still reports `bound 127.0.0.1:<port>`.
9247
10505
  - **The net harness is shared across every package that binds a listener, and a derived guard keeps it that way.**
9248
10506
 
9249
- The bind fix itself ships with the `host` option in this same release. What did not ship with it was reach: the mitigation lived in `packages/cli/vitest.config.ts`, written where the symptom appeared, so the other eleven packages whose tests bind a real listener never had it. `@voltro/mcp` then failed a release gate with the identical signature — bound, zero connections, its client stuck in `fetch` — and that read as a NEW problem rather than as the containment being too narrow. It is the second time this repo fixed a real-listener flake inside one package's config.
10507
+ The bind fix itself ships with the `host` option in this same release. What did not ship with it was reach: the mitigation lived in `packages/cli/vitest.config.ts`, written where the symptom appeared, so the other eleven packages whose tests bind a real listener never had it. `@voltro/mcp` then failed with the identical signature — bound, zero connections, its client stuck in `fetch` — and that read as a NEW problem rather than as the containment being too narrow. It is the second time this repo fixed a real-listener flake inside one package's config.
9250
10508
 
9251
10509
  `test/harness/setup.ts` is now loaded by all twelve. It is deliberately NOT in `@voltro/testing`: that package is published, and a `net.Server` monkey-patch does not belong in a shipped API surface.
9252
10510
 
@@ -9275,7 +10533,7 @@ _Changes staged for the next release accumulate here (rolled up from
9275
10533
 
9276
10534
  In a full uncached monorepo run the package's import phase alone was 88s and the file went red while every assertion in it would have passed. Given an explicit 60s ceiling: the timeout is now a backstop rather than the assertion, which is the same correction already applied to `coordinatedSchedule.test.ts`.
9277
10535
 
9278
- **Three more files had the same shape**, and they are the ones this repo's maintainer notes already list as "rotating victims" of full-monorepo runs: `cli/src/adminExportServe.test.ts`, `cli/src/connectionServe.test.ts` and `mcp/src/http.test.ts`. All three BOOT a real listener and make real HTTP round-trips — the last one boots two servers — against the same 5s default. Each went red in an uncached full run under load ~19 and green alone seconds later, with every assertion in them passing either way.
10536
+ **Three more files had the same shape**, and they are the known "rotating victims" of whole-repository runs: `cli/src/adminExportServe.test.ts`, `cli/src/connectionServe.test.ts` and `mcp/src/http.test.ts`. All three BOOT a real listener and make real HTTP round-trips — the last one boots two servers — against the same 5s default. Each went red in an uncached full run under load ~19 and green alone seconds later, with every assertion in them passing either way.
9279
10537
 
9280
10538
  That is worth naming precisely, because "it passes in isolation" has been the signature of both machine load AND a defect the suite carried itself, and this repo has been wrong in both directions. Here it is neither: the suites are correct and the timeout was measuring the wrong thing. A test whose claim is "these two endpoints compose" should not also be claiming how many milliseconds that takes on a saturated machine.
9281
10539
 
@@ -9523,7 +10781,7 @@ _Changes staged for the next release accumulate here (rolled up from
9523
10781
 
9524
10782
  | after | `cluster_messages` rows | |---|---| | run 1 | 15 | | run 2, with the purge | 15 | | run 2, purge disabled | 30 |
9525
10783
 
9526
- The failure therefore named the victim and never the cause: the losing file passes perfectly in isolation against a fresh database, so it read as machine load, and the standard response — re-run it — made the next run worse. This is the second producer behind the "rotating victims" this repo had a maintainer note about; the first was a half-provisioned mssql.
10784
+ The failure therefore named the victim and never the cause: the losing file passes perfectly in isolation against a fresh database, so it read as machine load, and the standard response — re-run it — made the next run worse. This is the second producer behind those "rotating victims"; the first was a half-provisioned mssql.
9527
10785
 
9528
10786
  State is purged in `beforeAll`, not `afterAll`, on purpose: a run that crashes cannot clean up after itself, and its leftovers are the likeliest to be there. Same reasoning as the boot path's `reapTestFixtures`. `cluster_migrations` is deliberately left alone — it records the cluster library's installed schema version, and clearing it would make the library re-run migrations it already applied.
9529
10787
 
@@ -9728,7 +10986,7 @@ _Changes staged for the next release accumulate here (rolled up from
9728
10986
 
9729
10987
  **A FOURTH copy of both defects was found in `voltro dev`'s migrations inspect endpoint, and it was the worst one.** It carried the same postgres-only `::text` casts, wrapped in `orElseSucceed(() => [])` — so on every non-postgres dialect the syntax error became an EMPTY history rather than a failure: the devtools migrations panel showed nothing, and with no history row the drift verdict came out `false`. A silent, permanent "no drift" on every mysql/mariadb/mssql/sqlite app. It also compared the declared hash against a live one, exactly like the CLI did.
9730
10988
 
9731
- Both are fixed there too, and `ledgerReadPortability.test.ts` now fails if any query touching `_voltro_migration_plans` grows a `::type` cast again. Three copies were fixed in one change and the fourth was missed in the same change, which is the argument for the test rather than another paragraph in a maintainer note.
10989
+ Both are fixed there too, and `ledgerReadPortability.test.ts` now fails if any query touching `_voltro_migration_plans` grows a `::type` cast again. Three copies were fixed in one change and the fourth was missed in the same change, which is the argument for the test rather than another paragraph of prose.
9732
10990
  - **@voltro/cli, @voltro/voltro** — `voltro dev` served a client-only shell — with a **200** — whenever a `renderMode: 'ssr'` page failed to render on the server. It now answers 500 with the cause, exactly as `voltro start` does.
9733
10991
 
9734
10992
  **Reported as "voltro dev does not SSR". It does**, and has since before 0.20.0 — the middleware, its intent stated in a comment ("mirroring what `voltro start` does in production"), is an ancestor of every 0.20.x tag. What the reporter saw was the masking: their pages suspended during the server render (a lazily-loaded i18n catalog above any Suspense boundary), all 225 degraded to Vite's SPA shell, and an empty `<div id="root">` is indistinguishable from a framework that never server-renders. Their conclusion was the only one the evidence supported.
@@ -9747,11 +11005,11 @@ _Changes staged for the next release accumulate here (rolled up from
9747
11005
 
9748
11006
  ### Internal (no consumer-facing effect)
9749
11007
 
9750
- - **@voltro/cli** — Maintainer notes only — no shipped behaviour changes.
11008
+ - **@voltro/cli** — Internal documentation only — no shipped behaviour changes.
9751
11009
 
9752
11010
  `mssqlClusterPatch.ts`'s header said the `@effect/cluster` patch covers "two mssql-only bugs" (it is four: the `deliver_at` INT-overflow, the MERGE…OUTPUT with correlated sub-SELECTs, `FOR UPDATE`, and `USING (SELECT * FROM (VALUES …))`) and implied that a version bump needs nothing but a re-key, because 0.59.0 → 0.60.0 happened to apply unchanged. On 0.60.2 the same patch fails on 3 of its 6 files — upstream refactored `SqlMessageStorage` and moved the context the hunks match on. A bump can require REGENERATING the patch.
9753
11011
 
9754
- The regeneration recipe now lives in `packages/sql-mssql/CLAUDE.md`, together with the two measurements that produced confidently wrong answers while working this out: reading an already-patched `node_modules/.pnpm/*patch_hash=*` copy and concluding upstream had fixed it, and un-patching one of the several installed copies and concluding the patch was not load-bearing. Both look like evidence.
11012
+ The regeneration recipe is written down, together with the two measurements that produced confidently wrong answers while working this out: reading an already-patched `node_modules/.pnpm/*patch_hash=*` copy and concluding upstream had fixed it, and un-patching one of the several installed copies and concluding the patch was not load-bearing. Both look like evidence.
9755
11013
 
9756
11014
  Also recorded there: verify by BREAKING it. `git apply --check` proves the patch lands, not that it still fixes anything. Un-patched, the mssql cluster suite fails with `Incorrect syntax near ')'`; patched, 5/5 against the live fixture.
9757
11015
 
@@ -10134,7 +11392,7 @@ _Changes staged for the next release accumulate here (rolled up from
10134
11392
 
10135
11393
  It is reachable by ordinary means, and on mariadb it is the *common* path: `INSERT IGNORE` swallows ANY unique violation, so a row with a fresh `id` and a duplicate `slug` is skipped, and the lookup by the named `conflictColumns` then finds nothing. A team with `tenants (id PK, slug UNIQUE)` hits it on the first duplicate slug.
10136
11394
 
10137
- The message now names the columns that were checked, the table, and the actual cause — a different unique constraint fired, and `insertIgnore` models one conflict target. This is step 1 of `plans/framework-insertignore-any-unique.md` and is deliberately independent of the feature: whether or not `conflictColumns: 'any'` ever ships, this error should have been readable.
11395
+ The message now names the columns that were checked, the table, and the actual cause — a different unique constraint fired, and `insertIgnore` models one conflict target. This is the first step of that work and is deliberately independent of the feature: whether or not `conflictColumns: 'any'` ever ships, this error should have been readable.
10138
11396
  - **@voltro/cli** — **`ui/unlinked` and `ui/orphaned` resolve through barrel re-exports.** A `*.component.ui.tsx` reached only via `export { X } from './x'` was reported as unrendered, however many pages actually rendered it.
10139
11397
 
10140
11398
  On the app that reported it, `PageContent` is imported by 42 pages — every one of them through `@/components/shared` — and doctor said `imported only by: index.ts, its own test`. It was the last false positive standing after the alias fix took that app from 16 findings to 2.
@@ -10150,9 +11408,9 @@ _Changes staged for the next release accumulate here (rolled up from
10150
11408
  A team planning to port 19 raw-SQL sites onto that seam inferred the opposite: they wrote down "a store read adds `deletedAt IS NULL`" as the trap with teeth on their list — a soft-deleted user logging back in would go from "found and revived" to "not found → insert → unique violation on email" — and deferred the whole port partly over it. The store does no such thing; it is the raw store plus the storage codec. Naming exactly one of four absences reads as an exhaustive list.
10151
11409
 
10152
11410
  The docstring now carries the same table the `AuthStrategyInput.store` docs do, with the soft-delete row called out for anyone porting: a read here returns tombstones the way their SQL did, so a lookup that must see one needs no opt-out. (`.withDeleted()` is the opt-out on `ctx.store`, which *does* apply the filter.) Doc-only; the behaviour is unchanged and was already correct.
10153
- - **`ci.yml` gains a `workflow_dispatch` trigger.** The full matrix — 11 database services plus the SQL Server AG init containers — is not reachable from a push to `main`: `paths-ignore` plus the job-level `if: github.event_name != 'push'` mean a main push runs static checks only.
11411
+ - **The verification workflow gains a manual trigger.** The full matrix — 11 database services plus the SQL Server AG init containers — is not reachable from a push to `main`: `paths-ignore` plus the job-level `if: github.event_name != 'push'` mean a main push runs static checks only.
10154
11412
 
10155
- So the only ways to exercise it were a pull request and the release gate, which meant a change to the workflow itself could sit unrun until it fired for the first time INSIDE a release — where a failure costs a ~40-minute round-trip and blocks the publish. That is precisely the position this repo was in.
11413
+ So the only ways to exercise it were an ordinary change and a release, which meant a change to the workflow itself could sit unrun until it fired for the first time INSIDE a release — where a failure costs a ~40-minute round-trip and blocks the publish. That is precisely the position this repo was in.
10156
11414
 
10157
11415
  ---
10158
11416
 
@@ -10216,7 +11474,7 @@ _Changes staged for the next release accumulate here (rolled up from
10216
11474
 
10217
11475
  **It paid for itself immediately, three times.**
10218
11476
 
10219
- *Twelve replication tests had never run.* postgres streaming replica, mysql GTID replica, mssql Always-On AG — left out of CI on the grounds that starting them OOMs a standard runner, so they reported `passed` in every run without once executing. Replication and failover: precisely the behaviour nobody can verify by reading it. Measured rather than argued: baseline 2091 MiB, `postgres-replica` **82**, the mysql pair **1335**, the AG pair **2035**. The OOM claim is true of the WHOLE compose file (keydb, dragonfly, valkey, redis cluster) and not of these. `ci.yml` now starts them — plus the two one-shot containers that actually FORM the availability group, without which both nodes are healthy and the suite still cannot connect.
11477
+ *Twelve replication tests had never run.* postgres streaming replica, mysql GTID replica, mssql Always-On AG — left out of CI on the grounds that starting them OOMs a standard runner, so they reported `passed` in every run without once executing. Replication and failover: precisely the behaviour nobody can verify by reading it. Measured rather than argued: baseline 2091 MiB, `postgres-replica` **82**, the mysql pair **1335**, the AG pair **2035**. The OOM claim is true of the WHOLE compose file (keydb, dragonfly, valkey, redis cluster) and not of these. The verification run now starts them — plus the two one-shot containers that actually FORM the availability group, without which both nodes are healthy and the suite still cannot connect.
10220
11478
 
10221
11479
  *Eighteen cache tests had tested one engine out of four.* The RESP suite iterates redis / valkey / keydb / dragonfly; only redis was started. The other three cost **27 MiB between them**.
10222
11480
 
@@ -10324,19 +11582,19 @@ _Changes staged for the next release accumulate here (rolled up from
10324
11582
  The graph now resolves aliases from the NEAREST `tsconfig.json` walking up to the scan root — `voltro doctor` runs at the project root while `@/*` is declared per app, so reading only the root config found no `paths` at all in the layout we scaffold.
10325
11583
 
10326
11584
  Two more edge forms were missing for the same reason: `export … from` and dynamic `import()`. The re-export one is not a completeness flourish — a barrel is the file most likely to reach across a feature boundary, so `export { x } from './orders.internal'` is precisely the case `internal/foreign-import` exists to catch, and it was the one shape the rule could not see.
10327
- - **@voltro/cli** — **The release gate now fails on a skipped test it was not told about.** "All green" has to mean everything RAN, or the total is a number about how little was attempted.
11585
+ - **@voltro/cli** — **A verification run now fails on a skipped test it was not told about.** "All green" has to mean everything RAN, or the total is a number about how little was attempted.
10328
11586
 
10329
11587
  Locally a skip stays fine — nobody should need six databases to run `pnpm test`, and a suite that fails without them stops being run at all. In CI it is not fine: a skipped test is an unverified claim wearing the same colour as a verified one.
10330
11588
 
10331
- `scripts/check-no-skipped-tests.mjs` reads the per-package vitest summaries out of the test step and fails on anything skipped that is not declared in its `ALLOWED` map with a reason and an **exact** count. Both directions are enforced, and the second is the one that matters:
11589
+ A build check reads the per-package vitest summaries out of the test step and fails on anything skipped that is not declared in its `ALLOWED` map with a reason and an **exact** count. Both directions are enforced, and the second is the one that matters:
10332
11590
 
10333
11591
  - more skips than declared → something stopped running; - **fewer** skips than declared → the entry is stale, and a stale allowlist silently absorbs the next regression. That is the failure mode an allowlist has instead of the one it removes, and it is only survivable if the list is forced to stay exact.
10334
11592
 
10335
- It runs `--selftest` first, like the changelog and message-API gates, for the same reason: a check that has quietly stopped detecting anything still prints green. Both of its rules were verified by breaking them and watching the selftest go red — the ANSI stripping and the stale-entry direction.
11593
+ It verifies itself first, for the same reason: a check that has quietly stopped detecting anything still prints green. Both of its rules were verified by breaking them and watching the self-check go red — the ANSI stripping and the stale-entry direction.
10336
11594
 
10337
- **Two kinds of skip exist and only one is a defect.** "The dependency was not there" is a coverage gap — start the service. "This does not apply to this configuration" is correct — declare it. The allowlist holds exactly the second kind: four tests in `sql-sqlite` and `sql-turso` whose dialects keep no workflow-runner state in SQL, so cross-process resume is not a thing they can do. The 12 replication tests that would have been the first entries were the FIRST kind, and cost 3.4 GB on a 16 GB runner — `ci.yml` starts their services instead of declaring them away.
11595
+ **Two kinds of skip exist and only one is a defect.** "The dependency was not there" is a coverage gap — start the service. "This does not apply to this configuration" is correct — declare it. The allowlist holds exactly the second kind: four tests in `sql-sqlite` and `sql-turso` whose dialects keep no workflow-runner state in SQL, so cross-process resume is not a thing they can do. The 12 replication tests that would have been the first entries were the FIRST kind, and cost 3.4 GB on a 16 GB runner — the verification run starts their services instead of declaring them away.
10338
11596
 
10339
- The check found both of its first three catches on its own first run: 18 cache tests covering one RESP engine of four, and the two dialect suites above. It also caught itself — it passed in 0.2 s on a run whose test step had aborted after 0.5 s, because an empty log has nothing to complain about. A log with no vitest summaries is now a failure, with its own selftest case.
11597
+ The check found both of its first three catches on its own first run: 18 cache tests covering one RESP engine of four, and the two dialect suites above. It also caught itself — it passed in 0.2 s on a run whose test step had aborted after 0.5 s, because an empty log has nothing to complain about. A log with no vitest summaries is now a failure, with its own self-check case.
10340
11598
  - **@voltro/cli** — **Four `voltro dev` inspect endpoints answered 200 to any caller: `traces`, `webhooks`, `analytics`, `aggregates`.**
10341
11599
 
10342
11600
  `handleInspectRequest` gates everything that reaches it. The branches in front of it are EARLY RETURNS — they answer and never reach it — so each had to remember to gate itself, and four did not. `traces` is the sharp one: an adopter measured 38 KB of live spans from an unauthenticated `curl`, a request-by-request record of what the process just did. Per the tracing docs those spans also carry `rpc.tag`, `subject.type` and `tenant.id`.
@@ -10404,25 +11662,25 @@ _Changes staged for the next release accumulate here (rolled up from
10404
11662
 
10405
11663
  That is the signal that forces a `BREAKING` changelog entry and its codemod, and on a subpath it was simply absent. `ApiKeyStrategyOptions.resolveKey` gained a parameter in this same release and no gate said anything — additive, so harmless, but the repo's own "more precise is still breaking" rule (the one that cost an adopter 102 hand-fixes) would have shipped silently through the same hole.
10406
11664
 
10407
- `gen-api-extractor.mjs` — already the single source of truth for this wiring — now emits one config per entry point (73 → 160) and one golden each, derived from the exports map so the checked set is BY CONSTRUCTION the published set. It also deletes configs and goldens for entry points a package no longer exports: a golden nothing runs reads exactly like covered surface. The per-package `api:check` chains its configs with `&&` rather than a loop, so the first failure stops and reports — a loop is what let the local gate score a green `api-surface` over two stale goldens.
11665
+ `gen-api-extractor.mjs` — already the single source of truth for this wiring — now emits one config per entry point (73 → 160) and one golden each, derived from the exports map so the checked set is BY CONSTRUCTION the published set. It also deletes configs and goldens for entry points a package no longer exports: a golden nothing runs reads exactly like covered surface. The per-package `api:check` chains its configs with `&&` rather than a loop, so the first failure stops and reports — a loop is what let a verification run score a green `api-surface` over two stale goldens.
10408
11666
 
10409
11667
  Verified by breaking a subpath signature on purpose: `api:check` now exits 1 and names `protocol-apikey.api.md`.
10410
11668
 
10411
11669
  Regenerating also fixed real drift in the shared path map — `@voltro/cli/serveEntry`, `/startEntry` and `/devActivity` ship but were missing from every package's `tsconfig.api-extractor.json`.
10412
- - `pnpm gate` ran each ci.yml `run:` block with `pipefail` but not `-e`, while GitHub Actions' default shell is `bash --noprofile --norc -eo pipefail`. A multi-command block whose MIDDLE command failed carried on, and the step's status became the status of the last command — so the `api-surface` step, a `for` loop over every package's `api:check`, printed two API-drift warnings and still reported `✓`. The gate said green on a tree whose CI job goes red, which is the one thing it exists to prevent.
11670
+ - The verification runner executed each block with `pipefail` but not `-e`, while the shell it mirrors uses `-eo pipefail`. A multi-command block whose MIDDLE command failed carried on, and the step's status became the status of the last command — so the `api-surface` step, a `for` loop over every package's `api:check`, printed two API-drift warnings and still reported `✓`. The gate said green on a tree whose CI job goes red, which is the one thing it exists to prevent.
10413
11671
 
10414
- Fixed, and it now ships a `--selftest` that runs first (silent unless it finds something), matching the changelog and message-API gates. Its first case is the exact shape that hid this — a failing middle command with a passing last one — and it goes red without the `-e`.
11672
+ Fixed, and it now verifies itself first (silent unless it finds something). Its first case is the exact shape that hid this — a failing middle command with a passing last one — and it goes red without the `-e`.
10415
11673
  - **The skipped-tests gate could not read the output CI actually produces.**
10416
11674
 
10417
- It parsed turbo's STREAMING shape, where every line carries a `@voltro/kv:test:` prefix. On a GitHub runner turbo detects Actions and switches to GROUPED output instead: the package name moves into a `::group::@voltro/kv:test` header and the lines inside carry no prefix at all. The summary regex requires the prefix, so it matched nothing.
11675
+ It parsed the task runner's STREAMING shape, where every line carries a `@voltro/kv:test:` prefix. On a hosted runner the task runner switches to GROUPED output instead: the package name moves into a `::group::@voltro/kv:test` header and the lines inside carry no prefix at all. The summary regex requires the prefix, so it matched nothing.
10418
11676
 
10419
11677
  The consequence was not a wrong answer — it was no answer. The check found zero summaries and refused to judge a run in which all 110 tasks had passed, which is exactly what it is built to do when it cannot confirm anything. It failed the release it was gating.
10420
11678
 
10421
- It had never once run in CI. A push to main runs static checks only, so between the commit that introduced it and the release that used it, nothing executed it against real runner output. Its nine selftest cases all passed throughout: they exercise the RULES, and the bug was the INPUT FORMAT.
11679
+ Nothing had executed it against real runner output between the change that introduced it and the release that used it. Its nine self-check cases all passed throughout: they exercise the RULES, and the bug was the INPUT FORMAT.
10422
11680
 
10423
- Both shapes parse now, group attribution closes on any non-`:test` group boundary so a stray summary is never credited to the wrong package, and a log downloaded with `gh run view --log` (which renders ESC as the two characters `^[`) parses too — that is how a red run gets debugged offline, and it is how this fix was verified: against the failing release run's own log, where the old parser reports "no summaries" and the new one reports the same `4 skipped in 2 packages` the local gate reported.
11681
+ Both shapes parse now, group attribution closes on any non-`:test` group boundary so a stray summary is never credited to the wrong package, and a log downloaded from the hosted runner (which renders ESC as the two characters `^[`) parses too — that is how a red run gets debugged offline, and it is how this fix was verified: against the failing release run's own log, where the old parser reports "no summaries" and the new one reports the same `4 skipped in 2 packages` the local run reported.
10424
11682
 
10425
- Eight selftest cases cover the grouped shape, for the reason the selftest exists at all — a check that has quietly stopped detecting anything still prints green.
11683
+ Eight self-check cases cover the grouped shape, for the reason the self-check exists at all — a check that has quietly stopped detecting anything still prints green.
10426
11684
 
10427
11685
  ---
10428
11686
 
@@ -10505,7 +11763,7 @@ _Changes staged for the next release accumulate here (rolled up from
10505
11763
  No product code changed.
10506
11764
  - **@voltro/cli** — The admin-import rejection tests stop uploading an archive they never needed.
10507
11765
 
10508
- A release gate failed with `expected 400 to be 401` on `401 without a Bearer token` and did not reproduce in five later runs. It was not an auth defect and not load-flake in the usual sense: measured against a live server on that path, an honest no-auth request answers **401**, a body-less one answers **401**, and one whose `Content-Length` lies — or whose chunked framing ends early — answers **400**, because the HTTP layer rejects broken framing BEFORE routing. `gate()` is the handler's first statement, so when it never runs there is no 401 to give.
11766
+ A verification run failed with `expected 400 to be 401` on `401 without a Bearer token` and did not reproduce in five later runs. It was not an auth defect and not load-flake in the usual sense: measured against a live server on that path, an honest no-auth request answers **401**, a body-less one answers **401**, and one whose `Content-Length` lies — or whose chunked framing ends early — answers **400**, because the HTTP layer rejects broken framing BEFORE routing. `gate()` is the handler's first statement, so when it never runs there is no 401 to give.
10509
11767
 
10510
11768
  Those two tests were POSTing the full packed bundle to assert an authorization property that is decided without reading the body at all. The archive proved nothing and made a multi-KB upload a precondition of an auth assertion. They now send no body, which is both flake-free and the sharper claim; the assertions carry the response body so a future mismatch names the responder instead of printing a bare status.
10511
11769
 
@@ -10597,7 +11855,7 @@ _Changes staged for the next release accumulate here (rolled up from
10597
11855
 
10598
11856
  **It immediately found a second instance nobody had reported** — the drop-COLUMN refusal also said "chain `.dropped()`", one level down from the reported one, and the real spelling is `<column>: dropped()` as the field's value. Close enough to guess from, which is why it survived.
10599
11857
 
10600
- Ships with a `--selftest` that runs first in CI, for the reason the changelog gate has one: a check that has quietly stopped detecting anything still prints green, and green is read as evidence.
11858
+ Ships.
10601
11859
  - **@voltro/database** — **Two migration refusals sent people the wrong way** — the worst place for a bad hint, because whoever reads one is already blocked and looking for the sanctioned way out.
10602
11860
 
10603
11861
  **The drop-table refusal recommended an API that does not exist.** Its first option was *"add it to your declared set + chain `.dropped()` on it"*. There is no table-level `dropped()` — only the column marker. The recommendation was also the conceptually RIGHT one, which is what made it expensive: the two options that do work are both worse, so a reader picks the one they cannot follow.
@@ -10843,7 +12101,7 @@ _Changes staged for the next release accumulate here (rolled up from
10843
12101
 
10844
12102
  `storeHistory()` returns a STABLE reference until the log changes, because `useSyncExternalStore` requires a cached snapshot and a fresh array per call sends any subscriber into an infinite render loop. Our own panel hit that within a minute of being written, so the safety lives in the API rather than in a note every consumer has to read.
10845
12103
 
10846
- **Verified in a real browser**, because three of the store's claims cannot be settled anywhere else. `scripts/browser-client-store.mjs` drives chromium against the fixture app and checks: the seeded value is in the FIRST PAINT with **JavaScript disabled** (the only way to prove the server rendered it rather than the client filling it a tick later); React reported no hydration mismatch (a mismatch is a console error in a browser and nothing anywhere else — which is exactly how the seed once shipped rendering 0 on the server and 7 on the client); and a component reading a DIFFERENT field of the same store does not re-render when the first one moves, counted in the DOM because a render count is not observable from outside a page any other way. Writes, undo, redo and the redo-tail truncation are all exercised through real clicks.
12104
+ **Verified in a real browser**, because three of the store's claims cannot be settled anywhere else. A build check drives chromium against the fixture app and checks: the seeded value is in the FIRST PAINT with **JavaScript disabled** (the only way to prove the server rendered it rather than the client filling it a tick later); React reported no hydration mismatch (a mismatch is a console error in a browser and nothing anywhere else — which is exactly how the seed once shipped rendering 0 on the server and 7 on the client); and a component reading a DIFFERENT field of the same store does not re-render when the first one moves, counted in the DOM because a render count is not observable from outside a page any other way. Writes, undo, redo and the redo-tail truncation are all exercised through real clicks.
10847
12105
 
10848
12106
  It caught two fixture defects on its first run, one of them the trap the docs name: the layout seeded the GLOBAL instance while the page read a KEY.
10849
12107
  - **@voltro/testing, @voltro/cli** — **`ctx.webhooks` exists in the test harness, and `voltro test` stops collecting `e2e/` specs.** Both were found by running the starter's own suite, which had been red on both counts.
@@ -11061,7 +12319,7 @@ _Changes staged for the next release accumulate here (rolled up from
11061
12319
 
11062
12320
  **What DOES change is every path that never minted** — `voltro start` (the production web runtime), `voltro serve`, and any harness that spawned a server with no token. Those were the open ones. If you depend on the inspect surface in production, set `VOLTRO_INSPECT_TOKEN` explicitly; it is deliberately never minted outside dev, because in production a missing secret must stay a boot-time decision rather than an invented value.
11063
12321
 
11064
- Known consequence, recorded rather than left to be discovered: the `voltro-starter` smoke scripts drive `/_voltro/inspect/invoke` with no Authorization header. They were ALREADY failing against a minted dev token before this change (the harness that runs them, `scripts/test-all.sh`, is itself broken on a stale `voltro-dashboard` path and runs in no CI, so nobody saw it). They need the token threaded through; tracked in `plans/app-graph-and-scope-registry.md`.
12322
+ Known consequence, recorded rather than left to be discovered: the `voltro-starter` smoke scripts drive `/_voltro/inspect/invoke` with no Authorization header. They were ALREADY failing against a minted dev token before this change (the harness that runs them, the harness script, is itself broken on a stale `voltro-dashboard` path and runs in no CI, so nobody saw it). They need the token threaded through; tracked separately.
11065
12323
  - **@voltro/runtime, @voltro/cli** — **The `HttpClient` handlers `yield*` now enforces an SSRF guard, on by default.**
11066
12324
 
11067
12325
  Refused: loopback, RFC-1918, CGNAT and link-local addresses (including the `169.254.169.254` cloud-metadata endpoint), the hostnames `localhost` / `*.internal` / `*.local`, and any non-http(s) scheme — **on the initial request AND on every redirect hop**.
@@ -11401,7 +12659,7 @@ _Changes staged for the next release accumulate here (rolled up from
11401
12659
 
11402
12660
  Why a NEW axis rather than reusing `.encrypted()`: encryption at rest says nothing about who may receive the plaintext — decrypting a private note *for its owner* is a valid case, so treating "encrypted" as "never to a client" would be wrong. Exposure is stated explicitly. Default stays exposed to both server and client; `.serverOnly()` opts a column out of the client.
11403
12661
 
11404
- Additive: a `serverOnly()` builder method + a `serverOnly?` flag on `ColumnDefinition` + the `serverOnlyColumns` helper. Enforcement beyond the `crud.*` path — a runtime strip at the rpc wire boundary and a `serverOnly: true` whole-query primitive — is a planned follow-on (see `plans/framework-serverOnly-exposure.md`).
12662
+ Additive: a `serverOnly()` builder method + a `serverOnly?` flag on `ColumnDefinition` + the `serverOnlyColumns` helper. Enforcement beyond the `crud.*` path — a runtime strip at the rpc wire boundary and a `serverOnly: true` whole-query primitive — is a planned follow-on.
11405
12663
 
11406
12664
  ### Fixed
11407
12665
 
@@ -11463,7 +12721,7 @@ _Changes staged for the next release accumulate here (rolled up from
11463
12721
 
11464
12722
  ### Added
11465
12723
 
11466
- - **@voltro/runtime** — `crud.*` secure-default CRUD handler helpers + `redactColumns` (A1 core). Each returns an executor you export as a `*.query.server.ts` / `*.mutation.server.ts` default — the descriptor (schemas + `guards`) stays hand-written and browser-safe:
12724
+ - **@voltro/runtime** — `crud.*` secure-default CRUD handler helpers + `redactColumns`. Each returns an executor you export as a `*.query.server.ts` / `*.mutation.server.ts` default — the descriptor (schemas + `guards`) stays hand-written and browser-safe:
11467
12725
 
11468
12726
  ```ts
11469
12727
  // accounts.list.query.server.ts
@@ -11477,8 +12735,8 @@ _Changes staged for the next release accumulate here (rolled up from
11477
12735
 
11478
12736
  What they deliberately DON'T do is authorize: a guard runs before the executor, so gating stays on the DESCRIPTOR (`guards: [...]`) — an executor can't gate itself. Keep write descriptors guarded.
11479
12737
 
11480
- Scope note: this is the browser-safe, codegen-free core. Deriving the descriptor SCHEMAS from a table (to drop the hand-written `Schema.Struct`) is structurally a codegen concern — a table VALUE can't be imported into a browser-loaded descriptor (it drags the store into the bundle; `rowSchema` is server-only for exactly this reason) — so full schema-derivation + a `.crud()` boot audit for the scope/gating discipline are a separate, planned pass. See `plans/framework-a1-defineCrud.md`.
11481
- - **@voltro/runtime** — `ctx.store.links(junctionTable, anchor)` — a diff-based writer for a many-to-many JUNCTION table (A2). It reconciles the links from one anchor row against a target-id list by writing only the DIFFERENCE:
12738
+ Scope note: this is the browser-safe, codegen-free core. Deriving the descriptor SCHEMAS from a table (to drop the hand-written `Schema.Struct`) is structurally a codegen concern — a table VALUE can't be imported into a browser-loaded descriptor (it drags the store into the bundle; `rowSchema` is server-only for exactly this reason) — so full schema-derivation + a `.crud()` boot audit for the scope/gating discipline are a separate, planned pass.
12739
+ - **@voltro/runtime** — `ctx.store.links(junctionTable, anchor)` — a diff-based writer for a many-to-many JUNCTION table. It reconciles the links from one anchor row against a target-id list by writing only the DIFFERENCE:
11482
12740
 
11483
12741
  ```ts
11484
12742
  await ctx.store.links('post_tags', { postId: post.id }).set(tagIds) // add missing, remove surplus
@@ -11633,7 +12891,7 @@ _Changes staged for the next release accumulate here (rolled up from
11633
12891
 
11634
12892
  ### Added
11635
12893
 
11636
- - **@voltro/cli** — **Repo gate** — a new `Claimed-wiring check` (`scripts/check-claimed-wirings.mjs`, wired into CI and therefore into `pnpm gate`): a doc comment that says something wires a symbol up must be telling the truth.
12894
+ - **@voltro/cli** — **Repository check** — a new claimed-wiring check (wired into CI): a doc comment that says something wires a symbol up must be telling the truth.
11637
12895
 
11638
12896
  `setSystemStoreHandle`'s comment read *"Process-wide handle, registered by the runtime boot (dev.ts / start.ts)"*. Nothing registered it, in either path, through an entire release — so `runAsSystem` threw for every consumer, and the comment was the only evidence anyone had that it should work. The shape is not rare: a comment gets written when the wiring is planned, the wiring gets deferred, and the comment never finds out. It then reads as documentation of behaviour rather than of intention, and the more confidently it is phrased the less likely anyone is to check it.
11639
12897
 
@@ -11850,19 +13108,19 @@ _Changes staged for the next release accumulate here (rolled up from
11850
13108
  This is filed as a fix rather than a chore because we know what it cost. A downstream consumer debugging an SSR loader that returned `null` for `employees.me` read `subscription.employees.me.snapshot rows=1` in their trace, correctly concluded from it that the server had produced a value, and reported a framework bug in the loader's response drain. The drain was fine; the trace was lying. They spent the investigation on the wrong layer, and so did we until the constant turned up. Observability that lies is worse than none, because it is trusted.
11851
13109
 
11852
13110
  Row-set subscriptions were always correct (`rows.length`) — only the two computed paths, snapshot and recompute-delta, carried the constant.
11853
- - **@voltro/cli** — **@voltro/cli** — `voltro dev` no longer throws away client state when you edit a page-local value export. Route Fast Refresh forced a full page reload whenever ANY non-component export of a page/layout changed by value — so a `export const COLUMNS = [...]` edited in the same save as the JSX that renders it reloaded the page, losing form input, scroll position and every `useState`, even though the JSX edit alone would have hot-updated. That was broader than the reason for the reload: an edit can only defeat HMR when the export is one the SERVER already read to produce the HTML in front of you. The forced-reload set is now exactly those exports — `loader`, `renderMode`, `dynamic`, `meta`, `getStaticPaths`, `revalidate`, `staleWhileRevalidate`, `cacheInvalidatesOn`, `interactive`, `tenantAware` — derived from `computeRouteMetadata`'s own parameter type (`src/routeServerExports.ts`), so it cannot drift from the readers in `build.ts` (prerender), `webDev.ts` (dev SSR) and `start.ts`. Everything else a route module exports now hot-updates: the module re-evaluates, the component renders the new value, and any other importer is Vite's normal graph propagation to resolve. A `loader` edit still full-reloads — correct, and unchanged — and the console line now names the changed export plus the server step that consumed it instead of always claiming "a loader runs on the server too". Unchanged as well: every non-component export is still reported to `@vitejs/plugin-react`'s ignored-exports hook (narrowing THAT list would make react-refresh refuse the boundary outright), and the transform stays client-only and append-only. One residual caveat, documented: ADDING or REMOVING a non-component export still reloads once, because Fast Refresh sees a name that was not yet on the ignore list. Verified in a real browser (`scripts/browser-deferred-stream.mjs --only=hmr`): a constant edit, and a constant + JSX edit in one save, both apply as hot updates with a live `useState` counter surviving and the new constant value on screen, while a `loader` edit still resets it.
13111
+ - **@voltro/cli** — **@voltro/cli** — `voltro dev` no longer throws away client state when you edit a page-local value export. Route Fast Refresh forced a full page reload whenever ANY non-component export of a page/layout changed by value — so a `export const COLUMNS = [...]` edited in the same save as the JSX that renders it reloaded the page, losing form input, scroll position and every `useState`, even though the JSX edit alone would have hot-updated. That was broader than the reason for the reload: an edit can only defeat HMR when the export is one the SERVER already read to produce the HTML in front of you. The forced-reload set is now exactly those exports — `loader`, `renderMode`, `dynamic`, `meta`, `getStaticPaths`, `revalidate`, `staleWhileRevalidate`, `cacheInvalidatesOn`, `interactive`, `tenantAware` — derived from `computeRouteMetadata`'s own parameter type (`src/routeServerExports.ts`), so it cannot drift from the readers in `build.ts` (prerender), `webDev.ts` (dev SSR) and `start.ts`. Everything else a route module exports now hot-updates: the module re-evaluates, the component renders the new value, and any other importer is Vite's normal graph propagation to resolve. A `loader` edit still full-reloads — correct, and unchanged — and the console line now names the changed export plus the server step that consumed it instead of always claiming "a loader runs on the server too". Unchanged as well: every non-component export is still reported to `@vitejs/plugin-react`'s ignored-exports hook (narrowing THAT list would make react-refresh refuse the boundary outright), and the transform stays client-only and append-only. One residual caveat, documented: ADDING or REMOVING a non-component export still reloads once, because Fast Refresh sees a name that was not yet on the ignore list. Verified in a real browser: a constant edit, and a constant + JSX edit in one save, both apply as hot updates with a live `useState` counter surviving and the new constant value on screen, while a `loader` edit still resets it.
11854
13112
  - **@voltro/cli** — **@voltro/cli** — the dev boot-health handle now reports the port it **actually** bound, not the one it was asked for, so `startDevHealthServer({ port: 0 })` is usable: bind ephemerally, read the real port back off `handle.port`.
11855
13113
 
11856
13114
  Echoing the request was fine for `voltro dev` itself (it names a concrete port, so the two always matched) and wrong for anyone binding `0` — the handle would report `0`, leaving the caller unable to find its own listener. `port: null` still means the bind failed, unchanged.
11857
13115
 
11858
- Found via a flaky test rather than a report, and the flake is the more useful half of the story. `devHealthServer.test.ts` picked a free port by binding `0`, reading the port, closing, and re-binding it — a window in which another process can take it. Under a full `pnpm gate` (dozens of parallel test processes plus the docker stack) that window gets hit: the server correctly degraded to `port: null`, the test then fetched a port now owned by something that never answers HTTP, and the run hung to the 60-second timeout. Green locally, red in CI, for nothing. The tests now bind `0` and use the reported port, so there is no window at all.
13116
+ Found via a flaky test rather than a report, and the flake is the more useful half of the story. `devHealthServer.test.ts` picked a free port by binding `0`, reading the port, closing, and re-binding it — a window in which another process can take it. Under a full verification run (dozens of parallel test processes plus the database stack) that window gets hit: the server correctly degraded to `port: null`, the test then fetched a port now owned by something that never answers HTTP, and the run hung to the 60-second timeout. Green locally, red in CI, for nothing. The tests now bind `0` and use the reported port, so there is no window at all.
11859
13117
  - **@voltro/cli, @voltro/web** — **@voltro/cli, @voltro/web** — a `defer()` in a LAYOUT loader now works on the SSR-layout-shell path (a `renderMode:'spa'` page under an SSR layout chain). It used to be **silently ignored**: `voltro dev` / `voltro serve` skipped deferral preparation entirely for that path, so the loader's deferred bucket never reached the renderer, `<Await>` in the layout got a promise nobody was streaming, and no error said so. That silence was the defect — every other unsupported combination in this feature (`static`, `isr`, `interactive:'none'`, `interactive:'islands'`) fails loudly, by name, with the fix in the message.
11860
13118
 
11861
13119
  The shell now STREAMS when a layout defers: the layout chain plus the EMPTY page slot flush immediately — byte-for-byte the shell a non-deferring page has always produced, which is what keeps the client's first render (`pageClientOnly` → the empty slot) hydrating without a mismatch — and the deferred layout value arrives in a later chunk behind its `<Await>` boundary, with the settle script that publishes it to the client registry. A shell whose layouts do NOT defer is untouched: still one buffered write, still no registry script. Both boot paths go through one shared preparer (`prepareSpaLayoutShell` in `ssrHelpers.ts`), so dev and serve cannot drift on the seam flags; `voltro serve` reports the streamed shell as `x-voltro-rendered-by: spa`, the same as the buffered one.
11862
13120
 
11863
13121
  Two behaviour changes worth naming even though neither breaks code that compiled. (1) `assertDeferralSupported` no longer decides on `renderMode` alone — a `renderMode:'spa'` page is accepted when its shell is delivered as a streamed per-request response, and still rejected when it is not. (2) The build-time prerender of a spa shell passes `layoutShell: 'artefact'`, so a deferring layout there is now a hard error naming the page instead of an `<Await>` fallback frozen into a static file forever. In practice that error is unreachable through `voltro build`: the existing safety gate already sends any chain with a layout `loader` to the on-demand renderer, and a `defer()` can only come from a loader — it is the second line of defence if that gate is ever loosened.
11864
13122
 
11865
- Verified as ORDERING rather than final bytes (a buffered implementation passes any final-HTML diff): the shell chunk must arrive first and must NOT contain the deferred value, asserted against both a real `voltro dev` and a real `voltro serve` in `webDevSsrLayoutLoader.test.ts`, in a jsdom hydration harness that streams real chunks into a real `hydrateRoot` (`deferredHydration.test.tsx`, with a control that breaks the `pageClientOnly` seam and must mismatch), and in a real chromium (`scripts/browser-spa-layout-shell.mjs --only=defer`, which also runs the seam-broken control against the streamed shell).
13123
+ Verified as ORDERING rather than final bytes (a buffered implementation passes any final-HTML diff): the shell chunk must arrive first and must NOT contain the deferred value, asserted against both a real `voltro dev` and a real `voltro serve` in `webDevSsrLayoutLoader.test.ts`, in a jsdom hydration harness that streams real chunks into a real `hydrateRoot` (`deferredHydration.test.tsx`, with a control that breaks the `pageClientOnly` seam and must mismatch), and in a real chromium (which also runs the seam-broken control against the streamed shell).
11866
13124
  - **@voltro/plugin-storage** — **@voltro/plugin-storage** — `storage.listRefs` no longer dies when one ref's `tags` cell is not an array of strings. An unreadable cell reads as `null`; every other row is unaffected.
11867
13125
 
11868
13126
  The reported failure: a tenant whose `_voltro_storage_refs.tags` column held a jsonb `{}` lost the **entire** query. The row set encodes against `Schema.NullOr(Schema.Array(Schema.String))` as one value, so a single bad cell fails all of it — and it surfaced as an Effect defect (`Die`), not the typed `StorageError` a caller can handle. The plugin's own media-library read was unusable for that tenant.
@@ -11895,7 +13153,7 @@ _Changes staged for the next release accumulate here (rolled up from
11895
13153
 
11896
13154
  ### Fixed
11897
13155
 
11898
- - **@voltro/cli** — `voltro dev` no longer OOM-kills itself when many `renderMode:'ssr'` pages compile at once. The dev SSR renderer compiles each page (and every layout in its chain) on demand via Vite's `ssrLoadModule`, and that call was unbounded: two browser tabs on the same cold route, or a health-check sweep hitting hundreds of distinct routes, each started its OWN esbuild module tree with no dedupe and no concurrency cap, so the transient heaps added up and the process was killed (a downstream app with ~224 SSR pages reached >14 GB in seconds; `--max-old-space-size=8192` died after ~5 concurrent cold pages). The fix is a cold-compile gate (`coldCompileGate.ts`) around every `ssrLoadModule` in the dev SSR handler — the page, each layout/error/loading segment, and the shared `@voltro/web/ssr` helpers, so a sweep can't fan out on layouts either. It does two things: (1) DEDUPES concurrent requests for the same module — N tabs on one cold route trigger ONE compile they all await; (2) BOUNDS how many DISTINCT cold compiles run at once (default 4). A WARM module (already in Vite's graph) bypasses the gate entirely, so a hot app stays fully concurrent — the gate only tames the cold stampede, it never serializes warm serving. Verified: a concurrent sweep of 100 distinct cold routes runs exactly 4 concurrent esbuild trees under the default instead of 100, and 8 concurrent requests to one cold route compile the page once. The bound is overridable with `VOLTRO_DEV_SSR_COMPILE_CONCURRENCY` — drop it to `1`/`2` on a low-memory box, raise it on a beefy one. Default 4 balances memory against cold-sweep throughput (a lower value is safer but serializes first-paint of freshly-hit routes). Production `voltro start` is unaffected: it renders from a precompiled bundle and refuses to boot without one, so it never compiles on demand. The dev-like `voltro start` middleware FALLBACK (used only when `NODE_ENV!=='production'` and no bundle exists) shares the same `ssrLoadModule` path and now goes through the same gate. Covered by `coldCompileGate.test.ts` (dedupe, bound, warm bypass, failure-clears-and-retries, env parsing); the heavy end-to-end reproduction is a scratchpad (`scripts/measure-ssr-compile-oom.mjs`), not a CI gate.
13156
+ - **@voltro/cli** — `voltro dev` no longer OOM-kills itself when many `renderMode:'ssr'` pages compile at once. The dev SSR renderer compiles each page (and every layout in its chain) on demand via Vite's `ssrLoadModule`, and that call was unbounded: two browser tabs on the same cold route, or a health-check sweep hitting hundreds of distinct routes, each started its OWN esbuild module tree with no dedupe and no concurrency cap, so the transient heaps added up and the process was killed (a downstream app with ~224 SSR pages reached >14 GB in seconds; `--max-old-space-size=8192` died after ~5 concurrent cold pages). The fix is a cold-compile gate (`coldCompileGate.ts`) around every `ssrLoadModule` in the dev SSR handler — the page, each layout/error/loading segment, and the shared `@voltro/web/ssr` helpers, so a sweep can't fan out on layouts either. It does two things: (1) DEDUPES concurrent requests for the same module — N tabs on one cold route trigger ONE compile they all await; (2) BOUNDS how many DISTINCT cold compiles run at once (default 4). A WARM module (already in Vite's graph) bypasses the gate entirely, so a hot app stays fully concurrent — the gate only tames the cold stampede, it never serializes warm serving. Verified: a concurrent sweep of 100 distinct cold routes runs exactly 4 concurrent esbuild trees under the default instead of 100, and 8 concurrent requests to one cold route compile the page once. The bound is overridable with `VOLTRO_DEV_SSR_COMPILE_CONCURRENCY` — drop it to `1`/`2` on a low-memory box, raise it on a beefy one. Default 4 balances memory against cold-sweep throughput (a lower value is safer but serializes first-paint of freshly-hit routes). Production `voltro start` is unaffected: it renders from a precompiled bundle and refuses to boot without one, so it never compiles on demand. The dev-like `voltro start` middleware FALLBACK (used only when `NODE_ENV!=='production'` and no bundle exists) shares the same `ssrLoadModule` path and now goes through the same gate. Covered by `coldCompileGate.test.ts` (dedupe, bound, warm bypass, failure-clears-and-retries, env parsing); the heavy end-to-end reproduction is a scratchpad, not a CI gate.
11899
13157
  - **@voltro/cli** — `voltro update` (and `voltro update --codemods-only`) no longer aborts with `ELOOP: too many symbolic links` when the app tree contains a circular symlink. The codemod file scan built its ts-morph `Project` with `addSourceFilesAtPaths([...globs])`, whose underlying glob FOLLOWS symbolic links and applies the `!**/node_modules/**` negations only to the RESULTS — so a self-referential symlink anywhere under the app root (pnpm's package layout inside `node_modules`, or any stray symlinked scratch dir) made the walk descend forever and throw before any negation could apply. The scan now enumerates source files itself with `followSymbolicLinks: false` and prunes heavy directories (`node_modules`, `.git`, `dist`, `build`, `.framework`, `.turbo`, `.next`, `.cache`, `.output`, `.voltro-*`, …) at the traversal level rather than by post-filtering — the crawler never descends into them, and no symlink is followed, so a cycle put there by anything is harmless. App source a codemod rewrites is always real files on disk, so files reachable only through a symlink are deliberately not scanned (and never rewritten). A codemod's explicit `scope` still narrows the file set exactly as before.
11900
13158
  - **@voltro/plugin-auth-supabase** — `supabaseStrategy({ cookieName: 'sb-<ref>-auth-token' })` now reads `@supabase/ssr` session cookies. Those cookies do not hold a raw JWT — the SDK stores the GoTrue session as a JSON envelope, optionally `base64-`-encoded and split across `sb-<ref>-auth-token.0`, `.1`, … chunk cookies. The strategy previously handed that envelope straight to the JWT verifier, so every cookie-mode request failed with `malformed jwt` and fell back to anonymous (including `ctx.query` from a `type:'web'` loader, which forwards the browser cookie to the api). The strategy now unwraps the envelope — URL-decode, strip the `base64-` prefix and base64url-decode when present, concatenate chunks in order, then lift the inner `access_token` — before verification. The `Authorization: Bearer` path is unchanged (always a raw token). Hostile / malformed / oversized cookies resolve to anonymous (skip), never throw. Auth strategies belong on the `type:'api'` app, not the web app — the api verifies the forwarded cookie.
11901
13159
 
@@ -11924,7 +13182,7 @@ _Changes staged for the next release accumulate here (rolled up from
11924
13182
 
11925
13183
  **Streaming is now wired for `renderMode: 'ssr'` on BOTH boot paths.** `voltro dev` and `voltro start` share one shell splitter + stream driver (`cli/src/ssrShell.ts`) rather than hand-mirroring the sequence — the same reason `buildSegmentChain` is shared. `renderPageToStream` was previously present but deliberately unwired, on the (correct, measured) grounds that streaming a Suspense-FREE tree changes event-loop blocking by zero. `defer()` is what makes the tree no longer Suspense-free, so that reasoning no longer applies and the doc comment saying so has been rewritten instead of left contradicting the code.
11926
13184
 
11927
- Measured against a real `voltro start` (`scripts/measure-deferred-stream.mjs`, one 400ms deferred field): first body byte at 7ms, deferred chunk at 408ms, and a probe firing every 10ms for the duration of the streamed request was served 30 times at a 3ms median / 7ms max. The event loop stays free while the deferred value is pending — that, not TTFB alone, is the point.
13185
+ Measured against a real `voltro start` (one 400ms deferred field): first body byte at 7ms, deferred chunk at 408ms, and a probe firing every 10ms for the duration of the streamed request was served 30 times at a 3ms median / 7ms max. The event loop stays free while the deferred value is pending — that, not TTFB alone, is the point.
11928
13186
 
11929
13187
  **`defer()` is a hard error, naming the page, on every mode where it provably cannot work** — each verified against React 19.2.7 rather than assumed: `renderMode: 'static'` (`renderToString` does not support Suspense; it emits an errored boundary plus a "switched to client rendering" template with no warning, so the artefact would ship a permanent fallback), `renderMode: 'isr'` (caches a completed HTML string), `interactive: 'none'` (no JS to run React's reveal scripts), `interactive: 'islands'` (the root never hydrates, so nothing consumes the streamed value). Awaiting-and-inlining on the artefact modes was considered and rejected: it makes `defer()` a silent no-op that still reads like it streams, and it needs a second wire format and a second `<Await>` path for no user-visible gain.
11930
13188
 
@@ -11946,7 +13204,7 @@ _Changes staged for the next release accumulate here (rolled up from
11946
13204
  - **@voltro/database, @voltro/cli** — `timestampMs` / `timestampMsOrNull` are now importable from a descriptor. 0.6.0 shipped them — and `rowSchema(table)` — documented for a `*.query.ts` `output`, but the browser-safety guard rejects `@voltro/database` and every subpath under it, so the documented usage aborted `voltro dev` with an import-chain error. A downstream app hit exactly that. The field schemas now ship from a new browser-safe entry, **`@voltro/database/wire`**, which contains plain `effect/Schema` values and imports `effect` and nothing else; the guard permits that one subpath. It is not a blanket allowlist entry — the guard resolves a workspace package to its `./src/*.ts` source and keeps walking, so a server import added to that module is still caught and still aborts boot. Both directions are covered by tests (`browserSafetyGuard.test.ts`): the subpath passes, bare `@voltro/database` still fails even with the subpath present, and a deliberately regressed wire module is caught. The package root keeps exporting the same values, so server-side code that already imports the root needs no second import. `rowSchema(table)` / `columnSchema(def)` are corrected rather than changed: they take the table as a VALUE, and reaching a table means importing an app's `database/schema.ts`, which imports `@voltro/database` — so a descriptor can never use them, no matter where they are packaged. That is a consequence of the browser/server boundary, not a packaging accident, and it is now stated where it was previously mis-stated. They remain the row CODEC for server-only code (`*.server.ts`, `*.seed.ts`, jobs, scripts): encoding rows for a file export or queue payload, decoding seed / import data against the real table shape. The doc comments, the docs site (en + de), the seeded agent guide, and the `voltro doctor` `hand-serialized-date` rule — which recommended `rowSchema(table)` in a descriptor's `output` and was therefore actively steering users into the boot crash — all now point at the field schemas instead. No API was removed or narrowed, and no user code that compiled stops compiling: the previously-recommended usage never got as far as a boot, so there is nothing to migrate. Apps that worked around this with hand-written `Date → epoch` converters can delete them and declare `timestampMs` on the field.
11947
13205
  - **@voltro/cli** — `voltro dev` now runs LAYOUT loaders during SSR, like `voltro start` always did. A `layout.tsx` exporting a `loader` had it skipped entirely on the dev server's SSR pass: `useLoaderData()` inside that layout was `undefined` on the server-rendered first paint and only populated after hydration, while the identical code rendered correctly in production. The dev log gave the one visible tell — the `loadChain` phase reported `0ms`, because the phase only imported layout modules and never awaited a loader. This is dev/production parity drift, so the fix is structural rather than a second copy of the loop. Chain assembly plus the layout-loader run now live in ONE `buildSegmentChain`, called by both SSR paths; only module loading differs between them (`voltro start` resolves through its SSR module provider, `voltro dev` through Vite's `ssrLoadModule`) and that is injected. Everything a user can observe is single-sourced: the loader argument shape (`params`, `pathname`, `signal`, `headers`, `query` — identical to what a page loader receives, including the server-side rpc `query()` bound to the request's session cookie), the parallel run across chain segments, and the keying of each result by CHAIN index so a layout can never be handed a sibling's data. Dev also now shares ONE `AbortController` across the page loader and every layout loader on a request, wired to the response `close` event — navigating away cancels all in-flight loader fetches, not just the page's. A throwing layout loader is surfaced exactly as a throwing page loader already was on each path: `voltro start` maps `NotFoundError` / `RedirectError` onto a 404 / 3xx and lets anything else propagate, `voltro dev` logs the failure and falls back to the SPA shell. It is never swallowed into a silently empty layout. No documented behavior changes — layout-level loaders were already specified to run server-side, and dev was the outlier. Apps that worked around this by refetching in the layout after hydration can drop the workaround; nothing needs to change to pick up the fix.
11948
13206
  - **@voltro/web** — The router no longer discards loader data it already holds while a route's loaders are still in flight. Its `pending` branch rendered the page under `<LoaderDataContext.Provider value={undefined}>` unconditionally, so `useLoaderData()` returned `undefined` for that window even when the router was holding that exact route's data. Where it actually bit: a `renderMode: 'static'` page. The prerender runs the PAGE loader and inlines its result into `__voltro_state__`, but it runs no LAYOUT loaders — so after hydration the client re-runs them and the router sits in `pending` until they settle, with the page's own data committed the whole time. During that window the page re-rendered with `undefined`, and an unguarded page (`data.title`, exactly what the docs tell you to write) threw into its `RouteErrorBoundary`. A fast layout loader hides this — it settles in the same microtask checkpoint and React batches the bad render away — so it only surfaces for real when a layout loader is slow, which is the case a real network call produces. The reuse is GATED, because the inverse is a worse bug: on a client-side navigation the committed data belongs to the route being left, and handing it to the incoming page would typecheck (both sides are `unknown` at that seam) and often look plausible on screen. The committed chain data now carries the pathname it was produced for, and the page-level provider reuses it only when that pathname is the one being rendered. The two diverge in exactly one situation — a route that opts into a `Pending` skeleton is displayed before its loaders settle — and that is the situation the gate exists for. Per-layout data got the same treatment one level down, with a finer key: during that same skeleton window a layout's committed value is reused only if the SAME `Layout` component still occupies that chain position. A shared shell therefore stays populated behind the incoming route's skeleton (the point of opting into one), while a DIFFERENT layout at that chain index now gets nothing instead of inheriting its predecessor's value by index. No public API changed (the api-extractor golden is unchanged) and no code that compiled stops compiling; a page that previously flashed `undefined` mid-pending now simply keeps its data. Covered end-to-end in `hydrateLoaderData.test.tsx` — a real SSR render → real state script → real `mount()` → real `hydrateRoot`, with a deliberately slow layout loader, plus both navigation directions and empty-console assertions.
11949
- - **@voltro/cli** — React Fast Refresh now works for pages and layouts in `voltro dev`. Until now **nothing** in a Voltro web app hot-updated: every edit — a page component, a layout component, a stylesheet-adjacent TSX tweak — forced a FULL PAGE RELOAD, discarding form input, scroll position, open dialogs and all client state. Measured in a real browser across both the streamed and the buffered SSR paths, so it was boot-path-agnostic and long-standing. Editing a page component now applies as a hot update with `useState` intact; editing a `loader` still reloads, deliberately. Two causes, both fixed. **(1)** react-refresh only accepts a module whose exports are all components, and a page exports `loader` / `renderMode` / `meta` beside its component, so every route module rejected itself and invalidated upward. **(2)** The rejection then reached the generated `.framework/app.tsx`, which was ALSO ineligible because it exported `preloadCurrentRoute` beside `App`, so it bubbled on to `main.tsx` — which accepts nothing, i.e. a full reload. The obvious fix — strip server-only exports from the client bundle, the Remix approach — is not available here: Voltro's loaders are **isomorphic** (`router.tsx` runs a route's `loader` in the browser on client-side navigation), so the loader must stay in the client graph. Instead, `voltro dev` registers each route module's non-component export names with `@vitejs/plugin-react`'s ignored-exports hook, so react-refresh judges only the components, and the framework decides the reload itself by comparing those exports' VALUES across the update — a function by its source text, everything else by its JSON form. A JSX-only edit recreates the `loader` function object but not its text, so it correctly reads as unchanged and hot-updates; an actual loader edit reads as changed and forces a reload. That reload is the correct outcome, not a limitation: a `loader` also runs server-side, the visible page was rendered from the old one, and the router caches loader results per route + params, so a silent hot swap would leave stale data on screen. The transform is **client-only and append-only** — it never removes an export, and it bails on the SSR environment entirely, because the dev renderer imports each page/layout through `ssrLoadModule` and reads `loader` / `renderMode` straight off the namespace. The generated dev entry is now split in three so the boundaries are clean: `app.tsx` exports `App` and nothing else (a valid refresh boundary), the route table + the mutable module registry HMR patches move to a new `.framework/routeTable.ts` — a plain `.ts` module that is deliberately NOT a boundary, so refreshing `app.tsx` cannot drop already-loaded page modules — and `main.tsx` stays side-effect-only. These are generated files, rewritten on every `voltro dev` boot; nothing user-authored changes. Covered by `routeFastRefresh.test.ts` (client transform injects, SSR transform is a no-op, and the reload decision — including the "re-evaluated but unedited loader is not a change" case that the whole thing turns on) and `webDevEntrySplit.test.ts` (app.tsx has exactly one export). The behaviour itself is browser-only and is driven by `scripts/browser-deferred-stream.mjs`, which now bumps a live `useState` counter before each edit and asserts it survives a component edit and resets on a loader edit — the only assertion that distinguishes a hot update from a reload.
13207
+ - **@voltro/cli** — React Fast Refresh now works for pages and layouts in `voltro dev`. Until now **nothing** in a Voltro web app hot-updated: every edit — a page component, a layout component, a stylesheet-adjacent TSX tweak — forced a FULL PAGE RELOAD, discarding form input, scroll position, open dialogs and all client state. Measured in a real browser across both the streamed and the buffered SSR paths, so it was boot-path-agnostic and long-standing. Editing a page component now applies as a hot update with `useState` intact; editing a `loader` still reloads, deliberately. Two causes, both fixed. **(1)** react-refresh only accepts a module whose exports are all components, and a page exports `loader` / `renderMode` / `meta` beside its component, so every route module rejected itself and invalidated upward. **(2)** The rejection then reached the generated `.framework/app.tsx`, which was ALSO ineligible because it exported `preloadCurrentRoute` beside `App`, so it bubbled on to `main.tsx` — which accepts nothing, i.e. a full reload. The obvious fix — strip server-only exports from the client bundle, the Remix approach — is not available here: Voltro's loaders are **isomorphic** (`router.tsx` runs a route's `loader` in the browser on client-side navigation), so the loader must stay in the client graph. Instead, `voltro dev` registers each route module's non-component export names with `@vitejs/plugin-react`'s ignored-exports hook, so react-refresh judges only the components, and the framework decides the reload itself by comparing those exports' VALUES across the update — a function by its source text, everything else by its JSON form. A JSX-only edit recreates the `loader` function object but not its text, so it correctly reads as unchanged and hot-updates; an actual loader edit reads as changed and forces a reload. That reload is the correct outcome, not a limitation: a `loader` also runs server-side, the visible page was rendered from the old one, and the router caches loader results per route + params, so a silent hot swap would leave stale data on screen. The transform is **client-only and append-only** — it never removes an export, and it bails on the SSR environment entirely, because the dev renderer imports each page/layout through `ssrLoadModule` and reads `loader` / `renderMode` straight off the namespace. The generated dev entry is now split in three so the boundaries are clean: `app.tsx` exports `App` and nothing else (a valid refresh boundary), the route table + the mutable module registry HMR patches move to a new `.framework/routeTable.ts` — a plain `.ts` module that is deliberately NOT a boundary, so refreshing `app.tsx` cannot drop already-loaded page modules — and `main.tsx` stays side-effect-only. These are generated files, rewritten on every `voltro dev` boot; nothing user-authored changes. Covered by `routeFastRefresh.test.ts` (client transform injects, SSR transform is a no-op, and the reload decision — including the "re-evaluated but unedited loader is not a change" case that the whole thing turns on) and `webDevEntrySplit.test.ts` (app.tsx has exactly one export). The behaviour itself is browser-only and is driven by a chromium check that now bumps a live `useState` counter before each edit and asserts it survives a component edit and resets on a loader edit — the only assertion that distinguishes a hot update from a reload.
11950
13208
  - **@voltro/web, @voltro/cli** — Server-rendered loader data now reaches the client's FIRST render. Every `renderMode: 'ssr'` page with a loader — and every `layout.tsx` with one — previously hydrated with `useLoaderData()` returning `undefined`, because the inlined `__voltro_state__` payload was written by the SSR pipeline and read by nobody: `mount()` used the script tag as a boolean to pick `hydrateRoot` over `createRoot` and never parsed its contents, and `<Router>` started with no committed loader data, re-running every loader in an effect. Layout (chain-segment) data was not inlined in any shape at all. The consequences were not cosmetic. An ordinary SSR page that dereferences its own loader data (`data.value`) threw `TypeError: Cannot read properties of undefined` on the hydration render and fell into `RouteErrorBoundary`; a layout rendering its loader's value produced a genuine React hydration mismatch (server `ROOT_LAYOUT_LOADER_RAN`, client `ROOT_LAYOUT_LOADER_MISSING`), after which the tree was regenerated from scratch. Both are now measured in a real browser against `e2e-fixtures/web-layout-loader`, with zero console errors and zero page errors, and reproduced in the unit suite through a real `hydrateRoot`. The payload is one coherent object — `{ page, segments: { <chainIndex>: … }, ran }` — defined once in `@voltro/web`'s `routerState` module and emitted through a single `renderRouterStateScript()` helper that `voltro dev`, `voltro start` and `voltro build` all call. That is deliberate: the dev and serve SSR paths are independent assemblies, and hand-mirroring the shape into each is exactly the drift that let layout loaders go missing in dev in the first place. `ran` exists because JSON cannot express `undefined` — without it a loader that resolved to `undefined` is indistinguishable from one that never ran, and the client would re-run it. Values are keyed by the same chain index the renderer wraps layouts with, so a layout can never be handed its neighbour's data; escaping is unchanged (`<` is escaped, so a `</script>` inside loader data cannot break out of the tag). **Two behaviour changes worth knowing about, neither of which stops any code compiling.** First, `useLoaderData()` now returns the server's value on the initial render of a server-rendered page instead of `undefined`; components that branched on `undefined` to show a skeleton will simply stop showing it on first paint. Second, loaders no longer re-run on initial hydration — that re-run was the source of the post-hydration flash. A loader whose client-side re-execution an app was relying on (to refresh data or to trigger a side effect after mount) will no longer fire on first load; move that work into an effect. Client-side navigation is unaffected and runs loaders exactly as before, as does a fresh client mount with no server markup. Static prerender (`voltro build`) inlines the page loader's result the same way. It does not run layout loaders — it never did — so a static page's layouts continue to resolve their data on the client after mount.
11951
13209
 
11952
13210
  ---
@@ -11982,8 +13240,8 @@ _Changes staged for the next release accumulate here (rolled up from
11982
13240
 
11983
13241
  ### Fixed
11984
13242
 
11985
- - **@voltro/sql-turso** — Turso (local Rust engine): pooled connections now WAIT for a held lock instead of failing instantly with `database is locked`. The engine keeps SQLite's default of `PRAGMA busy_timeout = 0`, so any statement that met a lock held by another connection failed on the spot — and with the default pool of 4 connections on one file, two concurrent writers are enough to reach it. `makeConnection` now issues `busy_timeout` for every pooled connection, beside the mandatory MVCC and foreign-key pragmas. MVCC did not cover this and was the reason it was missed: `journal_mode=experimental_mvcc` resolves write-write conflicts BETWEEN transactions, while DDL and the schema lock stay exclusive, so the failure lands on statements the concurrency design appears to have handled. It also only reproduces under CPU contention — green on an idle machine, sporadic under load — which is the worst shape for a defect to have. It surfaced as a flaky `CREATE TABLE` in the MVCC keystone test during a full local gate run, where 78 packages build in parallel; a user would see it as an intermittent `database is locked` under production traffic with no obvious trigger. The default is 5000ms, matching better-sqlite3's own default — which is why the sibling `@voltro/sql-sqlite` never needed this: that driver sets the timeout for us, and the turso NAPI driver does not. Tunable via `busyTimeoutMs` on `makeTursoSqlLayer` / `TursoClientConfig`, beside `maxConnections`; `busyTimeoutMs: 0` explicitly restores the fail-immediately behavior (asserted by a test, so the default can never be implemented as a floor that silently ignores 0). It is deliberately NOT on the cross-dialect `ConnectionConfig` — that shape stays free of engine-specific knobs, the same reason the Turso auth token is env-sourced rather than threaded through it.
11986
- - **@voltro/cli** — `voltro update` now honors the project's actual package manager instead of defaulting to npm. It resolves the manager by walking from the app directory **up to the repo root**, preferring the corepack `packageManager` field over a lockfile (`pnpm-lock.yaml` / `yarn.lock` / `bun.lock` / `bun.lockb` / `package-lock.json`), and only falls back to npm when nothing declares one. Walking up fixes the workspace case: a scaffolded project keeps its lockfile at the monorepo root, so running `voltro update` from `apps/api` previously found no lockfile and ran `npm install` against a pnpm/yarn workspace — writing a stray lockfile and a nested `node_modules`. The resolved manager is also used for the latest-version registry lookup (`pnpm view` / `yarn` / `bun pm view`, with `npm view` as a last-resort fallback), so a private or scoped registry configured in `.npmrc` / `.yarnrc.yml` is honored. The yarn query dispatches on the installed yarn MAJOR version rather than probing berry syntax first: on yarn classic, `yarn npm info …` parses as `yarn run npm` and **executes a `npm` script from the project's package.json** if one exists — verified against yarn 1.22.22. Resolving a version number must never run user code, so classic gets `yarn info … --silent` and only berry (>=2) gets `yarn npm info`. All three managers are verified against real binaries in throwaway Docker containers — yarn classic 1.22.22, yarn berry 4.6.0, bun 1.3.14 — each asserting both that the query resolves a version and that it does not execute a same-named script. Re-run with `node scripts/smoke-package-managers.mjs`.
13243
+ - **@voltro/sql-turso** — Turso (local Rust engine): pooled connections now WAIT for a held lock instead of failing instantly with `database is locked`. The engine keeps SQLite's default of `PRAGMA busy_timeout = 0`, so any statement that met a lock held by another connection failed on the spot — and with the default pool of 4 connections on one file, two concurrent writers are enough to reach it. `makeConnection` now issues `busy_timeout` for every pooled connection, beside the mandatory MVCC and foreign-key pragmas. MVCC did not cover this and was the reason it was missed: `journal_mode=experimental_mvcc` resolves write-write conflicts BETWEEN transactions, while DDL and the schema lock stay exclusive, so the failure lands on statements the concurrency design appears to have handled. It also only reproduces under CPU contention — green on an idle machine, sporadic under load — which is the worst shape for a defect to have. It surfaced as a flaky `CREATE TABLE` in the MVCC keystone test while 78 packages built in parallel; a user would see it as an intermittent `database is locked` under production traffic with no obvious trigger. The default is 5000ms, matching better-sqlite3's own default — which is why the sibling `@voltro/sql-sqlite` never needed this: that driver sets the timeout for us, and the turso NAPI driver does not. Tunable via `busyTimeoutMs` on `makeTursoSqlLayer` / `TursoClientConfig`, beside `maxConnections`; `busyTimeoutMs: 0` explicitly restores the fail-immediately behavior (asserted by a test, so the default can never be implemented as a floor that silently ignores 0). It is deliberately NOT on the cross-dialect `ConnectionConfig` — that shape stays free of engine-specific knobs, the same reason the Turso auth token is env-sourced rather than threaded through it.
13244
+ - **@voltro/cli** — `voltro update` now honors the project's actual package manager instead of defaulting to npm. It resolves the manager by walking from the app directory **up to the repo root**, preferring the corepack `packageManager` field over a lockfile (`pnpm-lock.yaml` / `yarn.lock` / `bun.lock` / `bun.lockb` / `package-lock.json`), and only falls back to npm when nothing declares one. Walking up fixes the workspace case: a scaffolded project keeps its lockfile at the monorepo root, so running `voltro update` from `apps/api` previously found no lockfile and ran `npm install` against a pnpm/yarn workspace — writing a stray lockfile and a nested `node_modules`. The resolved manager is also used for the latest-version registry lookup (`pnpm view` / `yarn` / `bun pm view`, with `npm view` as a last-resort fallback), so a private or scoped registry configured in `.npmrc` / `.yarnrc.yml` is honored. The yarn query dispatches on the installed yarn MAJOR version rather than probing berry syntax first: on yarn classic, `yarn npm info …` parses as `yarn run npm` and **executes a `npm` script from the project's package.json** if one exists — verified against yarn 1.22.22. Resolving a version number must never run user code, so classic gets `yarn info … --silent` and only berry (>=2) gets `yarn npm info`. All three managers are verified against real binaries in throwaway Docker containers — yarn classic 1.22.22, yarn berry 4.6.0, bun 1.3.14 — each asserting both that the query resolves a version and that it does not execute a same-named script.
11987
13245
  - **@voltro/cli** — Three fixes to `voltro update`, all reported by an app upgrading a pnpm workspace. **The bump is now LOCKSTEP across the whole workspace.** `voltro update` in `apps/api` bumped only that `package.json`, leaving the sibling web app and shared `packages/*` on the previous version — an api on 0.6.0 and a web client on 0.5.0 disagree about the generated rpcGroup types and the session cookie shape, and that disagreement surfaces as a runtime decode error, not a build error. When the app sits inside a workspace (`pnpm-workspace.yaml`, or a `workspaces` field in an ancestor `package.json`, found by the same bounded upward walk that resolves the package manager and stops at the first `.git`), every member `package.json` that declares `@voltro/*` is bumped to the target together, and the install runs **once at the workspace root** — running it inside `apps/api` corrupts a pnpm/yarn workspace's layout. Each file that will be bumped is listed in the plan output and in `--dry-run`. A standalone (non-workspace) project is unchanged: its own `package.json`, its own install, in place. The codemod re-exec now also looks for the installed `voltro` bin at the workspace root, since npm and yarn hoist it there. **A failed install now says that the codemods were skipped.** It previously printed only "install failed — package.json was bumped; fix the install and re-run", never mentioning codemods, so a user could boot on target-version code with source shaped for the old one and no signal as to why. The codemods for a jump ship *inside* the target version, which a failed install did not put on disk, so running them is impossible rather than merely undesirable — the fix is the message. It now states plainly that no codemods were applied, why, and prints the exact copy-pasteable recovery command with the concrete versions: `voltro update --codemods-only --from <from> --to <to>`. **`--help` / `-h` is answered before every guard.** `voltro update --help` on a dirty tree printed "working tree is not clean" — at precisely the moment the user was trying to discover `--dry-run` and `--codemods-only`. Help is documentation, not an operation, so it is now handled first, ahead of the `package.json` check, the `@voltro/*`-deps check and the clean-tree guard, and lists every flag (`--to`, `--from`, `--root`, `--dry-run`, `--force`, `--exact`, `--codemods-only`). `voltro doctor --help` had the same shape — it fell through to the preflight and reported on the tree instead — and gets the same treatment. `voltro help`'s `update` line now names `--from` and `--codemods-only` too.
11988
13246
 
11989
13247
  ---