@voltro/plugin-atlassian 0.11.4 → 0.13.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 +356 -0
  2. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -39,6 +39,362 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.13.0] — 2026-07-25
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/protocol, @voltro/plugin-scim, @voltro/plugin-prometheus** — SCIM was served UNAUTHENTICATED whenever its token was an empty string.
47
+
48
+ `checkBearer(headers, expected)` returned `true` when `expected` was unset or empty, documented as "no token configured = open; the caller decided not to gate this surface". Its one production caller had decided the opposite: `scimPlugin` declares `token: string`, and `scimPlugin({ token: process.env.SCIM_TOKEN ?? '' })` — the shape anyone writes — turned the gate off silently. The result was SCIM 2.0 Users and Groups readable with no credentials: a full directory dump plus the provisioning surface that can deactivate accounts. Likeliest exactly where it hurts, too: an env var set in production and missing in a preview environment.
49
+
50
+ `checkBearer` is now fail-closed by default, with the permissive behaviour available as an explicit `{ openWhenUnset: true }` — a two-argument helper cannot know its caller's intent, so it must not assume the permissive one. `@voltro/plugin-prometheus` passes it (its token is documented as optional), and `scimPlugin` now throws at construction — i.e. at boot — rather than answering the first anonymous request.
51
+ - **@voltro/database, @voltro/cli** — `voltro db apply` and boot auto-migrate could report success while applying nothing, and then record a fingerprint that made every later boot short-circuit on "schema up to date".
52
+
53
+ Reported from a live pod: `applied 31 op(s)` on every boot for two releases, with none of the 31 present in the database. Nothing was wrong with the transport, the lock or the transaction — the applier emitted statements that postgres accepted and that changed nothing. Two independent causes:
54
+
55
+ - A `ColumnSnapshot` carried no `vector` dimension / `array` element / `enum` name, so the applier's type renderers collapsed all three to `text`. A declared `vector(1536)` over a live `text` column planned an `alter-column-type` that emitted `ALTER COLUMN … TYPE text`. Valid, applied, no-op, re-planned forever. (Also meant an `add-column` for a vector, array, enum or PostGIS column created a plain `text` column.) - The default-clause renderers excluded ARRAYS, returning `null`, and the call site turned that into `SET DEFAULT NULL`. A declared `.default([])` on a `json()` column therefore never landed — thirty columns were stuck this way in the reporting schema.
56
+
57
+ Fixed: the snapshot carries the type parameters and the renderers delegate to `migrate.ts`'s canonical `sqlType`, so the applier and the CREATE-TABLE emitter cannot disagree; array defaults render (a real `text[]` literal on a native `array()` column, a jsonb literal otherwise); and a default the renderer cannot express now FAILS instead of degrading to `DEFAULT NULL`.
58
+
59
+ And the structural guard, which is the part that matters: **`applyPlan` re-plans against the live schema before it records a fingerprint, and refuses to record one if any operation remains.** DDL that changes nothing succeeds exactly as quietly as DDL that works, so the only evidence a plan applied is that the same planner has nothing left to do. `ApplyPlanCtx` gains a required `replan`; `AppliedMigration` gains `appliedOps` (what EXECUTED, not `plan.operations.length`), and the boot log quotes that.
60
+ - **@voltro/plugin-storage** — `storage.share`, `storage.revoke` and `storage.listGrants` performed no authorization at all.
61
+
62
+ Each took an object id straight off the wire and passed it to a service method that (correctly, for a trusted server-side API) checks nothing, with nothing in between. Any authenticated caller could grant themselves read or write on any object in the installation, revoke anyone else's grants, and enumerate who an object is shared with.
63
+
64
+ All three now require that the caller owns the object, or carries `admin:full`. A missing object and an unowned object report the same 403 — a 404 would let an unauthorized caller probe which ids exist. `GrantStore` gains `getById`, which `revoke` needs to resolve a grant id back to its object.
65
+
66
+ ### Added
67
+
68
+ - **@voltro/runtime, @voltro/database** — API keys carry app-owned `metadata` — the second ownership axis.
69
+
70
+ `tenantId` and `onBehalfOf` are the two relationships the framework models. Plenty of apps have a third that actually authorizes the key: a team, a project, an environment. `ApiKeyRecord` in `@voltro/protocol` has carried a `metadata` slot all along — its doc comment even names `teamId` as the example — but the SERVICE had nowhere to store it and nowhere to return it. So an app with a team axis could authenticate through the built-in strategy and still not authorize, and `apiKeys: true` was unusable for it. Reported as the one thing that stopped an otherwise complete adoption; their alternatives were a second table joined on the hot auth path, or smuggling `team:<id>` into `scopes`, where `hasScope` would then see a scope that is not a scope.
71
+
72
+ `IssueInput`, `ApiKeyRow` and `ResolvedApiKey` now carry it, stored as JSON on `_voltro_api_keys`, and it survives `rotate` — a rotated key is the same credential with a new secret, so dropping it would silently de-authorize every rotated key.
73
+
74
+ It is app data, never identity. The strategy merges it UNDER the framework's own claims: `provider` and the acting `userId` are written afterwards from `onBehalfOf` and always win, including when the answer is "none". A bag that could set `userId` would let whoever minted the key choose who the request is. Pinned end-to-end, not just at the protocol layer.
75
+
76
+ `PublicApiKey` also gains `createdBy` and `onBehalfOf`, so `service.list` can answer the two questions an admin actually asks about a shared credential. Neither is a secret — they are the accountability record, and omitting them hid them from the person responsible for the key.
77
+ - **@voltro/protocol, @voltro/cli** — A boot warning when two auth strategies claim the same bearer-token prefix.
78
+
79
+ The chain is first-match-wins, so a duplicate claim is not a harmless redundancy: whichever strategy runs first decides the Subject. An app that already has its own `sk_` keys and then sets `apiKeys: true` gets the framework strategy appended on the same prefix — resolving without the app's own team binding — and *which strategy answered* decides whether authorization works. Reported by an app that had to pin a test asserting it never enables the flag.
80
+
81
+ `AuthStrategy` gains an optional `claimsBearerPrefix`, set by `apiKeyStrategy` from its `prefix` option. Making the claim declarative is what makes the collision detectable at all — the same "only what is declared can be checked" argument the scope rules run on. Checked in `buildResolveSubject`, which both `voltro dev` and `voltro serve` call, so the two boot paths cannot drift.
82
+
83
+ A warning rather than a refusal: two strategies on one prefix can be deliberate (a migration window where old and new keys share a shape). What must not happen is that it goes unmentioned.
84
+ - **@voltro/protocol, @voltro/cli** — `auth.resolveScopes` — add scopes to an authenticated Subject from your own data, so ROLE-based authorization becomes declarable.
85
+
86
+ An app whose authorization is a database role (`requireCallerAdmin(ctx)` reading an `employees.role` column) is invisible to every static check the framework has: `voltro check`'s `rbac/unguarded-mutation` reports its writes as unguarded, and it is right to — nothing about the decision is declared. But the declarative alternative was unusable for exactly those apps: their subjects come from an external IdP's JWTs and carry no scopes, so `requireScope('employee:admin')` would lock out every real user. One app measured 1566 findings it had no way to act on.
87
+
88
+ Lifting the role into `subject.scopes` makes the SAME authorization declarable, visible in the manifest and checkable in CI. Deliberately narrow: the hook returns SCOPES, never a Subject — it cannot change `id` or `tenantId` (identity belongs to the auth strategy), and the result is unioned with the strategy's own scopes, so it can grant but never revoke. It runs per matched request, so cache the lookup yourself; the framework does not, because only the app knows how fast a role change must take effect. Wired identically in `voltro dev` and `voltro serve`.
89
+ - **@voltro/cli** — `voltro doctor` reports packages resolved at more than one version.
90
+
91
+ A consumer reported type errors inside the GENERATED `rpcGroup.generated.ts` — `Property '[TypeId]' is missing`, `typeof Never is not assignable to All`, an `Rpc<…, Stream<…>, …>` refused where `Any` was expected — and reasonably concluded the framework emits bad types, because the errors land in a file they cannot edit and did not write. That is the signature of two copies of `effect` in one install: Effect's types are nominal, so a Schema built by one copy is not the type the other expects.
92
+
93
+ It deserves its own check because the RUNTIME usually stays green — two instances only diverge where identity matters — so an app boots, serves and passes its tests while `tsc` is red, which sends people looking at the compiler instead of the dependency tree. The report names the versions, the paths, and the errors it explains. Only identity-sensitive packages count (`effect`, `@effect/*`, `@voltro/*`, react/react-dom); a duplicated string utility is wasteful, not a bug class.
94
+ - **@voltro/cli** — `voltro doctor` flags an executor that never names its own descriptor.
95
+
96
+ Descriptor/executor pairing is by FILENAME, which is right — and it means a `*.server.ts` can be a complete, correct executor with no reference at all to the contract it implements. Those are exactly the files where a hand-written input drifts from the wire.
97
+
98
+ Reported after a 426-executor migration to `ExecutorInput<typeof descriptor>`: three files were skipped by the app's own codemod for a reason no reviewer would guess — they never imported their descriptor, so there was no `typeof` to point at. In the same codebase, six executors had written `boardPurpose: string` where their descriptor declared `Schema.Literal(...)`, discarding the contract at the executor boundary. Only imports of a SIBLING module clear the finding: an executor importing nothing but `@voltro/*` and `node:*` has still not named its contract.
99
+ - **@voltro/database** — `updateManyRow(store, table, patch, { where })` — the last untyped write is now typed against its table.
100
+
101
+ `insertRow` and `upsertRow` already were; `ctx.store.updateMany(table, row, { where })` still took a string table name and an untyped row literal. Worth closing because the typed versions were measured: migrating 29 `store.upsert` call sites to `upsertRow` produced 15 `tsc` errors across 8 distinct defects that no test had caught — including seven per-user mutations with no authentication check at all (they wrote `ctx.request.subject.id`, typed `string | null`, into a NOT NULL column, so an anonymous caller reached the database and got a raw statement failure instead of a typed refusal).
102
+
103
+ ### Fixed
104
+
105
+ - **@voltro/runtime** — A `cache:` declared on a query whose handler returns a COMPUTED value was silently ignored; it now says so.
106
+
107
+ The snapshot cache wraps the store read, and a computed query has none — its handler has already run by the time the binding is built. Caching one would mean wrapping the handler invocation, which is a different feature. Until that exists, the honest failure is a loud one: silently ignoring the config is how an author ends up believing a hot query is cached while every subscriber re-runs it. The data stays correct, so nothing else would ever tell them. Warned once per query name, not per subscribe.
108
+ - **@voltro/cli** — The minted `.env.local` is handed to the workspace's owner, and an unreadable env file explains itself.
109
+
110
+ A dev container running as root with the host workspace bind-mounted wrote `apps/api/.env.local` as `root:root 0600` INTO THE SHARED WORKSPACE. On the host, everything that loads env then died with EACCES — vitest, `voltro doctor`, the editor — and the developer could not even read the file, while the next container boot recreated it. Container-with-bind-mount is the ordinary dev shape, not an edge case.
111
+
112
+ `0600` stays (the file holds a real signing key), because loosening it to `0644` would make that key readable by every account on the machine for the far more common single-user case. Ownership was the wrong variable, so that is the one corrected: the mint chowns the file to whoever owns the directory, which root can do — exactly the case that needs it — and reports loudly when it cannot. A plain EACCES while loading an env file now names the owning uid, the mode and the current uid, because that pair IS the diagnosis and none of it appears in node's message.
113
+ - **@voltro/cli** — Framework-generated output is handed to the workspace's owner, not left owned by whoever the process happens to be.
114
+
115
+ The previous release fixed this for the minted `.env.local`. The report that followed showed the scope was wrong: it is EVERY directory the framework generates. A dev pod running as root with the host monorepo bind-mounted leaves `.framework/` and `app.graph.observed.*` as `root:root` inside the developer's own tree, and on the host:
116
+
117
+ ```
118
+ voltro build . → EACCES: permission denied, open '…/apps/display/.framework/index.html'
119
+ ```
120
+
121
+ That is the harder failure. `.env.local` broke env loading; this breaks the production build of every web app outright, with no workaround short of chown-ing by hand after each pod boot. One team could only verify their frontends through test suites and live requests against the running pods.
122
+
123
+ `voltro dev` and `voltro build` now hand their generated output — `.framework`, `.env.local`, every `*.generated.*` — to the uid that owns the app root, and say so loudly when they cannot. A no-op on every ordinary run and in any container started with `--user <uid>:<gid>`: when the process already owns the root it returns without touching the tree. Only generated state is claimed; the framework never chowns a file a human wrote.
124
+ - **@voltro/cli** — The observed app-graph no longer restarts the dev server.
125
+
126
+ `app.graph.observed.json` was written into the watched app root every 10 seconds, and the supervisor's watcher fired on each write. A downstream pod measured two restarts before every boot over 2000 log lines — the rule, not an outlier — and paid a ~46 s boot three times per save.
127
+
128
+ The watcher excludes `<name>.generated.<ext>`, a substring rule chosen precisely because a per-extension whitelist had already let a generated file slip twice. This file slipped it a third time by not carrying the segment at all. It is now `app.graph.observed.generated.json`, which matches the convention instead of adding a fourth special case to a list that has drifted three times; a stale un-suffixed file from an older dev server is removed on boot so it cannot keep triggering restarts.
129
+ - **@voltro/cli** — Four tooling fixes, all from downstream reports:
130
+
131
+ - **`voltro check --offline` crashed on any app that declares a workflow.** It built workflow entries as `{ name }` behind an `as never` while `InspectWorkflowEntry` is keyed by `tag`, so the manifest's sort read `undefined` and threw — surfacing as "could not assemble the graph from source" rather than the type error underneath. The cast is what let the two shapes disagree. - **`voltro check --offline` reported plugin tables as `dangling-source`.** It collected only the app's own `*.entity.ts` tables, so a query reading `_voltro_storage_refs` was an `error` — which sets the exit code, failing the CI gate the offline mode exists for. It now uses the same `assembleFrameworkTables` the migrator does. - **`voltro test` now derives `resolve.alias` from the app's tsconfig `paths`.** An app mapping `@/* → ./src/*` could not test any module importing through it (`Cannot find package '@/locales/en'`), and the workaround was a local `vitest.config.ts` restating what tsconfig already said. - **The `raw-fetch` doctor rule follows the import graph.** Keyed on filename conventions it caught 9 of 39 outbound calls on the reporting app; the other 30 were in `lib/*.ts` helpers only server code imports. A file reachable from a server-convention file and from nothing else is server code; one a page also imports is not, and stays unflagged.
132
+
133
+ ---
134
+
135
+ ## [0.12.0] — 2026-07-25
136
+
137
+ ### ⚠ BREAKING
138
+
139
+ - **@voltro/protocol, @voltro/runtime, @voltro/database** — **API keys: `createdBy` and `onBehalfOf` are now two fields, because they were always two relationships.**
140
+
141
+ One field carried both, and its own doc comment gave it away — *"the user this key was minted for **/ by**"*. That slash is the defect: a `null` had to mean BOTH "nobody created it" (never true — somebody pressed the button, org keys included) and "it belongs to no person" (the thing actually being expressed). So the model could not answer *"who created this org key?"*, which is a question you will be asked, and an admin minting a key for a colleague had nowhere to record that the key is the colleague's.
142
+
143
+ - **`createdBy`** — WHO MINTED IT. Provenance, present for org keys too. - **`onBehalfOf`** — WHO IT ACTS AS. `null` here, and only here, means an ORG key.
144
+
145
+ **Attribution now resolves to the person.** `subject.id` for an API-key request is the CREDENTIAL (`apikey_01H…`), and the audit columns stamped it — so a UI rendering "created by …" either showed a raw key id to a human or paid a join back to the key table per row. A personal key now stamps the person it acts as; an org key keeps the key id, because there the credential *is* the actor and the id is the only thing naming which integration.
146
+
147
+ **Issuance is three rights, not one admin gate.** `admin:full` for all minting meant a normal user could never create even a narrowly-scoped credential of their own, and "minting for myself" was indistinguishable from "minting as someone else":
148
+
149
+ - `apikeys:issue:self` — a key acting as me - `apikeys:issue:org` — an org key (acts as nobody, outlives my account) - `apikeys:issue:other` — a key acting as another user; never implied by the others
150
+
151
+ Plus a scope ceiling: a key can never carry scopes its issuer does not hold, or the narrowest issuance right would be a privilege-escalation primitive. `admin:full` satisfies all three, as it does every scope. Omitting `onBehalfOf` defaults to a key acting as the caller — only an explicit `null` asks for an org key, so a client that forgets the field cannot accidentally mint a credential belonging to nobody.
152
+
153
+ **A regression caught while building this, worth recording.** The first version spread `userId` onto the Subject only when `onBehalfOf` was set. That left an app-supplied `metadata.userId` in place on exactly the keys that act as no person — an org key — letting a metadata bag forge the acting user. The framework's claim about who a request is must overwrite, *including overwriting with "none"*. An existing test caught it; a new one pins the hole rather than the symptom.
154
+
155
+ `onBehalfOf` is a new column on `_voltro_api_keys` — framework tables ride the declarative differ, so no migration to write. Existing rows read back with `onBehalfOf: null`, i.e. as org keys; if yours were personal keys recorded via `createdBy`, backfill `onBehalfOf` from it.
156
+ - **@voltro/runtime** — **`apiKeyService.revoke` / `.rotate` now require the caller's `tenantId`, and refuse a key belonging to anyone else.**
157
+
158
+ They took a key id and nothing else. The shipped route guards them with `requireScope('admin:full')`, which establishes *"is an admin"* — never *"an admin of THIS key's tenant"*. So an admin of tenant A could revoke tenant B's key given its id, and ids leak: logs, support tickets, an error message, a `createdBy` column.
159
+
160
+ **Rotate was the worse of the two**: it revokes the old key and returns a *usable token* for the same tenant, so an unscoped call handed the caller a working credential for someone else's tenant. It is not exposed on the shipped routes, which is the only reason this was a latent hazard rather than a live one.
161
+
162
+ `list` was already tenant-scoped, so key ids could not be enumerated — the gap needed an id from elsewhere. That narrows exploitability; it does not make an authorization check optional.
163
+
164
+ Required rather than optional, deliberately: an optional scope on a destructive operation is a scope somebody forgets — the same reasoning that moved the SSRF guard into the HttpClient instead of leaving it a helper you remember to call. A key belonging to another tenant returns `false` / `null`, indistinguishable from "no such key", so the call cannot be used to probe for ids.
165
+
166
+ The framework's own `/v1/api-keys/revoke` route now passes the caller's tenant — no action needed if you only use the shipped routes. The `manual` codemod covers direct callers; a transform cannot write this argument, since only the surrounding handler knows whose tenant it is, and a placeholder in an authorization check would look done.
167
+ - **@voltro/cli** — **The inspect surface is now fail-closed: no `VOLTRO_INSPECT_TOKEN`, no access.** `envTokenAuthResolver` read the other way — unset token meant *everyone authorised* — which was defensible while `/_voltro/inspect/*` was a local-dev convenience and became indefensible once `voltro start` mounted it. A public web app served its route table, ISR cache keys, metrics and the **whole process log buffer** to anyone who asked, unless the operator happened to set a variable the docs described as merely "recommended". The absence of a secret is not consent, and an authorization check you were configured not to perform is a refusal, not a pass.
168
+
169
+ **Most setups see no change.** `voltro dev` MINTS a per-project token before the env gate and delivers it to both legitimate consumers without the developer touching it (the CLI reads the runtime registry, so `voltro logs` works from any cwd; the dashboard proxy injects it server-side, so the browser never holds it). Verified against a live boot: tokenless `/_voltro/inspect/app` already returned 401 before this change, and returns 200 with the minted token. `voltro serve` (api) mounts nothing at all and is unaffected.
170
+
171
+ **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.
172
+
173
+ 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`.
174
+ - **@voltro/runtime, @voltro/cli** — **The `HttpClient` handlers `yield*` now enforces an SSRF guard, on by default.**
175
+
176
+ 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**.
177
+
178
+ Why it belongs in the client rather than in a helper you call: the framework already shipped a perfectly good `assertPublicUrl`, and it was reachable from exactly **two** call sites, both inside plugin-webhooks, one of them wrapped in `if (process.env.NODE_ENV === 'production')` — so a box running with an explicit `NODE_ENV=staging` delivered webhooks with no revalidation at all. A guard that has to be remembered is not a guard. Meanwhile every app with a scraper, a webhook-registration form, an importer, or a "test this connection" button feeds a caller-supplied URL into `yield* HttpClient` and got nothing.
179
+
180
+ **The redirect hop is the part a hand-rolled version misses**, and it took two things to get right. The guard is installed with `HttpClient.transform` (which wraps `postprocess`, and `followRedirects` calls `postprocess` once per hop) rather than `mapRequestEffect` (which wraps `preprocess` and would run once, ever); and `redirect: 'manual'` is provided to fetch, or fetch follows the hops itself and the intermediate URLs never surface to be checked. Covered by a test that allows the stub host and blocks its redirect target, so a pass can only come from the hop being revalidated.
181
+
182
+ **`http.allowHosts` is the escape hatch AND the test hook — deliberately the same mechanism.** A guard that blocks loopback breaks every test pointing app code at a local stub server; without an official hook, teams mock the guard away wholesale and never exercise the production path (which is exactly how the framework's own webhook delivery ended up `NODE_ENV`-gated). Using `allowHosts: ['127.0.0.1:8787']` in a test keeps it on the real guarded path with a narrow exception.
183
+
184
+ ```ts
185
+ export default defineApiConfig({
186
+ http: { allowHosts: ['*.svc.cluster.local', 'billing.internal'] },
187
+ })
188
+ ```
189
+
190
+ Entries may be an exact host, a `*.suffix` wildcard (which does NOT match the apex — one that did would silently widen the exception), or `host:port`. There is no boolean off-switch on purpose: "we call one internal service" and "we do not check URLs" are different postures, and a boolean cannot tell them apart later.
191
+
192
+ **Breaking, and named as such**: an app doing cluster service-to-service HTTP will start failing until it declares its hosts. `*.svc.cluster.local` is blocked by the `.local` rule. The `manual` codemod prints the migration during `voltro update`.
193
+
194
+ Not covered: DNS is not resolved, so a public hostname that RESOLVES to a private address (DNS rebinding) still passes. Said plainly in the docs rather than silently implied — that vector needs network-layer egress control.
195
+
196
+ Wired through the SHARED `makeHandlerHttpClientLayer` factory that both boot paths call, reading the same `http` config key, so dev and serve cannot diverge. In `dev` it is built inside `runDevInner` rather than at module scope, because the allowlist comes from `app.config.ts` and a module-level default would have quietly ignored it in dev while serve honoured it.
197
+
198
+ ### Added
199
+
200
+ - **@voltro/cli** — `voltro check` can now reason about authorization. The capability manifest has always serialised each handler's guards, but nothing translated them into the app graph, so every scope-shaped rule was dead code: `requiresScopes` was never populated and `--diff removeScope:<name>` found nothing. The graph now derives `requiresScopes` from the declared guards, which makes scope blast-radius real, and adds a new rule — `rbac/unguarded-mutation` — that flags a mutation or action declaring **no guard at all**. "Is this write authorized correctly" needs runtime knowledge and stays out of scope; "does this write carry any authorization" is decidable straight from the manifest, and an unguarded mutation is the shape of an accidentally-public write (it warns rather than errors, since a deliberately public mutation — a signup, a webhook receiver — is legitimate). The manifest now always emits a `guards` field, as `[]` when there are none: omitting it made "this handler has no authorization" indistinguishable from "guard information wasn't reported", so no consumer could ever draw the conclusion.
201
+ - **@voltro/cli** — `voltro check` no longer needs a running api. It still prefers one — a live manifest is ground truth, including table introspection — but with none reachable it assembles the same graph from source, so it works as a pre-commit hook or a CI gate instead of something that needs a second terminal open. `--offline` forces that path. This is possible because `buildRpcEntry` only ever read a descriptor, never a runtime service; it moved to `src/manifestBuild.ts` so the dev boot and the offline path can't drift into producing different manifests for the same app.
202
+
203
+ Two noise sources found by running it against real apps and fixed: framework-owned tables (`_voltro_*` plus the auto-injected `actors` / `tenants`) are no longer reported as orphans — the app neither declared them nor can delete them, so the advice was unactionable — while STAYING in the graph, so a framework query reading `_voltro_undo_log` or a mutation writing `tenants` still resolves rather than reporting as a dangling reference. And `rbac/unguarded-mutation` now covers mutations only: `*.action.ts` is "non-transactional external I/O" per the primitive rubric, which is as often read-like as not, and including actions made every finding on a clean starter app a false positive.
204
+ - **@voltro/cli** — **The inspect surface is now authenticated by default, and the diagnostic commands can reach a deployed app.**
205
+
206
+ `voltro dev` mints a per-project `VOLTRO_INSPECT_TOKEN` (framework-owned, like the session secret) into `.env.local`. Previously the surface was open whenever the var was unset — and the dev server binds every interface, so any peer on the same network could read the app's DB rows, schema and log buffer, and POST to fire schedules or roll back migrations. Nothing needs to be configured by hand: the CLI reads the token from the runtime registry (so `voltro logs` works from any directory, not only the app's), and the dashboard's server-side proxy injects it for same-machine targets, so the browser never holds it. The registry file is now written 0600 since it carries tokens. An operator-set `VOLTRO_INSPECT_TOKEN` always wins.
207
+
208
+ `--url <base>` (plus `--token`, or `VOLTRO_INSPECT_URL` / `VOLTRO_INSPECT_TOKEN`) targets a **deployed** app from `voltro inspect`, `logs`, `traces`, `workflows`, `cluster` and `check`. Until now all six resolved targets exclusively from the local runtime registry, so the entire diagnostic toolchain went blind the moment an app left the machine.
209
+ - **@voltro/runtime** — **`crud.list` / `crud.count` gain `scope: (ctx) => ({ … })`** — the caller-derived WHERE, as opposed to `filter`'s request-derived one.
210
+
211
+ ```ts
212
+ crud.list('timeEntries', {
213
+ filter: (input) => ({ status: input.status }), // what the caller ASKED for
214
+ scope: (ctx) => ({ ownerId: ctx.request.subject.id }), // what it MAY SEE
215
+ })
216
+ ```
217
+
218
+ The gap this closes: tenant scope is applied automatically, but anything narrower — owner, team, role — was **not expressible at all**. Replacing a hand-written handler that carried such a narrowing with `crud.list` therefore widened the result set, silently and with no error anywhere. Reported by an app that lost exactly that across eight list views.
219
+
220
+ `scope` is merged LAST, so a request field of the same name cannot widen it (`?ownerId=someone-else` is overridden). Pass the same `scope` to `crud.count`, or the total contradicts the pages.
221
+
222
+ **Why a separate option instead of `filter(input, ctx)`.** Two reasons, and the second one is why the first isn't the whole story:
223
+
224
+ 1. Only one of the two is a security boundary. Kept apart, "does this list declare a `scope`?" is a question a reviewer — or a future boot audit — can ask. Folded into `filter`, it becomes "does this filter happen to read ctx somewhere in its body?", which nothing can check. 2. Adding a parameter to `filter` would have been BREAKING, not additive: a filter stored in a variable and invoked directly (`{ ...base(input), status: … }` — a plausible composition pattern) stops compiling on "Expected 2 arguments". The changelog gate caught that classification; this shape needs no codemod at all.
225
+
226
+ `apiSurface: compatible` — the one golden line that changed is `crud.count`'s options widening from `Pick<CrudListOptions, 'filter'>` to `'filter' | 'scope'`. A parameter type that accepts strictly MORE cannot break a caller: every value assignable to the old type is assignable to the new one, and both keys are optional.
227
+
228
+ The general lesson for any helper we ship that REPLACES hand-written code: the extension point is what keeps people ON the safe path. Hand-writing the query to obtain the narrowing also forfeits `serverOnly` stripping and the page-size clamp — so a missing hook doesn't merely inconvenience, it pushes users off the secure default at exactly the moment their requirements got stricter.
229
+ - **@voltro/cli** — **`voltro doctor` flags raw `fetch()` in server files.**
230
+
231
+ The SSRF guard shipped in 0.12.0 lives in the `HttpClient` handlers `yield*`. An app reported the consequence honestly: 36 raw `fetch` calls, **zero** `HttpClient` uses — no breaking change for them, and no protection either. A guard in a client nobody adopted protects nobody, and the apps that never adopted it are usually the ones that secured least elsewhere. The absence is worth naming rather than assuming the default did its job.
232
+
233
+ Their ask was a **boot** hint. This is in `doctor` instead, and the reason is structural: boot does not parse source. Doctor already runs a ts-morph pass, the cost is opt-in, and a false positive there is a line of output rather than a stopped server.
234
+
235
+ Server conventions only (`*.server.ts`, `*.cron.ts`, `*.subscribe.ts`, …). `fetch` is unremarkable in a browser component, and flagging it there would make the rule noise that gets scrolled past — taking the real findings with it. Real CALL expressions only, so the word in a comment, in a string, or inside `prefetch(` does not trip it; each of those is pinned by a test.
236
+
237
+ `RuleContext` gained the file's relative `path` to make this possible — every file is parsed under one in-memory name so a single ts-morph project can be reused, so a rule that needs to tell server code from client code had no other signal.
238
+ - **@voltro/runtime, @voltro/cli** — **The observed app graph — `voltro check` now reconciles declared intent against recorded behaviour.**
239
+
240
+ A query's `source` and a mutation's `targets` are not documentation: the framework routes optimistic patches and decides which subscriptions a write invalidates from them. A wrong declaration is a live, user-visible bug that nothing type-checks — the mutation succeeds, the write lands, and the wrong list fails to update.
241
+
242
+ `voltro dev` now records what each procedure ACTUALLY touched into `app.graph.observed.json` (gitignored automatically), and `check` diffs the two: undeclared reads/writes, a declared table never touched, a target whose `op` disagrees with what happened.
243
+
244
+ **Why recorded and not derived.** The tempting version parses the handlers. This repo already made and documented the opposite call once, for index auditing: the schema is constructed code, so a visitor cannot follow the builder pattern, and inspecting the realized value is both more correct and simpler. A handler is strictly more dynamic — shared `lib/` helpers, conditionals, computed table names — so a static pass has a long tail of both false positives and false negatives, and a check that is *sometimes* wrong is one people stop reading.
245
+
246
+ **`unexercised` is a distinct third state and never an error.** A procedure no test and no dev session ever ran has no observation, which is NOT "touches nothing". Reporting it as a mismatch would bury the real findings in any repo with partial coverage. Coverage is printed first for the same reason: three findings at 8% and three at 95% are different claims, and hiding the denominator is how a check starts overstating what it knows. Observed diagnostics are always warnings — `check`'s exit code gates CI, and an observation is evidence about the runs that happened, not a proof about the ones that didn't.
247
+
248
+ Recording is off unless enabled (`enableGraphObservation()`, or `VOLTRO_OBSERVE_GRAPH=1` for an out-of-process harness), so a production serve pays nothing. The recorder sits on the ONE `DataStore` beneath `wrapStoreWithMixinBehaviour` — instrumenting the middleware's ~20 public methods instead would double-count (`one`/`first`/`maybeOne` all delegate to `query`) and miss any method added later.
249
+
250
+ Three things this needed that a first cut would have missed, each pinned by a test: transactional writes go through a DIFFERENT store handle (every mutation's writes are transactional, so missing it would report the busiest procedures as touching nothing); queries are bound under a `subscription.` span prefix, which a naive parser rejects, silently dropping the majority of the surface; and concurrent requests need AsyncLocalStorage rather than a module-level "current tag", or two in-flight procedures attribute each other's tables.
251
+
252
+ Also fixed, from the same plan: the declared-scope registry now reaches the OFFLINE manifest. `rbac/unknown-scope` fires only when the app has a scope vocabulary, and the live inspect endpoint passed the plugins while the offline path — the CI gate the rule exists for — did not. So the rule worked next to a running `voltro dev` and did nothing in CI. The written `app.manifest.generated.json` had the same empty-registry gap.
253
+ - **@voltro/cli** — **`voltro doctor` now checks every literal predicate column against the table it filters.**
254
+
255
+ `eq` / `isNull` / `inSet` are free functions, so the column arrives as a bare `string` and the builder cannot relate it to the table the predicate is later attached to:
256
+
257
+ ```ts
258
+ database.teamAppointments.where(isNull('deletedAt'))
259
+ // ^ no softDelete() mixin, so no such column. tsc: OK.
260
+ ```
261
+
262
+ It type-checks, so review and CI pass, and it fails at runtime as a bare SQL error. A downstream cron failed on every recorded run this way and had to be diagnosed by bisecting which store debug lines were *absent*.
263
+
264
+ The check reports the table, the missing column, and the table's real columns, so the fix is in the message. Matching is on the AST rather than on text, so a column name in a comment or an unrelated string cannot trip it — the hand-rolled version of this check produced exactly that false positive. A call site whose table cannot be resolved is skipped silently: unlike the workflow audit, where an unreadable payload is a gap worth naming, an unresolvable receiver here is usually not a table at all.
265
+
266
+ **Why a check and not (yet) a type.** Binding the predicate to the row — `where(c => isNull(c.deletedAt))` as the only form — is the right end state. It is also 300+ call sites inside this repo alone plus every downstream app, and the codemod has to rewrite arbitrary predicate expressions; a half-migrated query API is worse than either end state. This check is the half that works **retroactively**, on code that already exists, which a type change never will. The precedent is `database/src/indexAudit.ts`, which checks a table's INDEX declarations against its columns for the same reason — the query path was simply the gap.
267
+ - **@voltro/cli, @voltro/plugin-rbac, @voltro/protocol, @voltro/mcp** — `voltro check` now catches a guard that requires a scope no role can grant. Such a handler is not merely misconfigured — it is permanently, silently uncallable: every caller fails the guard, forever, and no test finds it unless someone happens to exercise that exact procedure with the role that should have passed. The rule (`rbac/unknown-scope`) existed but was dead code, because nothing produced the app's declared scope vocabulary. `rbacPlugin({ roles })` already IS that vocabulary, so it now publishes the union of its role map via a new optional `declaredScopes` on `VoltroPlugin`, and the capability manifest exposes the union of every plugin's under `scopes`. The wildcard `'*'` is excluded — it is the admin bypass, not a scope name. Crucially, rbac publishes NOTHING when a custom `resolvePermissions` is configured: that resolver merges extra strings (per-row ACLs, feature flags) which by design live outside the role map, so a partial vocabulary would flag correct code. With one configured the check stays dormant, which is the honest outcome — a check that cries wolf gets ignored.
268
+ - **@voltro/runtime, @voltro/cli** — **`ctx.storeForTenant(tenantId)`** — the same store, scoped to one tenant.
269
+
270
+ For non-request work whose subject has no tenant: a schedule, a subscriber, a workflow step. There, reads see every tenant and a write to a `tenant()` table fails with `TenantScopeViolation`, so a per-tenant cron has to say which tenant it means. Every fan-out cron was literally a loop doing that by hand — and by hand, each `.where('tenantId', t.id)` and each explicit `tenantId:` is one forgotten call away from reading or writing across tenants.
271
+
272
+ ```ts
273
+ for (const t of await ctx.store.select('tenants').all()) {
274
+ const scoped = ctx.storeForTenant(t.id)
275
+ await scoped.insert('digests', { body: summary }) // tenantId stamped, not passed
276
+ }
277
+ ```
278
+
279
+ **The implementation detail worth stating, because it nearly shipped wrong.** The scoped view does NOT run as `{ ...systemSubject, tenantId }`. A `system` subject carries `tenantId: null` by construction *and* the tenant read-scope mixin special-cases `type === 'system'` to skip the tenant merge entirely — on a system subject a null tenant means "all tenants". Spreading a tenantId onto one produces a completely unscoped store that claims to be scoped to that tenant: worse than the problem this solves. The type system caught it; the fix is a `serviceAccount` subject, which belongs to exactly one tenant by construction, keeping the caller's scopes and identity. `tenantScopedSubject`'s return type is narrowed to that variant so it cannot regress silently, and a test pins the mechanism rather than only the outcome.
280
+
281
+ Assembled in the shared `makeAppContextBuilder`, so it carries the same mixin wrapper and the same row filter as `ctx.store` — a caller that RECEIVES a scoped store cannot forget the row filter, and one that builds its own can.
282
+
283
+ Inside a request this is almost always the wrong tool: the subject already carries a tenant, and reaching for another is a cross-tenant access with extra steps. It exists because the system subject has no tenant to infer.
284
+ - **@voltro/cli, @voltro/runtime, @voltro/database** — **`voltro doctor` now audits every `workflows.start(name, payload)` call site** against the workflows the app registers — unknown name, missing required fields, unknown fields.
285
+
286
+ I argued this was redundant once runtime validation landed, on the grounds that it only bought "~24 hours". That was wrong, and the correction is the interesting part: the argument silently assumed a DAILY cron. A weekly job is seven days per data point and a quarterly one is a quarter — and `voltro inspect schedules --failing` structurally cannot see a job that has never fired, because its roll-up is built from recorded runs. A downstream app found a weekly workflow broken since a port exactly this way, and the same scan *disproved* one of their own earlier bug reports, which is the better argument: a static check is how a report stops being anecdotal.
287
+
288
+ **`UNCHECKED` is never folded into a pass.** A payload built with a spread or a computed key has an unknowable key set; those sites are counted and listed rather than passed silently, because `0 issues` must not read as "all verified" — the same rule the observed app graph follows with `unexercised`. The workflow NAME is checked regardless of the payload, since a rename is decidable either way. Required keys come from the live `payloadSchema` via the same function the runtime validation uses, so the two cannot disagree.
289
+
290
+ In `doctor`, not at boot: `voltro dev` would pay a full ts-morph parse on every start, and a boot that refuses because it could not understand a spread is worse than the bug.
291
+
292
+ ---
293
+
294
+ **API-key usage accounting is now buffered, and gained `requestCount`.**
295
+
296
+ `resolveByHash` did `await store.patch(id, { lastUsedAt })` on **every** auth check — a synchronous row write in front of every key-authenticated request, for a value nobody reads with per-request precision. A downstream team had hand-rolled a fire-and-forget replacement to avoid paying it, then asked for `requestCount` noting they would not pay a synchronous write for that either. Both correct.
297
+
298
+ `makeApiKeyUsageBuffer` accumulates in memory and flushes on a timer (30s) or a pending-key threshold. A crash loses the current window: `lastUsedAt` may be one window stale and `requestCount` may undercount. That is the right trade for "is this key still in use, and roughly how much" — key revocation and runaway- integration questions — and the wrong one for billing, which is why the column doc says it must never become a billing input. Cross-replica flushes are not atomic, for the same reason and stated in the same place.
299
+
300
+ Wired in BOTH boot paths. A buffer in only one would have made the hot path — and the counter — differ between dev and production.
301
+
302
+ `requestCount` is a new column on `_voltro_api_keys`; framework tables ride the declarative differ on `voltro db apply` / boot, so no codemod is needed.
303
+
304
+ ### Changed
305
+
306
+ - **@voltro/cli** — `voltro migrate` now applies the schema through the **declarative differ** (it delegates to `voltro db apply`) instead of the create-only emitter. This is a correctness fix: the old behavior was `CREATE TABLE IF NOT EXISTS` with no diffing and no ALTERs, so adding a column, changing a type or adding an index reported success having applied **nothing** — and the declared schema silently drifted from the live database. It was the shortest, most guessable name in the schema family, the docs recommended it for CI/ops, and it was the one that didn't work. `voltro migrate --create-only` keeps the old emitter for the one case it suits: bootstrapping a brand-new database with nothing to diff against. No user code changes shape, so no codemod applies — but a pipeline that relied on `voltro migrate` NOT altering existing tables should switch to `--create-only`.
307
+
308
+ ### Fixed
309
+
310
+ - **@voltro/cli, @voltro/data-transfer** — The admin-import **storage pull** (`POST /_voltro/admin/import` with `{ bundleKey }`) can no longer wedge a request forever, and no longer leaks the provider's stream. Both were unbounded before: a storage backend that goes silent hands back a body that emits neither data, nor `end`, nor `error` — so the archive parser's read never settled and the import held its temp dir, its socket and, with `x-import-atomic`, an **open transaction** until the process died. Nothing downstream could rescue it (the read was a raw promise, hence uninterruptible, and undici's 300 s `headersTimeout` outlasts most callers). The read is now bounded at both phases — resolving the object, and a per-**chunk** idle budget during the transfer, so an arbitrarily large bundle still streams for as long as it likes provided bytes keep arriving; only silence is fatal — and a trip fails with a named error instead of hanging. Separately, `unpackBundle` now always releases its source iterator: every exit left it suspended (the `KIND_END` return is the NORMAL path, plus every `throw`), so the producer's own cleanup never ran and a storage-backed import leaked the provider's body stream / fd — on s3, a pooled socket — once per import. Only the memory and filesystem providers always settle, which is why no test caught either.
311
+ - **@voltro/cli** — `voltro help` is readable again: commands are grouped by job (Start a project / Develop / Database & data / Build & run / Deploy / Observe & debug / Maintain / Meta) and each line shows one short lead sentence — the previous flat list printed 45 entries in registry order with summaries up to 351 characters, so every line wrapped. An unknown command now suggests near misses (`voltro mgirate` → "Did you mean? voltro migrate"), and `voltro help <command>` shows that command's help instead of the whole list. Fixes a regression from the uniform `--help`: 9 commands (cloud, workflows, cluster, traces, static, serverless, package, baseline, update) implement their own richer usage text, which the dispatcher was swallowing. Also fixes `voltro secret generate --bytes=64` silently minting a 32-byte key (the `=` form read as "flag absent"), and `bin.ts` losing a non-zero exit code while draining stdout.
312
+ - **@voltro/cli, @voltro/database** — Security follow-ups found by a second review pass. The dashboard proxy's `?target=` guard was only applied to `voltro start` — the `voltro dev` twin (`webDev.ts`, the path developers actually run) was still an open SSRF that forwarded the caller's bearer to any host. Both paths now share one allowlist: same-machine (loopback) **or** an origin the operator configured in `VOLTRO_DASHBOARD_APPS` — which also un-breaks the documented remote-dashboard feature the first loopback-only fix had disabled. All four proxy fetches now use `redirect: 'manual'`, so a loopback target with an open redirect can't bounce the proxy to an arbitrary host. Destructive inspect writes (`/schedules/:name/fire`, `/migrations/rollback`, `/seeds/run`, workflow start/cancel, `/data/rows` writes) gained a CSRF guard: a state-changing request carrying a non-loopback `Origin` is rejected — CORS only stops a hostile page *reading* the response, never *sending* a form POST. Minted secrets (`.env.local`) and `voltro cloud env pull`'s `.env.cloud` are written 0600, and `env pull` warns when the file isn't gitignored. `SqlClient` is now re-exported from `@voltro/database` so a hand-written `*.migration.ts` can import it at all (apps don't depend on `@effect/sql`; the runner only warned on the failed import, so the migration was silently skipped).
313
+ - **@voltro/cli** — `voltro test` now accepts filters and flags instead of ignoring them. Previously the first positional was always used as vitest's *root*, so `voltro test src/foo.test.ts` pointed the root at a file, matched zero tests, and — with `passWithNoTests` — **exited 0**: a user or CI running one test file got a green result having executed nothing. Now a positional that is an existing directory selects the root and anything else is a name/path filter, `-t` / `--testNamePattern` and `--watch` are forwarded, and an explicit filter that matches no test fails (exit 1) rather than passing vacuously. A bare `voltro test` in a project with no tests yet still passes.
314
+ - **@voltro/cli** — **`/_voltro/inspect/metrics` reported an empty rpc surface under `voltro dev`.** The endpoint read `snapshotMetricsSync()` — @voltro/runtime's process-global metric registry — while every rpc sample is recorded into the per-boot `metricsCollector` the mutation / action / query runners are wired with (`recordMetric`). Nothing in the serve pipeline calls `recordSample`, so the two stores were disjoint and the read side was always empty. The dashboard's metrics panel therefore showed no query/mutation/action activity at all in dev.
315
+
316
+ Worth naming because a comment asserted the opposite. The shared mutation runner carries: *"Used by BOTH the rpc WS handler and the `/_voltro/inspect/invoke` endpoint so they observe identical behaviour: same transactional wrap, same plugin interception, **same metrics recording**. Skipping any of those on one path means a mutation triggered via the dashboard would silently bypass audit logging."* The write half of that was true; the read half made it unobservable.
317
+
318
+ Found by `smokePluginAudit`, which asserts the `mutation.todos.create` bucket grows by two after two invocations and observed zero — a smoke that had not run in a long time because the harness around it was broken.
319
+ - **@voltro/runtime, @voltro/cli** — **`defineExecutor` returns the handler's own type instead of widening it.** It returned `(input, ctx) => ExecutorReturn<D, E, R>` — the four-arm union — so an Effect-returning executor came back typed as the union and `Effect.runPromise(execute(input, ctx))` stopped compiling. The wrapper pinned the success value and widened the return type in the same stroke: for the caller, net negative. A team measured it honestly — 164 executors wrapped, 918 errors, zero output drift, 465 test files broken — and declined to adopt for that reason, not out of convenience. They were right.
320
+
321
+ Only the RETURN type is threaded through, not the whole function type: returning `F` outright would inherit its ARITY too, so an executor written as `(input) => …` could no longer be called with `(input, ctx)` — trading one papercut for another. The constraint still does the checking; a wrong output shape still fails, pinned by the existing `@ts-expect-error` cases. No overloads, which would have to enumerate the four arms and drift the moment a fifth appears.
322
+
323
+ ---
324
+
325
+ **The `.serverOnly()` audit accepts a field typed `Schema.Null`.** A view that keeps a server-only column's KEY for wire compatibility — an importer breaks if it vanishes — while always emitting `null`, with the value behind an admin-gated route and a `hasX` boolean beside it, is a real pattern. The audit rejected it, leaving two remedies: break the importer, or drop the marker. A check whose only remedy is to disable it gets disabled.
326
+
327
+ The exemption costs something on purpose: the field must be DECLARED null, so the schema stops claiming a string it never sends. `NullOr(String)` still fails, and should — that can carry a value, and the next edit to the handler is one line from sending one. The message now names all three ways out and states plainly that "my handler always sets it to null" is not one of them, since the audit reads the declared shape rather than the emitted value.
328
+
329
+ ---
330
+
331
+ **The generated agent guide now carries a "What's new in `<version>`" module**, listed first in its index.
332
+
333
+ Reported twice by two different teams, the second time as the request that "amplifies everything else": a feature ships, the docs get a page, and nobody finds it. One team discovered `.serverOnly()` only by **diffing two `.d.ts` files** — it was not in the update text and not in the guide. The feature existed; the path to it did not.
334
+
335
+ This is the highest-leverage place to fix that for a behavioural reason rather than an editorial one: agents read the seeded guide on every task and a docs page approximately never, so the section rides along with something already being read. Sourced from the most recent RELEASED changelog section, not the unreleased staging area — that is empty right after a tag, which is exactly when someone installs the version and asks what changed.
336
+ - **@voltro/plugin-flags, @voltro/plugin-webhooks** — **`requireFlag(ctx, …)` / `isFlagEnabled(ctx, …)` now actually accept an `AppContext`.** Their `GuardCtx` doc comment promised "the framework `AppContext` (`ctx.request.subject`)" and the type rejected it: `FlagSubject`'s optionals were written `id?: string | null`, which under `exactOptionalPropertyTypes` means *absent or string-or-null* and refuses an explicit `undefined` — exactly what a real `Subject`'s `?: T | undefined` fields are. `metadata` had a second problem, a mutable `Record<string, unknown>` where `Subject.metadata` carries a readonly index signature. Every optional now spells `| undefined`, and `metadata` uses a readonly index signature, so the loose-supertype intent holds instead of being an aspiration.
337
+
338
+ **`webhookTables()` returns a concrete tuple instead of `ReadonlyArray<TableLike>`.** Two widenings stacked into one unusable return value: the array type made the documented `const [targets, deliveries] = webhookTables()` yield `TableLike | undefined` under `noUncheckedIndexedAccess`, and the `: TableLike` annotations on the three table constants erased their real types so `databaseHandle` rejected them outright. Feeding either into a handle poisoned the inferred types of the app's OWN tables alongside them — the failure surfaced as `database.orders is possibly 'undefined'` in files that never touched webhooks. The annotations are dropped (inference already had the right answer) and the return is `as const`.
339
+
340
+ Both were found by type-checking the shipped templates rather than by reading the source: `api-feature-flags` and `api-webhooks` had been failing `tsc` while their own test suites passed, because `voltro test` transpiles without checking. `voltro-templates` now runs both, so this class cannot accumulate silently again.
341
+ - **@voltro/plugin-rbac, @voltro/protocol** — **An rbac resolver that throws SYNCHRONOUSLY no longer escapes its error handling.** `rbacPlugin`'s `resolveRoles`, `resolvePermissions` and `resolveResourceRoles` were invoked eagerly, *outside* the Effect — so only a rejected Promise ever reached the `catchAllCause` around them. A synchronous throw (`subject.metadata.roles.map(…)` on a null, a destructure of a missing field, a bad argument to a membership lookup) blew straight past it, and both of the plugin's documented error postures were wrong in that case: the interceptor's "degrade to the subject's own scopes" became a hard request failure, and the resource resolver's "fail-closed → deny" became an opaque defect instead of a typed `ScopeError`. The resolvers now run inside `Effect.suspend`, so a synchronous throw becomes a defect the cause handlers catch — the degrade and the denial both behave as documented. Neither case ever *granted* access; the impact was availability plus a denial that crossed the wire untyped, so a client branching on `ScopeError` saw an unhandled error instead of a refusal.
342
+
343
+ **`checkGuardsEffect` now fails closed for every way a resource-scope or policy resolver can misbehave.** It caught only typed failures (`Effect.catchAll`), so a resolver that threw or died escaped as a defect and surfaced as a 500 rather than the typed `ScopeError` the function promises. It now suspends the resolver call and catches the whole cause. This is defence in depth for the fix above and applies to any resolver registered directly via `setResourceScopeResolver` / `setPolicyGuardResolver`, not just rbac's.
344
+
345
+ Covered by the new `enforcement.test.ts` in `@voltro/plugin-rbac` — the first tests to install the real `rbacPlugin` and drive calls through the real interceptor + guard chain — plus three fail-closed cases in `@voltro/protocol`'s `scopes.test.ts`.
346
+
347
+ codemod: none
348
+ - **@voltro/runtime** — **A failing schedule now reports the DRIVER's error, not `@effect/sql`'s wrapper constant.** Every query failure arrives as a `SqlError` whose message is the useless string `Failed to execute statement`; the actual reason — MariaDB's `Unknown column 'deletedAt' in 'where clause'`, a pg SQLSTATE, an FK constraint name — sits on the driver error a few `cause` levels down. `extractDbCause` already pulls it, and the rpc path and the store's tenant-FK guard already used it. **The schedule path did not.**
349
+
350
+ So a cron that failed on a bad column logged exactly `Failed to execute statement` with no table and no column — on the one surface with nobody watching. The reporting app had to diagnose it by bisecting which store debug lines were *absent* before the failure.
351
+
352
+ Both halves are fixed: the error log gains `dbMessage` / `dbErrno` / `dbCode` / constraint fields, and the message RECORDED in `_voltro_schedule_runs` appends the driver detail — that row is what `voltro inspect schedules --failing` prints, so without it that diagnostic was useless for exactly the failures it exists to surface. A non-DB failure gains no invented fields.
353
+ - **@voltro/plugin-storage, @voltro/cli, @voltro/runtime** — **Admin export/import now uses the storage backend the app actually configured.** The serve path resolved storage with `resolveStorageProvider({})`, which builds the ENV DEFAULT and cannot see the options passed to `storagePlugin(...)`. An app running on `storagePlugin({ provider: s3(…) })` — or any custom `name` / `bucket` / `root` — therefore had its admin export and import silently reading and writing a *different* backend than the rest of the app, surfacing much later as a "no object at key" error. `storagePlugin` now publishes its resolved provider, and the new **`appStorageProvider()`** returns it (falling back to the env default when no storage plugin is installed) — use that, not `resolveStorageProvider({})`, anywhere outside the plugin that needs "the storage this app actually uses".
354
+
355
+ **`ServeApiHandle.close()` now releases the rpc/ws layer, not just the socket.** `startRpcServer` handed its launch to `NodeRuntime.runMain`, which registers a fresh SIGINT/SIGTERM listener per call and returns no handle — so every boot leaked a live fiber and its scope, and `close()` was quietly untrue about what it freed. It now forks the launch, returns a `shutdown` alongside the server, and wires the process signals **once** instead of once per boot (verified: twelve boots leave exactly one listener each, where the eleventh previously tripped node's `MaxListenersExceededWarning`). Signal behaviour is deliberately unchanged — a signal still interrupts the launch so finalizers run and in-flight requests finish, then exits. Production boots one server per process, so the leak was invisible there; it is the embedded / multi-boot cases and the API's honesty that this fixes.
356
+ - **@voltro/protocol, @voltro/cli, @voltro/testing** — **`ctx.storeForTenant(id)` now exists under test, so handlers that use it can be tested at all.** It was added to `AppContext` — and therefore made required on `TestContext` — without reaching the test harness, so `@voltro/testing` did not type-check and `makeTestContext()` returned a context missing the field. Any cron, workflow step or backfill reaching for the scoped view had no route to a unit test.
357
+
358
+ **`tenantScopedSubject` moved from `@voltro/cli` to `@voltro/protocol`**, next to `anonymousSubject` / `systemSubject`. The harness could not import it from the CLI, and the alternative — re-deriving "the same" subject in a second place — is precisely the shape of the worst dev/serve drift this repo has hit: two internally-consistent constructions that disagree, where a schedule read ONE tenant under `voltro dev` and EVERY tenant under `voltro serve`. Both the serve context builder and the harness now call the one function, so the scoped store a handler sees under test is the store it gets in production.
359
+
360
+ Covered by four cases asserting the scoping itself rather than the field's presence: a write is stamped with that tenant, reads see only that tenant, the tenant-less `ctx.store` still sees across tenants (which is *why* the scoped view exists), and two views for the same tenant agree.
361
+ - **@voltro/cli** — **`voltro test` forwards every vitest flag.** It built an options object from the four things it understood itself — root, filter, `--watch`, `-t` — and silently dropped the rest. So `--coverage`, `--reporter=junit` and `--outputFile` were no-ops: a team had no coverage number and no JUnit report in their merge-request widget, with nothing to say why. The exit code still worked, so CI kept blocking correctly, which is exactly why it went unnoticed.
362
+
363
+ Flags now go through **vitest's own `parseCLI`**, not a list this wrapper maintains — a hand-kept allow-list would go stale the next time vitest adds a flag, which is the same bug again on a delay.
364
+
365
+ Two details found by testing against the real parser rather than a stub:
366
+
367
+ - Parsing an *empty* argv is not empty — vitest fills in `--`, `color` and `run`. Forwarding those would hand `startVitest` values the user never asked for, so the parsed options are diffed against that baseline. This also keeps the allow-list-free property: nothing needs to know which keys are "real". - `--reporter=junit` produces `reporter: ['junit']`, not `reporters`. Worth knowing if you assert on it.
368
+
369
+ The framework keeps three decisions and they win over a forwarded flag: the **root** (a positional that is an existing directory, which vitest would read as a filter), `--watch`, and `passWithNoTests` — "an explicit filter matching no file is an error" is a judgement vitest cannot make, because it does not know which positional was treated as a root.
370
+
371
+ An unrecognised flag stays ignored rather than becoming fatal — that is a separate decision from making the recognised ones work — and a parse failure degrades to running without the extra flags, with a warning, instead of taking the run down.
372
+ - **@voltro/runtime, @voltro/cli, @voltro/database** — **A workflow executor can now reach plugin services, `apiConfig.layers`, and `SubjectService`.** It could not, which made the primitive built for long-running EXTERNAL I/O the one primitive that could not use the framework's mechanism for external I/O: `yield* SomePluginService` inside a workflow died with `Service not found`. Analytics, cache, kv, every plugin's `services`, and the app's own `layers:` were provided on the rpc/handler path only, in BOTH boot paths. Reported from an app where 7 of 12 workflows had been failing in production for months.
373
+
374
+ The split matters and is now explicit:
375
+
376
+ - **Process-wide** services (plugin `services`, `layers:`, cache, kv, analytics, HttpClient) merge into the workflow `ManagedRuntime` — same set a handler gets. - **Per-execution** services (`SubjectService`, `EffectStore`) are provided per run in `makeWorkflowLayers`. Merging a subject into the runtime would be *wrong*, not merely untidy: that runtime is built ONCE at boot, so every execution would act as whoever ran first.
377
+
378
+ `SubjectService` is why a plugin service that resolves the CALLER's credential (an OAuth token, a per-user PAT) worked in a handler and failed in a workflow — `ctx.request.subject` carried the value the whole time; nothing provided the Tag.
379
+
380
+ ---
381
+
382
+ **`voltro inspect workflows --failing`** — the same roll-up and the same exit-1 semantics as `schedules --failing`.
383
+
384
+ It needed to be separate because the two failures are separate facts that look identical from outside: a cron whose only job is to START a workflow **succeeds** the moment the start returns. `_voltro_schedule_runs.status` is `succeeded`, `schedules --failing` reports green, the boot banner is clean — and the workflow fails one table over. That is precisely how the months of breakage above stayed invisible: every operational surface said fine.
385
+
386
+ `running` / `suspended` are in-flight, not verdicts — they neither count as failures nor end a streak, or one long execution would mask one. `cancelled` is a human decision, likewise not a verdict.
387
+
388
+ ---
389
+
390
+ **A predicate value is coerced to its column's type.** `eq('ceremonySummaryDate', epochMs)` against a `timestamp()` column compiled to `WHERE date = 1767916800000` and matched **zero rows** — no error, no warning. Verified both ways against a real row: epoch-ms → 0 rows, `Date` → 2. The write path has always encoded (`insertRow` takes a `Date`), so the asymmetry was the trap: a value shaped for the wire reads as *"no data"* rather than as a mistake, and the UI renders an empty state everybody believes.
391
+
392
+ Deliberately narrow — epoch-ms / ISO-string → `Date` for a temporal column, and nothing else. No string→number, no truthy→boolean: a silent numeric coercion would paper over a genuine type confusion, whereas here the value *meant* the right thing and was merely wire-shaped. Applied to equality only (a range comparison on a wire number is at least visibly wrong), never inside a JSON path (no column type to consult), and an unregistered table coerces nothing.
393
+
394
+ Same root as the predicate-column audit: predicates are not bound to their table. Binding `where(...)` to the row type makes both a compile error and remains the right end state; this fixes existing code today, which a type change never will.
395
+
396
+ ---
397
+
42
398
  ## [0.11.4] — 2026-07-25
43
399
 
44
400
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/plugin-atlassian",
3
- "version": "0.11.4",
3
+ "version": "0.13.0",
4
4
  "description": "Jira + Confluence plugin — JiraService + ConfluenceService over the Atlassian REST/Greenhopper/Agile APIs, with a pluggable per-subject credentials resolver (PAT), transient retry + Retry-After, timeouts, an SSRF-guarded PAT-free avatar proxy, and optional response caching via @voltro/cache.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -42,8 +42,8 @@
42
42
  "node": ">=24.0.0"
43
43
  },
44
44
  "dependencies": {
45
- "@voltro/integration-http": "0.11.4",
46
- "@voltro/protocol": "0.11.4"
45
+ "@voltro/integration-http": "0.13.0",
46
+ "@voltro/protocol": "0.13.0"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "effect": "^3.21.4"