@voltro/plugin-audit 0.21.0 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +316 -0
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,322 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## [0.22.1] — 2026-08-01
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- **@voltro/cli** — **Two things a QUERY could not do that a mutation beside it could — and a redirect that answered 500 in dev and 303 in production.**
|
|
47
|
+
|
|
48
|
+
### `useAggregate` in a subscription handler: `Service not found`
|
|
49
|
+
|
|
50
|
+
The documented way to read an aggregate from a handler (`useAggregate(def).read(...)`) failed at runtime with `Service not found: @voltro/AggregateRegistry`, on every delivery.
|
|
51
|
+
|
|
52
|
+
Both boot paths hand the subscription/query executor a `provideEffect` callback, and each had written its own — smaller — layer set:
|
|
53
|
+
|
|
54
|
+
| path | provided to an Effect-returning query | |---|---| | `voltro dev` | store + actionBase (**no** `mergedUserLayer`) | | `voltro serve` | store, and nothing else | | either, handler path | the full set |
|
|
55
|
+
|
|
56
|
+
So a query could not `yield*` the aggregate registry, the cache, the kv store, a plugin's service or the app's own `layers:` — while a mutation in the same app could. `useAggregate` was one symptom of the set being wrong, not a bug of its own.
|
|
57
|
+
|
|
58
|
+
The cost the reporter measured is worth repeating: a subscription whose delivery fails renders NOTHING, so their working-time card showed `00:00` — no error, no empty state. Silence is the worst failure shape a data path has.
|
|
59
|
+
|
|
60
|
+
Both paths now provide the same set, and `serveApi` reaches it through the single helper its handler path already used (it had two, one complete and one not).
|
|
61
|
+
|
|
62
|
+
**Why their tests could not see it**, in their words: `@voltro/testing` supplies `aggregateRegistryLayer(...)` and the existing example uses it, so the suite provided exactly what production lacked — *"wenn der Layer im Test nötig ist und in der Laufzeit fehlt, ist er genau der falsche Default."* That is the sharpest line in the report. A harness that hands the code under test something the runtime does not is a second implementation, not a harness. The runtime supplies it now; the layer stays available for tests that genuinely stand alone.
|
|
63
|
+
|
|
64
|
+
### A loader that throws `RedirectError` answers 303 in dev too
|
|
65
|
+
|
|
66
|
+
`agent-docs/routing.md` promises a 303 with `Location`. `voltro start` did it; `voltro dev` caught the same throw as a render failure and answered **500 with no `Location`**.
|
|
67
|
+
|
|
68
|
+
The divergence was written down as intended — *"each path maps it onto its own convention"* — and recording it is what let it stand. A redirect is CONTROL FLOW. Two renderers that disagree about that disagree about what the app does, and the one they disagreed on is the one every developer and every dev-environment probe hits first. It cost the reporter a rollout: a readiness probe walked a redirecting route, got the 500, and the deployment never became ready.
|
|
69
|
+
|
|
70
|
+
One mapper (`loaderControlResponse`) now answers for both, brand-checked rather than `instanceof` because the error crosses a bundle boundary — and since the CLI cannot import `@voltro/web`, a test reads that package's source so a renamed brand cannot silently put dev back to 500.
|
|
71
|
+
|
|
72
|
+
`NotFoundError` → 404 comes with it, for the same reason.
|
|
73
|
+
- **@voltro/client, @voltro/cli** — **A hot reload no longer takes every page down, and a route that owns the whole origin says so.**
|
|
74
|
+
|
|
75
|
+
### `defineStore` and module re-evaluation
|
|
76
|
+
|
|
77
|
+
The duplicate-name guard keyed on the NAME alone and threw. It is right about the danger — two stores sharing a name silently share state — and wrong about one case: a module RE-EVALUATING, which is exactly what a hot reload does.
|
|
78
|
+
|
|
79
|
+
A consumer measured the cost on one running dev server:
|
|
80
|
+
|
|
81
|
+
| | status | bytes | |---|---|---| | fresh boot | 200 | 43629 | | touch any `*.store.ts` | 500 | 2328 | | three further requests | 500 | — |
|
|
82
|
+
|
|
83
|
+
One edit to a store — or to anything importing one — took all 225 of their pages down for the rest of the session, with no self-recovery. Their workaround memoised registrations by name on `globalThis`, which works and costs the thing HMR is for: editing a store's initial state stopped taking effect until a restart.
|
|
84
|
+
|
|
85
|
+
Registration is keyed by ORIGIN **and evaluation PASS** now — the calling module's stack frame, without `line:column` so that shifting the call down a line is still the same origin. The same module may redefine its own store and gets the LIVE handle back, so state survives the edit. A DIFFERENT module still throws, and the error now names both files, because "rename one" is only actionable if you know which two to look at.
|
|
86
|
+
|
|
87
|
+
Origin alone was not enough, and the existing suite caught it: two `defineStore('x')` calls in ONE file share an origin, so origin-keying merged them — the exact silent state-sharing the guard exists for. A module body runs synchronously, so two registrations in one file land in the same pass; a hot reload re-evaluates in a LATER tick. Same origin AND same pass is a collision; same origin, later pass is a reload.
|
|
88
|
+
|
|
89
|
+
(`line:column` would separate those two as well, and was rejected: stripping the position is what lets an edit ABOVE a `defineStore` call shift it down a line without reading as a new origin — and that edit is the common case this is about.)
|
|
90
|
+
|
|
91
|
+
When the runtime gives no usable stack, it behaves like a collision rather than a redefinition: if the two cannot be told apart, silently sharing state is the worse outcome, and that is what the guard exists for.
|
|
92
|
+
|
|
93
|
+
### A route whose first segment is dynamic
|
|
94
|
+
|
|
95
|
+
`voltro doctor` reports a page route like `[id]/[playerCode]` — its FIRST segment dynamic, so it answers every two-segment URL on that origin, `/api/health` included.
|
|
96
|
+
|
|
97
|
+
The pattern is not a bug; that is what it means. It is invisible until something requests such a URL, and then the page renders, its loader runs, and the failure reads as an application error rather than as a route claiming a path nobody meant it to. Advisory, and scoped so it stays readable: `orders/[id]` is not flagged — a dynamic segment under a literal one is bounded by that literal.
|
|
98
|
+
|
|
99
|
+
The same report described this as REST routes losing to page segments. They do not compete: `restRoutes` are served by the API server and pages by the web server, on different ports. The three-segment probe paths they adopted worked because the pattern is two-segment, not because precedence changed.
|
|
100
|
+
|
|
101
|
+
### Two items from the same report were already shipped
|
|
102
|
+
|
|
103
|
+
Both were measured against 0.20.1 and landed in **0.21.0**, so they need no change — only saying so:
|
|
104
|
+
|
|
105
|
+
- `Could not resolve "@voltro/cli/startEntry"` during the start-bundle build → fixed by `resolve @voltro/cli/{start,serve}Entry from the CLI, not the app root`. It is the same class as the `tsx` bare-specifier bug: a specifier for a package the APP never declared is invisible from the app root under strict pnpm. - `voltro schedule run <name>` exists, with `--process`, `--trigger manual|external` and `--url`. Note it POSTs to a mutating inspect endpoint, so it now needs `VOLTRO_INSPECT_WRITE_TOKEN` outside dev.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## [0.22.0] — 2026-08-01
|
|
110
|
+
|
|
111
|
+
### ⚠ BREAKING
|
|
112
|
+
|
|
113
|
+
- **@voltro/protocol, @voltro/runtime, @voltro/cli** — **`cache: { scope: 'tenant' }` — one entry per org, none shared across orgs.**
|
|
114
|
+
|
|
115
|
+
BREAKING only in the "more precise is still breaking" sense: the union `'subject' | 'global'` gained a member. Every scope you already wrote still compiles; what can stop compiling is code that consumes the union EXHAUSTIVELY (a `switch` with an `assertNever`, a `Record<QueryCacheScope, …>`). Almost always framework-internal rather than app code — the codemod is a `manual` note so the compile error is recognised rather than debugged, because a transform cannot tell a switch that wants a `tenant` branch from one whose author should look at the query and decide.
|
|
116
|
+
|
|
117
|
+
`scope` took `'subject' | 'global'`, and for an org-wide figure neither fits. `'subject'` recomputes it per PERSON; `'global'` shares one entry across every caller. A consumer put it exactly:
|
|
118
|
+
|
|
119
|
+
> `'subject'` rechnet pro Person neu … bei 18 Mitarbeitern also bis zu 18 > identische Berechnungen derselben Zahlen. `'global'` würde über > Mandantengrenzen hinweg teilen. Für Daten, die aus `subject.tenantId` > abgeleitet sind, ist das kein Cache, sondern ein Leck.
|
|
120
|
+
|
|
121
|
+
They took the 18 computations of a nine-table statistic rather than write the leak. That was the right call, and it should not have been a call.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
export const last12Months = defineQuery({
|
|
125
|
+
name: 'globalStatistics.last12Months',
|
|
126
|
+
input: Schema.Struct({}),
|
|
127
|
+
output: Stats,
|
|
128
|
+
source: ['invoices', 'employees'],
|
|
129
|
+
cache: { ttl: '5m', scope: 'tenant' },
|
|
130
|
+
})
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Rubric, now three-way: does the resolved predicate depend on the caller? On the PERSON → `subject`; on their ORG only → `tenant`; on neither → `global`.
|
|
134
|
+
|
|
135
|
+
**A caller with no tenant BYPASSES a `'tenant'` cache** rather than falling back. Falling back to `global` is the leak the option exists to avoid; falling back to `subject` silently turns a cache the author sized per org into one sized per person. Note this covers `tenantId: null` as well as absent — an anonymous subject's `tenantId` is `string | null`, so `null` is the shape that actually arrives, and treating only `undefined` as absent would key every tenantless caller under one literal `:t:null` entry.
|
|
136
|
+
|
|
137
|
+
**And `scope: 'global'` over a `tenant()`-scoped table is now reported.** The option alone would have left the leak one word away, in a field whose two legal values differ by one word. `cacheScopeLeaks` runs in the discovery both boot paths share — so dev and serve cannot disagree about what a leak is — and rides the same gate as the `.serverOnly()` audit: `voltro dev` warns, `VOLTRO_SERVER_ONLY=strict` refuses. It names every offending table rather than the first, is silent for `'global'` on reference data (the case the option exists for), and is silent for a query with no declared `source`, where it has nothing to reason about and a guess would be a warning nobody can act on.
|
|
138
|
+
|
|
139
|
+
**Not a replacement for modelling.** The same consumer moved that statistic to a `defineAggregate` with `tenantId` as an indexed column, which puts the tenant boundary in the DATA rather than in a cache key — better for a rollup, and they say so. `scope: 'tenant'` is for the other case they name: a query that must be FRESH and is merely expensive, where an aggregate's refresh interval is the wrong instrument.
|
|
140
|
+
- **@voltro/cli** — **A destructive inspect endpoint now needs its own credential. One token for "list my routes" and "erase this person" was one token too few.**
|
|
141
|
+
|
|
142
|
+
`/_voltro/inspect/*` is not read-only. Plugins mount POSTs on it that DO things: `plugin-governance`'s `/erase` is an irreversible GDPR right-to-be-forgotten deletion and `/export` a full personal-data dump; `plugin-storage` mints and revokes object access. Every one of them sat behind the same bearer as reading a route list, so anything that could read the sitemap could erase a person.
|
|
143
|
+
|
|
144
|
+
An earlier change in this series made plugins DECLARE `inspect:write` for a mutating endpoint, and shipped with the note that this "governs what a plugin may mount, not who may call it". That was a footnote under a GDPR erasure endpoint, not a fix. This is the half that closes it.
|
|
145
|
+
|
|
146
|
+
**A mutating method requires `VOLTRO_INSPECT_WRITE_TOKEN`, sent as the `x-voltro-inspect-write` header ON TOP of the bearer** — an additional factor, not an alternative credential: the read token still has to be correct to get there. GET / HEAD / OPTIONS are unaffected. Unset → refused, with the same posture as the read token: *the absence of a secret is not consent.*
|
|
147
|
+
|
|
148
|
+
**`voltro dev` mints it** per project, exactly like the read token, and the dashboard proxy injects it for loopback targets under the same three conditions the bearer already had (loopback only, never over a caller's own header, only when a value exists). So the dev loop is unchanged and no developer handles a secret. **Nothing mints it for `serve` / `start` / a bare harness** — in production a destructive endpoint should take a deliberate act to enable.
|
|
149
|
+
|
|
150
|
+
**The design decision worth knowing.** `InspectAuthResolver` gained a REQUIRED `method` parameter. The alternatives were both worse: optional-and-skipped is fail-open at exactly the call site most likely to be added carelessly, and optional-and-refused makes the exported resolver hostile to every legitimate read caller — which is how a security default gets replaced with a custom resolver that does less. A required parameter puts the check in the compiler, and it earned that immediately: `tsc` named **eight** more mount points than the four found by hand, including `pluginInspect.ts` (where the destructive plugin POSTs actually live) and `start.ts` (production).
|
|
151
|
+
|
|
152
|
+
BREAKING for a caller of `envTokenAuthResolver` / a custom `InspectAuthResolver` CALL SITE — the second argument is required. A resolver IMPLEMENTATION is unaffected: `(headers) => …` still satisfies the type. The codemod is a `manual` note; a transform cannot know whether a given mount is a read or a write, and guessing on this surface is how the boundary would be lost again.
|
|
153
|
+
- **@voltro/database, @voltro/cli, @voltro/devtools-ui** — **An index whose name changed is now renamed, not rebuilt.** The planner gained a `rename-index` operation; where it applies, `voltro db apply` emits one catalog-only statement instead of `DROP INDEX` + `CREATE INDEX`.
|
|
154
|
+
|
|
155
|
+
The cost this removes is not hypothetical. Auto-named indexes are `<table>_<column>_idx`, and **no dialect renames an index when the column under it is renamed** — verified on postgres 18, MySQL 8.4 and MariaDB 11.8. So every `.renamedFrom()` column rename, itself a metadata-only operation, dragged a full B-tree rebuild of that column's indexes behind it: on a large table, minutes of IO and — without `CONCURRENTLY` — a write lock. The same probe confirms the replacement is free: postgres reports an unchanged `relfilenode` across the rename, which is the definition of "no rebuild happened".
|
|
156
|
+
|
|
157
|
+
It is deliberately narrow, because the cases left out are the ones that cannot be made safe by inspection. **sqlite** has no rename statement, so it keeps drop + create rather than have the plan disagree with what runs. **UNIQUE** indexes are constraint objects whose rename syntax diverges by dialect. **Expression** indexes have no comparable key text (the DB normalises it). And **two same-shaped indexes renamed at once** is ambiguous — nothing says which became which, so both rebuild. A rebuild is slow; renaming the wrong catalog object is worse.
|
|
158
|
+
|
|
159
|
+
**Why this is BREAKING for a purely additive change.** Widening a union that the framework PRODUCES breaks every exhaustive `switch` a consumer wrote over it. That is not a theoretical reading — it broke one inside this repo, which is how the second half of this entry was found.
|
|
160
|
+
|
|
161
|
+
**Also fixed: the dashboard could not render two operation kinds.** `add-unique-composite` and `drop-unique-composite` were missing from `@voltro/devtools-ui`'s hand-written `OperationKind`, from its `formatOp` switch, and from the CLI's inspect payload — for as long as those ops have existed. Nothing crashed; the row just rendered blank, which reads exactly like a plan that has no such step. The CLI's copy of the union is now DERIVED from the planner's, the renderer's switch is exhaustive by construction (no `default:` arm — that would swallow the next one), and a parity test in `@voltro/database` fails on any divergence in the one copy that genuinely cannot be derived.
|
|
162
|
+
- **@voltro/database, @voltro/plugin-notifications, @voltro/plugin-scim, @voltro/plugin-ai-flows, @voltro/cli, @voltro/devtools-ui** — **Ten plugin-owned tables move into the `_voltro_` namespace, and the planner learned to carry a table across instead of dropping it.**
|
|
163
|
+
|
|
164
|
+
`plugin-notifications` (`notification_inbox`, `notification_preferences`, `notification_deliveries`, `notification_topic_subscriptions`, `notification_quiet_hours`, `notification_held`), `plugin-scim` (`scim_users`, `scim_groups`) and `plugin-ai-flows` (`ai_flows`, `ai_flow_runs`) registered framework-OWNED tables into the USER's table namespace while every other plugin used `_voltro_*`. An app with a same-named table collided with the framework.
|
|
165
|
+
|
|
166
|
+
**Nothing is required of you.** The tables carry their rows across on the next `voltro db apply` — or a `voltro dev` / `voltro serve` boot with auto-migrate — as a catalog-only `ALTER TABLE … RENAME TO`. Run `voltro db plan` first if you want to see it; it prints the renames without touching anything.
|
|
167
|
+
|
|
168
|
+
**The blocker was a missing primitive, not the rename.** A rename and a drop+create are structurally identical to a differ — old table gone, new table present — and for a TABLE the difference is all of the data, so the planner had no way to express one and this sat as a known gap. It can now:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
table('_voltro_notes', { id: id({ prefix: 'note' }), body: text() }).renamedFrom('notes')
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`ALTER TABLE … RENAME TO` is catalog-only on every dialect including sqlite, so unlike `rename-index` this has no dialect gate. Three guards each block a way to destroy data rather than move it, and none of them is silent:
|
|
175
|
+
|
|
176
|
+
- **the old name must not still be declared** — an app with its OWN `notes` table keeps it, which is exactly what makes reclaiming a name into `_voltro_` safe for you. A legitimate outcome, so the plan runs and the `create-table` line says why the marker was not applied; - **the target must not already exist live**, and **two tables may not claim one old name** — both REFUSE TO PLAN, because only you can say which table holds the real rows, and the quiet alternative (an empty plan reading "schema up to date" while the old table still holds everything) loses data by inaction.
|
|
177
|
+
|
|
178
|
+
A marker whose old table is simply absent is a quiet no-op, so it survives a staged rollout.
|
|
179
|
+
|
|
180
|
+
**The half that nearly shipped broken, twice.** Index names are DERIVED (`<table>_<col>_idx`, `<table>_pkey`) and no dialect renames an index when its table is renamed. Diffed raw, a table rename planned as DROP the primary-key index plus re-add it as a plain UNIQUE — which postgres refuses outright, so the rename could never converge. Live index names are now projected through the table rename and the real ones emitted as `rename-index` ops.
|
|
181
|
+
|
|
182
|
+
The second half was subtler and postgres-shaped: `<table>_pkey` is NOT a catalog object on four of the five dialects — every introspector fabricates that entry from the table's current name. Emitting a rename for it failed outright (`ERROR 1176: Key 'notes_pkey' doesn't exist`, measured on MySQL 8.4.10), and postgres is the one dialect where the fabricated name happens to be real, so hand-verifying the statement there proved nothing about the rest. It is projected for the diff and emits no DDL at all — after the rename the next introspection fabricates the new name on its own.
|
|
183
|
+
|
|
184
|
+
Covered end-to-end against a live database now, not by hand — and on EVERY dialect, not just the one that happened to work. `runDialectParity` gained a `rename-table` scenario that applies a real plan to a real database and asserts the rows survived AND the re-plan is EMPTY; it runs on postgres, mysql, mariadb, sqlite, mssql and turso.
|
|
185
|
+
|
|
186
|
+
**Why BREAKING when your code does not change.** Two reasons. Any raw SQL you wrote against those table names by hand — a reporting query, a dashboard view, a `db.raw(...)` — now names a table that does not exist; `tsc` cannot see that, so the codemod prints the list. And `MigrationOperation` gained a `rename-table` kind, which breaks an exhaustive `switch` over it (the same reason `rename-index` was breaking).
|
|
187
|
+
|
|
188
|
+
**A `_voltro_` table needs an explicit `id({ prefix })`** — a typeid prefix cannot be derived from a name starting with `_`. All ten already had one; this only matters if you declare your own.
|
|
189
|
+
|
|
190
|
+
### Added
|
|
191
|
+
|
|
192
|
+
- **@voltro/database, @voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/plugin-versioning** — **A change event now says WHICH CALL caused it, and `_voltro_row_history` records it.**
|
|
193
|
+
|
|
194
|
+
A row diff carries no intent. The same `DELETE` on a join table is a member being removed, a team being deleted, a user being deleted, or a membership expiring — and `before`/`after` cannot tell those apart, because the difference is not in the data.
|
|
195
|
+
|
|
196
|
+
A consumer running the audit plugins beside their own hand-written `auditLogs` table put it exactly: `_voltro_row_history` can say *"`userTeams` row X changed"* and show the JSON, and cannot say *"Anna removed Bernd from the Frontend sub-team"*. Their audit UI renders the sentence, so they kept 2900 rows and 300 call sites of their own.
|
|
197
|
+
|
|
198
|
+
`ChangeEvent` and `PluginChangeEvent` gained `procedure` — the rpc tag of the call doing the writing — and `_voltro_row_history` gained a column for it. `traceId` says which CALL; this says which call it WAS.
|
|
199
|
+
|
|
200
|
+
**It cost one field rather than a hook**, which is the part worth stating. The same consumer asked for a per-table `annotate: (op, before, after, ctx) => …` that would let them write the sentence themselves. That hook as specified cannot produce their example: `subTeamMemberRemoved` is not in the diff either, so an annotator would face the identical problem one layer up. The tag was already resolved at the boundary that establishes write attribution (it is the same string the span is named after), and it was simply not travelling.
|
|
201
|
+
|
|
202
|
+
Available for a background write too — a schedule or startup write carries its own procedure with no request identity beside it, and the field is not conditional on its neighbours.
|
|
203
|
+
|
|
204
|
+
**Four copies of one shape.** `WriteAttribution` → `attributionFields()` → `ChangeEvent` → `PluginChangeEvent` → `RecordedWrite` → the versioning row's builder AND its reader. `plugin-versioning`'s own source records that this class has bitten it twice already: `traceId` / `subjectId` were added to the bridge and silently dropped, first by the row builder and then by the row reader, and nothing failed either time because a missing optional field reads as an honest absence. `procedureAttribution.test.ts` pins the chain, including that an unattributed event stays byte-identical to one from before the field existed (the key OMITTED, never `procedure: undefined`).
|
|
205
|
+
|
|
206
|
+
**Nothing to run.** The new column is additive and framework tables ride the declarative differ on every dialect, so a boot picks it up wherever it picks up your own schema changes.
|
|
207
|
+
|
|
208
|
+
Two related asks from the same report are NOT in this change, deliberately:
|
|
209
|
+
|
|
210
|
+
- **An actor SNAPSHOT (`{ id, email, name }`) frozen on the audit row.** It would close the "who was this, eight months ago" question, and it collides with the erasure endpoint we also ship: a frozen email retained for years is exactly what a right-to-be-forgotten request must reach. Denormalising PII into an append-only table is a design decision that has to include how `plugin-governance`'s `/erase` finds it again, and shipping the first half alone would hand every user a compliance trap. - **`changedFields` beside `data`.** Cheap and uncontroversial; it wants the previous version at write time, which the writer already fetches for the version number. Left out only to keep this change to one idea.
|
|
211
|
+
- **@voltro/cli** — **`voltro doctor` gained an authz scan, and the check it replaces was measured wrong in both directions.**
|
|
212
|
+
|
|
213
|
+
A consumer audited a 598-executor app by hand, closed **28** authorization holes, and then classified what `subject-write-no-guard` had said about the same code: it named **10 of the 28**, and **33 of its 68 findings** pointed at handlers that were already correct. A reviewer who spot-checks three findings, sees three correct handlers, and closes the tab has behaved rationally.
|
|
214
|
+
|
|
215
|
+
Three things were wrong with the old rule, and each is answered:
|
|
216
|
+
|
|
217
|
+
**It could not see the app's own guards.** The guard list was the framework's (`requireScope`, `assertCan`, …). That app's guards were `requireTeamAccess`, `requireRoadmapManageAccess` and friends in its own `lib/`, so every call site read as unguarded — 9 of the 33. The vocabulary is now INFERRED from the app's source (an exported `require*` / `assert*`). Once the vocabulary is known the check can be INVERTED, which is the only form that finds anything: not "this file looks wrong" but "this file references no check at all".
|
|
218
|
+
|
|
219
|
+
**It looked only at writes.** One of the 28 was a READ — an executor that took an inquiry id and returned the whole message thread with every participant's name, email, avatar and roles, to any authenticated user in the org. Reported clean, because a read writes nothing. Queries and streams are scanned now.
|
|
220
|
+
|
|
221
|
+
**`subject.id`-in-the-write was a proxy for the wrong thing**, and the reason is worth stating because it inverted the incentive: `storeMiddleware` stamps `createdBy` / `updatedBy` from the request subject for any table carrying `audit()`, so a handler on such a table never writes `subject.id` itself. The better an app used the actor mixin, the fewer of its writes the actor check would even look at. 17 of the 28 misses were that shape.
|
|
222
|
+
|
|
223
|
+
**An inline ownership check is now reported as informational, not as a hole.** `row.userId !== subject.id → new AccessDeniedError({})` is correct code; it is worth SEEING (only that one file knows the rule) and it is not a finding. That was 24 of the 33.
|
|
224
|
+
|
|
225
|
+
**The ratchet is what makes it usable on an existing app.** A first run reporting 221 unreviewed handlers is not actionable, and a check nobody can act on gets switched off. `voltro doctor --write-authz-allowlist` records today's unchecked executors into `voltro-authz-allowlist.txt`; every later run fails only on ADDITIONS. The file says DEBT and not approval, in those words, because an allowlist that reads as sign-off is worse than none. It is keyed by rpc TAG, not path, so moving a file can neither re-open a hole nor hide one — and it is consulted LAST, so an executor that gains a real guard is reported as guarded and its line simply stops mattering.
|
|
226
|
+
|
|
227
|
+
Findings are ordered by blast radius (op weight, plus the target table being `tenant()`-scoped or referenced by other tables). With dozens of findings, ordering is what decides whether the first three anyone reads are the ones that matter.
|
|
228
|
+
|
|
229
|
+
**And the scan now points at the declarative form.** `guards: [{ action, resourceType, resource }]` answers "may this subject act on THIS row", and an app whose relationships live in its own tables registers its own tuple source instead of copying data into a framework table — which covers the data-dependent membership checks apps hand-roll. The reporting app used it on **0 of 359** mutations, and it is the second app observed to build 100+ imperative checks beside an unused policy engine. That is a discoverability defect, so the message that flags an imperative check is where it gets said.
|
|
230
|
+
- **@voltro/protocol, @voltro/cli** — **`internal: true` keeps a procedure off the wire. There was no way to say that.**
|
|
231
|
+
|
|
232
|
+
`publicApi` and `exposeAsTool` opt IN to wider surfaces. Nothing opted OUT of the default one: every discovered `*.query.ts` / `*.mutation.ts` / `*.action.ts` / `*.stream.ts` was value-imported into `rpcGroup.generated.ts` and callable over the WebSocket by any authenticated browser session.
|
|
233
|
+
|
|
234
|
+
A consumer found what that costs. Their app had grown 18 procedures named `*Internal` — the convention a Convex port carried over for "only other server code calls this". All 18 were in the client group. One of them:
|
|
235
|
+
|
|
236
|
+
```
|
|
237
|
+
auditLogInternal.createFromAction
|
|
238
|
+
input: { actorId, actorType, actorEmail, actorName, resourceType,
|
|
239
|
+
resourceId, eventType, before, after, teamId, … }
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
No guard, every field client-supplied, zero callers. Any authenticated user could write audit rows attributed to anyone. This is the same argument as `.serverOnly()` on a column, one level up: **a naming convention is not a boundary.** If the only thing keeping a procedure off the wire is that nobody wrote a client call for it, it is on the wire.
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
export const createFromAction = defineMutation({
|
|
246
|
+
name: 'auditLog.createFromAction',
|
|
247
|
+
input: Schema.Struct({ /* … */ }),
|
|
248
|
+
output: Schema.Void,
|
|
249
|
+
internal: true, // no client-group entry, no route in dev or serve
|
|
250
|
+
})
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Server code calls it by importing its executor directly, which is what a server-to-server caller already does.
|
|
254
|
+
|
|
255
|
+
**It is honoured on all three paths, and that is the load-bearing part.** The generated client group, `voltro dev`'s rpc group and `voltro serve`'s rpc group are three INDEPENDENT assembly paths. A boundary honoured by two of them is worse than one honoured by none: the docs would say internal, the browser would agree, and production would still route the tag — invisible from outside, in the one place people stop looking once a flag exists. All three now consult one exported predicate (`isWireReachable`), and a source-reading test fails if any assembly site stops consulting it, or if a FOURTH one appears.
|
|
256
|
+
|
|
257
|
+
Available on queries, mutations, actions **and streams**. A stream without it would have been a hole in the same boundary; `tsc` caught that omission.
|
|
258
|
+
|
|
259
|
+
**Not a substitute for a guard.** An internal procedure still runs with whatever authority its caller has. This removes the wire surface, not the need to check who is asking — `voltro doctor`'s authz scan still covers it.
|
|
260
|
+
|
|
261
|
+
`internal` is compared as `!== true`, so a descriptor whose flag is absent, `undefined`, or anything other than exactly `true` stays reachable. An accidental de-routing is an outage, and outages caused by a security flag are how the flag gets reverted.
|
|
262
|
+
|
|
263
|
+
### Fixed
|
|
264
|
+
|
|
265
|
+
- **@voltro/workflow** — **The cross-dialect cluster-engine suite poisoned the database it tests against, and got less reliable the more you ran it.**
|
|
266
|
+
|
|
267
|
+
`clusterTestSuite.ts` (shipped as the test-only `@voltro/workflow/cluster-suite` subpath) left every run's state in the `cluster_*` tables and nothing removed it. Each scenario names its workflows with a per-run suffix, so two runs never collide — they ACCUMULATE. A later runner then finds messages addressed to `ClusterCron/clusterCron_<oldSuffix>` entity types that no process registers a handler for any more, retries them every 10 seconds forever, and holds a connection each time. Eventually the pool cannot be acquired and whichever scenario happens to be running dies with `SqlError: Failed to acquire connection`.
|
|
268
|
+
|
|
269
|
+
Measured on mysql, one file, back to back:
|
|
270
|
+
|
|
271
|
+
| after | `cluster_messages` rows | |---|---| | run 1 | 15 | | run 2, with the purge | 15 | | run 2, purge disabled | 30 |
|
|
272
|
+
|
|
273
|
+
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.
|
|
274
|
+
|
|
275
|
+
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.
|
|
276
|
+
|
|
277
|
+
The general lesson, because it is not specific to this fixture: **"it passes in isolation" is a symptom, not a diagnosis.** It is equally consistent with machine load and with shared state the suite itself poisons, and only the second one can be fixed. Ask what a suite LEAVES BEHIND, and count it, before recording a red as environmental.
|
|
278
|
+
- **@voltro/cli, @voltro/runtime** — `voltro dev` could stop restarting altogether. After a save, the supervisor printed `file changed — restarting` and then nothing: no new server, the old process still holding the port and the browser's websocket, and no reconnect ever. Only killing the tree by hand recovered it. Reported as "after a change to a backend service nothing ever reconnects again".
|
|
279
|
+
|
|
280
|
+
**Two independent missing deadlines, on the same path.**
|
|
281
|
+
|
|
282
|
+
1. The supervisor's SIGKILL escalation could never fire. It was guarded by `!proc.killed`, and node sets `killed` as soon as a signal has been successfully **sent** — so it was already `true` on the line after the SIGTERM. The 1.5s grace window was decorative, and the stop waited on the child forever in an uninterruptible release. 2. The child had nothing to escalate against. Installing a SIGTERM listener removes node's default kill, so the only thing that ends the process is the handler reaching `process.exit()` — and it got there via `Promise.all(fibers.map(Fiber.interrupt))` with no bound. One finalizer that never completes (a pool drain against a database that is gone, a wedged plugin `onDeactivate`, a `quit` on a dead socket) and the server ignores SIGTERM outright.
|
|
283
|
+
|
|
284
|
+
Both are bounded now, and a child that needs SIGKILL says so (`child ignored SIGTERM — escalated to SIGKILL`) instead of costing every restart the full grace in silence.
|
|
285
|
+
|
|
286
|
+
**Shutdown hooks now actually run.** Thirteen teardowns sat on `process.on('beforeExit')` — the CDC detach, the subscriber and reaction detach, the scheduler, the workflow runtime, the retention sweep, trace persistence — and `beforeExit` is not emitted when something calls `process.exit()`, which is how a signalled process ends. A listening server never drains its event loop naturally either, so they had never run at all. Two more were on `process.once('exit')`, which fires but drops async work; both bodies were async. They are all on the signal path now.
|
|
287
|
+
|
|
288
|
+
That includes the one users can observe: the `ctx.onShutdown(cb)` callbacks a `*.startup.ts` registers, whose contract says "on SIGTERM / SIGINT". Under `voltro dev` they had never fired.
|
|
289
|
+
|
|
290
|
+
**`voltro serve`'s production drain was being truncated.** dev and serve each installed their own SIGINT/SIGTERM listeners next to the runtime's, so two owners raced to call `process.exit` — and the runtime's, registered first during `startRpcServer`, won as soon as the launch fiber interrupted (~40ms, measured). Whatever serve's careful sequence had not reached by then did not happen: plugin deactivate, analytics flush, in-flight request drain, connection pool close. There is one owner now; `onProcessShutdown` is how a boot path contributes teardown to it.
|
|
291
|
+
- **@voltro/database, @voltro/cli** — **A framework table that changed SHAPE never reached an existing database, and what `voltro dev` did depended on your dialect.**
|
|
292
|
+
|
|
293
|
+
`_voltro_*` tables were stripped from BOTH sides of the boot diff and evolved by a separate emitter instead — `CREATE TABLE IF NOT EXISTS` + `ADD COLUMN IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS` on **postgres**, and on every other dialect just the CREATEs. Nothing there could change a column's type or nullability on any dialect.
|
|
294
|
+
|
|
295
|
+
So the framework could declare a shape for one of its own tables that a boot would never reach — and reach it on postgres while silently skipping it on MariaDB, in a framework whose every other layer is built on dialect parity. 0.21.0 shipped exactly that: `_voltro_api_keys.hashedKey` gained `.maxLength(64)`, and on MariaDB that column's unbounded UNIQUE is what keeps the table out of binlog capture — so the fix for the CDC exclusion was itself excluded, on the dialect where it mattered.
|
|
296
|
+
|
|
297
|
+
**The filter was symmetric, and that was the bug.** The reason to exclude framework tables is the DROP direction: a live `_voltro_*` / `cluster_*` table no app declares must not plan as a lossy drop. That reason says nothing about a table we DO declare. It is asymmetric now — undeclared framework table, never dropped; declared framework table, diffs like any other — so framework tables ride the same planner, the same classification and the same applier as user tables, on every dialect. The second, weaker path is gone rather than fixed: patching it would have kept two paths.
|
|
298
|
+
|
|
299
|
+
**Nothing to run.** A boot applies framework-table changes wherever it applies your own. No `voltro db apply` step, no dialect-specific instruction.
|
|
300
|
+
|
|
301
|
+
Two things worth knowing, because they are how a half-finished version of this looked correct:
|
|
302
|
+
|
|
303
|
+
- the live filter existed in TWO places — the plan, and the convergence RE-PLAN `applyPlan` runs before it records a fingerprint. Widening only the first made the re-plan see a declared set full of framework tables against a live set with none, so it proposed `create-table` for all four and `applyPlan` correctly refused. The framework's own convergence proof caught it; there is one filter now. - the fingerprint fast-path now covers framework tables too, so a release that changes only one of them invalidates it instead of being skipped. That re-fingerprints once and self-heals on the next apply.
|
|
304
|
+
|
|
305
|
+
**Why the rule said otherwise.** The codemod exemption for DB tables rested on a test whose every assertion exercises `planMigrations` — which does see framework tables — and whose header concluded that a `voltro dev` boot reconciles them. Evidence about the planner, conclusion about the boot path. Its parenthetical gave it away: "(add the column, add the table)" are exactly the two cases the old emitter could do, one of them on postgres only. Both are covered now, and `sql-postgres` / `sql-mysql` carry `frameworkTableEvolution.*.integration.test.ts` — boot a real database on both dialects, assert the column changed, assert the next boot has nothing to do, and assert an undeclared `cluster_*` table is still never dropped.
|
|
306
|
+
|
|
307
|
+
An earlier revision of this change shipped a boot WARNING (`frameworkShapeGap`) naming the work the additive emitter could not do. It is deleted. It was the right answer to the wrong problem — it described the divergence rather than removing it, and told MariaDB users to run a command postgres users did not need.
|
|
308
|
+
- **@voltro/cli, @voltro/plugin-governance, @voltro/plugin-storage, @voltro/plugin-billing, @voltro/plugin-mail, @voltro/plugin-moderation, @voltro/plugin-search** — **A plugin mounting a destructive inspect endpoint declared `inspect:read`, and nothing checked.**
|
|
309
|
+
|
|
310
|
+
`inspectEndpoints` mapped to `inspect:read` in the boot permission audit — one hook, one permission, regardless of what the plugin actually mounted. The permission is named "read" and the endpoints did not have to be. Measured across the shipped plugins after a consumer noticed it from the outside: seven mount a non-GET inspect endpoint, and **six declared `inspect:read` alone**. Among those endpoints are `plugin-governance`'s `/erase` — an irreversible GDPR right-to-be-forgotten deletion — and `/export`, a full personal-data dump, plus `plugin-storage`'s `/share` and `/revoke`.
|
|
311
|
+
|
|
312
|
+
`inspect:write` already existed as a permission, and `plugin-flags` already declared it. So the convention was right and simply unenforced — the "declaration nobody checks" shape.
|
|
313
|
+
|
|
314
|
+
The requirement is DERIVED from what the plugin mounts now: any endpoint whose method is not GET / HEAD / OPTIONS requires `inspect:write`, and the boot audit names the offending `METHOD /path` so the fix is not a guess. Adding a POST to a plugin panel forces the declaration at boot, for every future plugin too. This is the same shape `extendSchema` already had, where one contract field ships two distinct capabilities.
|
|
315
|
+
|
|
316
|
+
The six plugins are corrected. A source-level test asserts the shipped set keeps passing its own rule — the check that would have caught this originally, since it was found by a consumer rather than by us.
|
|
317
|
+
|
|
318
|
+
**Scope, stated plainly:** this governs what a PLUGIN may mount, not who may call it. Those endpoints still sit behind the same single inspect token as reading a route list. Treat that token as an admin credential. The inspect docs said "read-only introspection surface" and now say what is actually there.
|
|
319
|
+
- **@voltro/database, @voltro/voltro** — A `text().maxLength(n)` **narrowing** could not be applied. `db plan` counted it under `blocked`, and every route refused: `db apply`, `db apply --force`, `migrate`, and `VOLTRO_DESTRUCTIVE_OK` (which only relaxes `lossy`). There was no acknowledgement flag anywhere.
|
|
320
|
+
|
|
321
|
+
0.21.0's own change log said the opposite — *"narrowing is deliberately NOT blocked — blocking it would leave the remedy just as unusable as the silence did"* — and the code refused every route. So the feature that was supposed to make the MariaDB hash-long-unique remedy usable made it **visible** without making it **applicable**, which is a smaller step than it reads.
|
|
322
|
+
|
|
323
|
+
**The cause is one argument.** `mkPlanned`'s fourth parameter is the refuse-marker, and I used it to attach the count query as a hint. The field's own doc comment says it is for ops "with no resolution" — a narrowing that ships the query which resolves it is the opposite of that. The query now rides in the `reason`, where the CLI already prints it, and the operation is `needs-backfill` and appliable.
|
|
324
|
+
|
|
325
|
+
Unblocked, the failure mode is the honest one the message already describes: if a value IS longer than the new bound, the database rejects the ALTER and the migration fails loudly. Better than a gate that cannot be opened.
|
|
326
|
+
|
|
327
|
+
Reported by a consumer who had run all four of their check queries first — 2900 rows on one column, 46 on another, **zero** offending values, fixed-width trace ids and SHA-256 digests sitting exactly at their bound — and then found no way to say so. Their data was provably safe and the tool still refused.
|
|
328
|
+
|
|
329
|
+
No test pinned `blocked`, which is why this shipped: the CLASSIFICATION was right the whole time, so reading it alone showed nothing wrong. There is now an assertion on the flag itself, verified red against the 0.21.0 behaviour.
|
|
330
|
+
- **@voltro/database, @voltro/runtime, @voltro/cli** — **Two places where the framework had documented a shortcoming instead of removing it.**
|
|
331
|
+
|
|
332
|
+
### The framework bootstrap is one statement kind again
|
|
333
|
+
|
|
334
|
+
`emitFrameworkBootstrapSql` also emitted `ALTER TABLE … ADD COLUMN IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS`, and the ADD COLUMN half was **postgres-only**, because that is the dialect with the syntax. While framework tables were filtered out of the boot planner, this was their only evolution path — so a release that added a framework column reached a postgres user's database on boot and a MariaDB user's never. One `voltro dev`, two behaviours, decided by the driver.
|
|
335
|
+
|
|
336
|
+
The planner owns framework-table evolution now, so those steps were not merely redundant: they were a second, weaker path. And the index step actively hurt — it ran BEFORE the planner could add a column, so an index over a newly-added column failed the boot (pg 42703) instead of waiting one step. The ADD COLUMN step existed to paper over exactly that ordering, which is a good sign the ordering was wrong.
|
|
337
|
+
|
|
338
|
+
What remains is `CREATE TABLE IF NOT EXISTS` (plus enum types, cyclic FKs and reactive triggers, which the planner does not manage). It exists for one reason: `applyPlan` records into `_voltro_migration_plans`, so that table has to exist first. It is dialect-uniform, because nothing is left in it that only one dialect can express.
|
|
339
|
+
|
|
340
|
+
Its tests asserted the opposite — one was literally named *"non-postgres path delegates to plain emitSchemaSql (no ALTER evolution)"*, recording the divergence as intended behaviour. They now assert the same DDL SHAPE on all five dialects.
|
|
341
|
+
|
|
342
|
+
### A guard and its executor share one query
|
|
343
|
+
|
|
344
|
+
A relationship guard (`guards: [{ action, resourceType, resource }]`) is answered by the app's registered `TupleSource`, and for any real policy that means loading something — the draft whose `teamId` decides access, the membership row. Then the executor loads the same row for the actual work. Two queries for one row, on every guarded call.
|
|
345
|
+
|
|
346
|
+
A consumer proposed a new guard form (`resolve(...)` then `check(row => …)`) so the framework would hand the loaded row down. The framework already had the answer: a request-scoped, batching, caching loader that the executor uses. The tuple source just could not reach it — its signature was fixed at boot, several layers above the request.
|
|
347
|
+
|
|
348
|
+
`TupleSource` now receives `load`, the REQUEST's loader — the same one `ctx.load` gives the executor. Reading through it makes the second read free. Measured rather than asserted: one query with the shared loader, two without, with the "without" case kept as a control so the claim stays falsifiable.
|
|
349
|
+
|
|
350
|
+
`load` is `undefined` outside a request — a boot seed, a schedule tick, a plugin's startup hook. That is a real answer and stays one; a source must fall back to its own query there rather than assume a cache with no lifetime.
|
|
351
|
+
|
|
352
|
+
Wired in `makeAppContextBuilder`, the single builder both boot paths call, so dev and serve cannot drift on it. It is an async-local rather than a parameter for the same reason `writeAttribution` is one: guard evaluation runs through `checkGuardsEffect` in browser-safe `@voltro/protocol`, which must not learn a runtime loader type, and only one of its three call sites has a request context in scope. The isolation property is pinned by a test with two concurrent requests — a process-wide cache here would be a tenant-isolation bug, not a performance detail.
|
|
353
|
+
|
|
354
|
+
Not breaking: a `TupleSource` implementation that destructures the fields it already used keeps compiling.
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
42
358
|
## [0.21.0] — 2026-07-31
|
|
43
359
|
|
|
44
360
|
### ⚠ BREAKING
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/plugin-audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.1",
|
|
4
4
|
"description": "Audit plugin — ships the `audit()` schema mixin (createdAt/updatedAt/createdBy/updatedBy → Actor) plus an optional mutation interceptor that records every call to a configurable sink (console / memory / custom function).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
"node": ">=24.0.0"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@voltro/database": "0.
|
|
41
|
-
"@voltro/logger": "0.
|
|
42
|
-
"@voltro/protocol": "0.
|
|
40
|
+
"@voltro/database": "0.22.1",
|
|
41
|
+
"@voltro/logger": "0.22.1",
|
|
42
|
+
"@voltro/protocol": "0.22.1"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"effect": "^3.22.0"
|