@voltro/plugin-audit 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -39,6 +39,429 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.19.0] — 2026-07-29
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/plugin-audit** — **The durable audit sink stops writing credentials to a log table by default.** `redactInput` defaults to `'all'` — the payload becomes `{ __redacted: 'all' }`, which still proves a payload existed. `redactInput: 'none'` restores the previous behaviour, and a function gives field-level control.
47
+
48
+ `AuditEvent.input` is the raw mutation input, so an unredacted trail is where a password change, an API key at issuance and a PAT land — the one place nobody thinks to look for a credential. Losing payload detail is visible the first time you read a row; leaking a credential is not visible at all, which is why the default moved rather than staying opt-in.
49
+
50
+ **It is deliberately NOT driven by `.serverOnly()` / `.sensitive()`,** which is the design a consumer proposed and the one that cannot work: those markers live on TABLE COLUMNS and this is a mutation's INPUT. `changePassword({ oldPassword, newPassword })` has no column to consult, so a marker-driven default would cover exactly 0% of the motivating case while reading, to whoever configured it, like protection. (`.sensitive()` is also the export axis, not "unsafe to log" — the category error the three-marker table exists to prevent.)
51
+
52
+ Also:
53
+
54
+ - **`record: 'all' | 'errors' | predicate`** — filters by OUTCOME, where `include`/`exclude` filter by tag. `'errors'` is the forensic core and pairs with `plugin-versioning` for the successful writes. **`'all'` stays the default** on purpose: defaulting to errors would silently stop recording successes on upgrade, and "what did this compromised account touch" is answered by successes. - **`errorTag`** — the typed error's `_tag`, flattened out of the `outcome` json and indexable. `null` for an untagged failure rather than a guess: "this had no tag" and "the tag is 'Error'" are different, and a column that invents the second makes every filter on it quietly wrong. - **Two indices for the questions asked under pressure** — `(subjectId, status, at)` and `(tenantId, status, at)`. "Every denied call by subject X in the last 30 days" and "every failure against tenant Y" were both unindexed; the existing `byAuditTag` / `byAuditTrace` cannot serve either. - **Retention registers itself** — 365 days, `VOLTRO_AUDIT_LOG_TTL_HOURS`, drained by the boot sweep. "Pair it with the governance sweep" was a docs sentence rather than a default, so nobody did. - **Erasure is deliberately NOT auto-registered.** Erasing a subject must not delete the record that they were refused four hundred times — that record *is* the evidence. Anonymise instead; the docs carry the `subjectScopes` entry to paste, and it stays a decision the app makes explicitly.
55
+
56
+ **Migration** — `voltro update` prints it (`0.19.0/02_audit-redact-input-default`, `manual`, and it fires only for apps that mount the plugin). Nothing stops compiling and existing rows are untouched; what changes is what the NEXT row records. Keep the new default unless you know your mutation inputs carry no secrets; pass a function for field-level control; or opt back in explicitly with `redactInput: 'none'`. The codemod deliberately does NOT write `'none'` into your config — a transform could do it perfectly, which is exactly why it must not: it would pin every adopter to the behaviour the default moved away from and report the migration as complete.
57
+
58
+ *Why this is `BREAKING` and not `Changed`: it is a silent behaviour change on upgrade. Nothing fails, which is the problem — an operator who never reads this section keeps a trail that has quietly lost its payload detail. `BREAKING` is what routes it into `voltro update`.*
59
+ - **@voltro/cli, @voltro/database** — **`.serverOnly()` now gates where it said it did.** A wire-reachable query that declares a `.serverOnly()` column of its source table in its `output` fails the boot under `voltro serve`, and makes `voltro doctor` exit non-zero. `voltro dev` still warns.
60
+
61
+ It shipped as one `log.warn` and nothing else — in every command — while `ColumnBuilder.serverOnly()`'s own doc comment and the seeded `AGENTS.md` marker table both said "**the boot audit — it FAILS the boot**, it does not warn". A team read the strong sentence, adopted the marker on four credential columns, injected a deliberate leak to check, and watched the server come up serving the leaking query. That is the register this repo keeps meeting from a new angle: *a check that prints instead of gating still reads as coverage* — here on the one marker whose entire job is the enforcement.
62
+
63
+ Two smaller things went with it, both of which had misled the reporter:
64
+
65
+ - **The message names the command.** It was emitted through a module-level logger scoped `voltro:dev`, so a warning from `voltro serve` announced itself as dev — which is why they concluded, and reported, that the audit does not run in production at all. It did; it just misattributed itself and stopped nothing. - **The audit is computed once, in `loadDiscovered`**, the discovery dev / serve / doctor / check all share — the same reasoning `validateRegisteredRelations()` lives there for. Dev and serve may disagree about what a leak COSTS; they must not disagree about what a leak IS.
66
+
67
+ `VOLTRO_SERVER_ONLY` moves the line both ways: `strict` fails `voltro dev` too, `warn` downgrades serve, `off` silences it. The downgrades are documented rather than hidden, because the alternative to a stated escape hatch is deleting the marker, and a check whose only way out is to disable it gets disabled.
68
+
69
+ `voltro check` deliberately does NOT run it: it has a live-api mode with no access to your table definitions, and a rule that fires in one of its two modes is worse than one that fires in neither.
70
+
71
+ **Migration** — `voltro update` prints it (`0.19.0/01_server-only-gates-the-boot`, `manual`, and it fires only for apps that actually use the marker). Run `voltro doctor` BEFORE you deploy: it reports exactly what `voltro serve` will now refuse, with no deploy involved. Each finding has two honest fixes and only its author can choose — the column is not wire-safe (drop it from the query's `output`, keep the marker), or the marker is wrong (drop the `.serverOnly()`). Do not substitute `.encrypted()`: that is the at-rest axis, the runtime decrypts for the handler, and reading it as "safe to expose" is the category error the three-marker table exists to prevent. To ship while triaging, `VOLTRO_SERVER_ONLY=warn` downgrades serve back to a warning — a bridge, not a setting to keep.
72
+
73
+ *Why this is `BREAKING` and not `Changed`: no signature moves and nothing that compiled stops compiling, so the literal type-level test does not catch it. It can still turn a booting production app into one that refuses — which is the point, the boot it refuses is the one shipping the column — and that failure lands at DEPLOY time. Filing it as `Changed` would have kept it out of the one section the stability contract names as the migration path, and out of `voltro update` entirely, because codemods hang off `BREAKING`. Both doc claims were corrected in the same change, so the `.d.ts`, the agent template and the docs site now describe the same behaviour.*
74
+
75
+ ### Added
76
+
77
+ - **@voltro/plugin-audit** — **`auditByTrace` / `auditBySubject` — the read side of the correlation join.**
78
+
79
+ `byAuditTrace` and `byAuditSubjectStatus` shipped in the same release with **no caller**. That is the identical defect the versioning side had and that its own entry points were added to fix, repeated on the other half of the join one file away: an index nobody can enter is a query the app still hand-writes, and the docs then demonstrate a raw select over a framework-internal table.
80
+
81
+ It surfaced by checking an adopting team's design document against the code rather than from memory. Their §6 asks for *"a read-side composition joining `_voltro_row_history` × the audit sink (on `traceId`) × `actors`"* — which needs BOTH halves to have an entry point, or neither is usable.
82
+
83
+ ```ts
84
+ const calls = await auditByTrace(ctx.store, traceId) // who called, and was it refused
85
+ const changed = await historyByTrace(ctx.store, traceId, tenantId) // what it changed
86
+ const denied = await auditBySubject(ctx.store, actorId, { status: 'error' })
87
+ ```
88
+
89
+ `auditBySubject` takes `status` as a real argument rather than leaving the caller to filter in JS — the index is `(subjectId, status, at)`, so a filter applied after fetching would not use it. `limit` defaults to 100, because an actor's history is unbounded and an entry point that returns all of it is one you call once in production.
90
+
91
+ *They were also not exported from the package index when first written — built, tested, and unreachable. Caught before shipping; worth recording because "it has a test" and "a consumer can call it" are different claims.*
92
+ - **@voltro/database, @voltro/runtime, @voltro/protocol, @voltro/cli, @voltro/plugin-versioning, @voltro/plugin-audit, @voltro/voltro** — **`ChangeEvent` carries the calling `traceId` and `subjectId`** — the join key that lets `plugin-versioning` (what changed) and `auditPlugin` (who called, and whether they were refused) be read as one trail.
93
+
94
+ Both halves shipped and neither could be joined to the other. A consumer put it exactly right: *"we are not asking you to build our audit feature. We are asking for the join key that lets anyone build one on the three pieces you have already shipped."*
95
+
96
+ **The mechanism, and the part that was an empirical question rather than a design one.** Identity is known one layer up (`subject` in the middleware, `traceId` at the rpc boundary) and the event is created several layers down, inside each dialect store's private emit. Threading a context argument through every `DataStore` method to reach it would change the port every driver implements, for metadata that is ambient by nature — so it rides an `AsyncLocalStorage` (`@voltro/database`'s `writeAttribution.ts`), the same shape as the existing trace and replica-routing contexts.
97
+
98
+ Whether that survives a SQL store was *not* obvious: the write goes through `ManagedRuntime.runPromise`, so the read happens inside an Effect fiber, and a scheduler draining fibers from a shared loop would run them under the async context of whoever created the drain. If that were true the attribution would be silently ABSENT — a join key that is simply never there, on a trail nobody checks until an incident. Verified against a real sqlite store, including the transactional path (which queues events and flushes them post-commit, *outside* the scope — which is why the stamp goes at event CREATION, not delivery).
99
+
100
+ - **One call site, not one per method.** The scope is established at the rpc executor boundary, so it covers every write the handler makes — `ctx.store`, `EffectStore`, the crud helpers, a plugin interceptor's own writes. Wrapping the store middleware instead would have meant wrapping each mutating method and hoping the next one added remembers. - **`subjectId` is the same identity `audit()` stamps** (`actingUserId`, now exported so there is one source). Two answers to "who wrote this row" on one write would be worse than one; which API *key* was used is recoverable from the audit row sharing the `traceId`. - **Absent is a fact, not a gap** — no request behind the write (seed, startup hook, schedule, workflow step), or an event from another replica, where stamping the local ambient trace would attribute a remote write to a local call. The keys are omitted rather than set to `undefined`, so an unattributed event is byte-identical to one from before this existed. - **`dev.ts` and `serveCommand.ts` no longer hand-mirror the plugin fan-out.** They built that object literal separately in two files; a field added to one and not the other gives a plugin the data in dev and silence in production, and both files typecheck alone. Now `toPluginChangeEvent`.
101
+
102
+ `_voltro_row_history` gains `traceId` / `subjectId` plus `byTrace` and `bySubject` indices — "what did this call touch" and "what did this actor touch" were previously unanswerable at any speed, since `byRow` requires already knowing which row you are asking about. `changedBy` now prefers the caller over the row's `audit()` stamp, which fixes a reported case for free: the stamp is `null` for every write through the boot store, so a login route produced version rows with no actor at all.
103
+
104
+ *`apiSurface: compatible`: every golden line this touches is either a pure addition (the new fields and `actingUserId`) or api-extractor renumbering an import alias — `Subject_2` became `Subject` across `runtime` and the `voltro` re-export because a new import changed the ordering. No declaration changed shape and no call site is affected.*
105
+ - **@voltro/cli** — **`voltro doctor`'s hand-roll detector gains six rules and ranks its findings by file count.**
106
+
107
+ An adopting team audited four apps by hand against the framework's "reach for instead" table and produced twenty items, each with a count (`1631` files with a manual `rows[0]`, `500` queries with `input.offset`, `306` forms, `368` relations declared against `17` uses). **The detector already reported eleven of those twenty, with counts.** These are rules for what it missed — the findings that made a person do work a scan should have done:
108
+
109
+ - **`offset-pagination`** — `.offset(` / `input.offset` / `.skip(` on a list query. `hand-cursor` did not catch this: it wants `hasMore` AND `limit+1`, which is a hand-rolled *keyset* pager. Plain OFFSET is a different smell with a worse ending — O(n) in the page number, so it does not fail, it just stops loading once the table is big, on the tables (audit logs, time entries) that get big first. - **`oauth-token-table`** — an `accessToken`/`refreshToken` column on an app table → `defineConnection({ kind: 'oauth2' })`. The reporting team's version of this leaked: a `json()` column holding a Slack payload with the token inside, readable by every colleague in the tenant. - **`non-incremental-aggregate`** — a `count/sum/avg/min/max` aggregate with no `incremental:`, rescanned in full on every refresh. - **`external-state-library`** — jotai / zustand / mobx / redux → `defineStore`. A second runtime beside the reactive engine, and one that does not participate in the subscription graph. - **`hand-route-module`** — importing a hand-maintained `routes` / `urls` module instead of `.framework/routes.generated`, where a renamed page is a compile error rather than a 404 someone finds in production. - **`hand-permission-check`** — a component reading a permission bag by hand instead of `useCan` / `useResourceCan`.
110
+
111
+ **And the findings are now ordered by file count rather than by the order the rules happen to be declared in.** A reader facing twenty findings acts on the top of the list, so an arbitrary order silently decides what gets fixed. The reporting team ranked their own work by exactly that number and had to count it by hand, because this printed the same facts unranked. Effort we cannot know; magnitude we can.
112
+
113
+ *Two of these rules were caught being wrong by their own tests before shipping: `offset-pagination`'s first version checked only `input.offset` and did not match its own fixture (a narrow rule reports nothing and reads like a clean codebase), and the ranking test passed with the sort deleted until the fixture was rebuilt so declaration order and count order disagree.*
114
+ - **@voltro/plugin-versioning** — **`historyByTrace` / `historyBySubject` — the entry points the new indices existed for.** Plus their Effect twins.
115
+
116
+ The correlation bridge added `byTrace` and `bySubject` to `_voltro_row_history` in the same release, and shipped them with no caller: `rowHistory` requires a `rowId` you already have, which is the wrong way round during an incident, when what you have is a trace or an actor. The docs demonstrated a raw `ctx.store.select('_voltro_row_history')`, which is the shape an index is supposed to save you from writing.
117
+
118
+ Both are tenant-scoped exactly like `rowHistory` (own-tenant rows plus null-tenant rows from untenanted source tables; `undefined` skips the filter, for system paths only). `historyBySubject` takes a `limit`, default 100, because an actor's history is unbounded and an entry point that returns all of it is one you call once in production and never again.
119
+
120
+ **And a bug this surfaced.** The stored-row → `VersionRow` decoder was written inline inside `versionsOf`, before the bridge existed, and silently dropped `traceId` / `subjectId` — so `rowHistory()` returned rows missing the very field the feature exists to carry. Extracted to one `rowToVersion` now shared by all three readers, which is why the drift was possible in the first place.
121
+ - **@voltro/cli, @voltro/database** — **`setRowFilter` is now named in the always-loaded agent core**, with the distinction that makes it findable.
122
+
123
+ A team migrating eleven hand-written row filters reported missing it **twice** — once in a full framework audit, and once while telling a colleague in writing that the framework has no row-filter primitive. The depth doc covers it well; a depth doc is opened by someone who already suspects the topic. The core's SERVER rubric asked *"a permission check?"* and answered with two BOOLEAN questions, so a reader holding a row-VISIBILITY question took the nearest-fitting answer and wrote the filter by hand. Eleven times.
124
+
125
+ The rubric now asks it directly, and the pairing is the point:
126
+
127
+ > `guards:` → *may I call this procedure?* → a typed `ScopeError`. > `setRowFilter` → *which rows may I see?* → the rows are simply absent.
128
+
129
+ Plus the failure it deletes — a `WHERE ownerId = me` in the list handler covers the query and **not** the subscription — and the trap they lost time to: `load` must read through an UNFILTERED store, because applying the filter to its own loader recurses until the stack blows.
130
+
131
+ Also:
132
+
133
+ - **`voltro doctor` gains `unexercised-row-filter`.** Their 7466 tests stayed green when the filter landed because not one passed `rowFilter:` to `makeTestContext`; they only noticed because they sabotage their own predicates before believing a pass, and four sabotages ran green. `makeTestContext({ rowFilter })` being opt-in is right — a process-global in a parallel suite would be worse — but the consequence is a visibility rule nothing verifies. Same class as a suite reporting `passed` with no database. - **`MATCHES_NO_ROWS` is exported from `@voltro/database`.** They wrote `eq('id', '')` for "this caller sees nothing", because the natural spelling `inSet(col, [])` is the one that is dangerous in most query builders: an empty `IN ()` gets dropped, and a dropped predicate does not narrow, it WIDENS to the whole tenant. Here it is safe — the SQL compiler emits `FALSE`, the memory evaluator returns false — but that held by accident, with no test enforcing it. Now pinned in both evaluators, including the mirror case: an empty `notIn` matches EVERYTHING.
134
+
135
+ *The doctor rule was itself wrong when first written: it read the normal source set, which excludes test files, so it could never observe the passing case and would have fired unconditionally on every app. Caught by writing the negative test.*
136
+ - **@voltro/database, @voltro/plugin-versioning, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-mssql, @voltro/sql-sqlite** — **`versioningPlugin({ timing: 'in-transaction' })` — the history row commits or rolls back WITH the change it records.** Default stays `'post-commit'`.
137
+
138
+ Post-commit recording is lossy by construction: between COMMIT and the forked history write there is a window, and a process that dies inside it leaves the change permanent and the trail silent. The size of the window is not the point — the direction is. A missing entry cannot be told apart from "nothing happened", so a trail that can lose entries proves nothing. Retry does not close it either; the process that would retry is the one that died.
139
+
140
+ This was the sole reason an adopting team could not replace their hand-rolled audit writer — called from **315 of 358 mutation handlers**, with the 43 misses being what happens to any rule that depends on someone remembering.
141
+
142
+ **What a recorder receives is an `append`, not a store.** Bound to the caller's transaction, insert-only. It cannot open a nested transaction (which throws by design), cannot read-modify-write its way into a deadlock, and "append-only" stops being a docs claim and becomes the shape of the only thing it is handed.
143
+
144
+ **The rejection is the guarantee, not a defect.** When the history insert fails, a transaction offers exactly two outcomes: the mutation fails with it, or the error is swallowed and the change commits without its entry — which is post-commit's hole with the cost already paid. There is no third option, so recorders do not swallow.
145
+
146
+ **Why the default did not move.** In-transaction makes the history table a hard dependency of every covered write path: its availability becomes your write path's availability, and every covered write holds locks longer. Post-commit loses at worst one entry; in-transaction can at worst stop writes to the covered tables. Right trade for a compliance trail, wrong one for the undo / time-travel use this plugin also serves.
147
+
148
+ **It refuses the in-memory store at boot** rather than silently no-op'ing. `memory` is the default dev store; an option that appears to work where it is cheapest to try and stops where it matters is worse than one that says so. Two limits hold in both timings and are documented: `store.raw()` produces no change event and is absent from the trail, and a write made outside a transaction is recorded immediately after rather than atomically.
149
+
150
+ *Two design claims in the plan for this were wrong and are corrected there. "Twelve write sites across three layers, no funnel" was true of `storeMiddleware` and irrelevant — each dialect store funnels every write through one private `routeEvent`, which is where this hooks. "Bulk writes have no per-row post-image" was simply false: `updateMany` already issues `RETURNING *` and emits one event per affected row, so bulk needed no special case at all. Both were found by looking in the middleware instead of the store — twice.*
151
+
152
+ ### Fixed
153
+
154
+ - **@voltro/cli** — **A `voltro serve` that refuses to boot says why, instead of blaming the serve bundle.** A deliberate refusal — a missing `VOLTRO_SESSION_SECRET`, a `.serverOnly()` leak — came out of the launcher as:
155
+
156
+ ```
157
+ [voltro] serve bundle failed to load: VOLTRO_SESSION_SECRET is not set — refusing to serve. …
158
+ [voltro] FATAL: production `voltro serve` requires a precompiled serve bundle at …
159
+ but it is missing or failed to load. Run `voltro build` before serving …
160
+ ```
161
+
162
+ The real reason is on the first line, under a wrong headline, followed by a louder and more confident wrong instruction. An operator whose first deploy forgot the session secret is told to rebuild an artefact that is fine — and rebuilding it produces the identical output, so the loop has no exit.
163
+
164
+ The cause is a catch that has to exist: `bin/voltro.mjs` imports the precompiled serve bundle inside a `try`, because an unusable bundle must degrade to the tsx path rather than kill the boot. It could not tell "this artefact is broken" from "this app decided not to start". Refusals now carry a marker (`bootRefusal.ts`) and the launcher prints them and exits 1.
165
+
166
+ The marker is the error's `name`, a plain string, rather than a class: the serve bundle INLINES the framework, so the thrown Error crosses an instance boundary where `instanceof` does not survive — the same reason the core-table registry is keyed by `Symbol.for`.
167
+
168
+ Found by running the new `.serverOnly()` gate against a real fixture rather than by reading it, which is also how the misleading pair came into view: the session-secret case had been shipping that way for a while.
169
+ - **@voltro/sql-postgres, @voltro/sql-mysql** — **`insertIgnore` explains a second-unique conflict instead of reporting an internal invariant.** The message was `row conflicted but lookup found nothing`, which tells a caller nothing they can act on.
170
+
171
+ It is reachable by ordinary means, and on mariadb it is the *common* path: `INSERT IGNORE` swallows ANY unique violation, so a row with a fresh `id` and a duplicate `slug` is skipped, and the lookup by the named `conflictColumns` then finds nothing. A team with `tenants (id PK, slug UNIQUE)` hits it on the first duplicate slug.
172
+
173
+ The message now names the columns that were checked, the table, and the actual cause — a different unique constraint fired, and `insertIgnore` models one conflict target. This is step 1 of `plans/framework-insertignore-any-unique.md` and is deliberately independent of the feature: whether or not `conflictColumns: 'any'` ever ships, this error should have been readable.
174
+ - **@voltro/cli** — **`ui/unlinked` and `ui/orphaned` resolve through barrel re-exports.** A `*.component.ui.tsx` reached only via `export { X } from './x'` was reported as unrendered, however many pages actually rendered it.
175
+
176
+ On the app that reported it, `PageContent` is imported by 42 pages — every one of them through `@/components/shared` — and doctor said `imported only by: index.ts, its own test`. It was the last false positive standing after the alias fix took that app from 16 findings to 2.
177
+
178
+ The rules ask "does anything RENDER this". A barrel is a real importer and renders nothing, so stopping at the first importer answers a different question than the one asked — but only in an app that has an `index.ts`, which is why it survived.
179
+
180
+ The walk is narrowed by NAME rather than opened up wholesale: a downstream file counts only if it imports one of the names the barrel republishes from that file (aliases followed, `export *` expanded to the file's own exports, type-only re-exports ignored — they publish nothing at runtime). Without that narrowing, `export *` on a 40-entry barrel would credit its entire readership to every entry and `ui/orphaned` would quietly stop finding anything — trading a visible false positive for a silent false negative. There is a test for exactly that direction: a barrel with a used entry and a dead one must still report the dead one.
181
+
182
+ ### Internal (no consumer-facing effect)
183
+
184
+ - **@voltro/protocol** — **`PluginHttpRouteRequest.store` names all four absences instead of one.** The docstring said "It is not tenant-scoped" and left soft-delete filtering, audit stamping and row-level security to be inferred from "everything that does not need a Subject".
185
+
186
+ A team planning to port 19 raw-SQL sites onto that seam inferred the opposite: they wrote down "a store read adds `deletedAt IS NULL`" as the trap with teeth on their list — a soft-deleted user logging back in would go from "found and revived" to "not found → insert → unique violation on email" — and deferred the whole port partly over it. The store does no such thing; it is the raw store plus the storage codec. Naming exactly one of four absences reads as an exhaustive list.
187
+
188
+ The docstring now carries the same table the `AuthStrategyInput.store` docs do, with the soft-delete row called out for anyone porting: a read here returns tombstones the way their SQL did, so a lookup that must see one needs no opt-out. (`.withDeleted()` is the opt-out on `ctx.store`, which *does* apply the filter.) Doc-only; the behaviour is unchanged and was already correct.
189
+ - **`ci.yml` gains a `workflow_dispatch` trigger.** The full matrix — 11 database services plus the SQL Server AG init containers — is not reachable from a push to `main`: `paths-ignore` plus the job-level `if: github.event_name != 'push'` mean a main push runs static checks only.
190
+
191
+ So the only ways to exercise it were a pull request and the release gate, which meant a change to the workflow itself could sit unrun until it fired for the first time INSIDE a release — where a failure costs a ~40-minute round-trip and blocks the publish. That is precisely the position this repo was in.
192
+
193
+ ---
194
+
195
+ ## [0.18.0] — 2026-07-28
196
+
197
+ ### ⚠ BREAKING
198
+
199
+ - **@voltro/database** — **`_internalCmp` is no longer exported from `@voltro/database/sql`.**
200
+
201
+ It was re-exported from the migration planner with the comment "exported for tests that want a custom column sort". No test ever imported it — not in `@voltro/database`, not in another package, not in any sibling repo. It was published surface that existed for nobody, and it stayed invisible because the API goldens covered only each package's main entry until now.
202
+
203
+ `codemod: none` because nothing can plausibly be importing it: a grep of every workspace package and every sibling repo finds the export line and no call site. `cmp` remains the local helper it always was; if a test genuinely needs it, export it again together with the caller that justifies it.
204
+ - **@voltro/database, @voltro/runtime** — **`.where(col, 'like', value)` is removed, and `startsWith` takes the job it was pretending to do.**
205
+
206
+ `'like'` never behaved like SQL LIKE. It mapped to `contains` — `%…%` around the value, case folded on both sides — so `.where('path', 'like', '/api/%')` matched only rows literally containing the characters `/api/%`, and the wildcard the caller wrote did nothing. The docs advertised `.where('col', 'like', 'abc%')`, which is exactly the shape that silently returns nothing.
207
+
208
+ In its place, a real prefix predicate:
209
+
210
+ ```ts
211
+ .where('key', 'startsWith', 'awb_') // ergonomic form
212
+ where(startsWith('key', 'awb_')) // predicate helper
213
+ jsonField('config', 'ns').startsWith('awb_') // inside a json() column
214
+ ```
215
+
216
+ `startsWith` is **case-SENSITIVE**, unlike `contains`, and the asymmetry is the design rather than an oversight. `contains` is a search primitive — a human typing into a box means `hello` to find `Hello`. A prefix is a NAMESPACE: `awb_` and `AWB_` are two different key spaces, and quietly merging them is a bug. It also matches the JS method it is named after.
217
+
218
+ It is the one string predicate a database can answer from an index: it lowers to `LIKE 'literal%'`, which a btree can range-scan. `contains` (`%…%`) cannot, which is also why there is no `endsWith` — a second un-indexable operator would only look cheaper than it is. `%` and `_` in the value are escaped, so they match literally, and the ESCAPE clause is stated per dialect (sqlite and mssql have no default escape character; mysql already has backslash *and* treats it as a string escape, so the clause is omitted there).
219
+
220
+ **Migration** is deliberately manual. Rewriting `'like'` → `'contains'` would reproduce exactly what runs today, bug included, and report the migration as complete — while every site that used a wildcard keeps returning nothing. Each call site is one of two things and only its author can tell which: a wildcard pattern (→ `startsWith`, and that query has been wrong until now) or a substring search spelled oddly (→ `contains`, no behaviour change).
221
+
222
+ ### Added
223
+
224
+ - **@voltro/protocol** — **`apiKeyStrategy`'s `resolveKey` receives the strategy input** — the same object a hand-written `AuthStrategy.resolve` gets, as a second argument:
225
+
226
+ ```ts
227
+ apiKeyStrategy({
228
+ prefix: 'awb_',
229
+ resolveKey: async (hash, { store }) => {
230
+ const rows = await store?.query(apiKeys.byHash(hash))
231
+ return (rows?.[0] as ApiKeyRecord | undefined) ?? null
232
+ },
233
+ })
234
+ ```
235
+
236
+ `AuthStrategyInput.store` shipped two releases ago and this helper was the one auth seam that could not reach it: an app using `apiKeyStrategy` had to wrap it in its own strategy purely to close over a store the framework had already handed over — or keep the second connection path to the same database that the seam exists to delete. Existing one-argument resolvers are unaffected.
237
+ - **@voltro/testing** — **`describeIfReachable` — because "the database was not there" must not look like "the database was fine".**
238
+
239
+ Every integration and dialect-parity suite probes a TCP port and bails when the service is down. The bail was hand-written each time, and the hand-written form ends a test body with an early `return` — which is a PASS. So with no database at all, a suite reports `Tests 2 passed`. The only trace is a smaller duration, which nobody reads, and vitest swallows the accompanying `console.warn` by default, so even the intended signal never prints.
240
+
241
+ Found by running the postgres introspection suite against port 1: `2 passed`, having connected to nothing. That suite guards the fix for a boot hang, so a green run there was load-bearing evidence that meant nothing.
242
+
243
+ `describeIfReachable(label, target, suite)` makes the honest outcome the default: an unreachable target produces a vitest SKIP — reported as `skipped`, counted separately, with the missing service named in the suite label. "We did not verify this" and "we verified this and it holds" no longer print the same.
244
+
245
+ Same class as the changelog and message-API selftests: a check that has quietly stopped checking still prints green, and green is read as evidence.
246
+
247
+ **All 36 suites carrying that shape are converted** — every one was fully gated, so wrapping the suite loses no test. Verified in both directions, because only one of them is obvious:
248
+
249
+ | | before | after | |---|---|---| | no services running | 105 tests **passed** | **0 passed, 92 skipped** | | the full stack up | 105 passed | **all pass, 0 undeclared skips** |
250
+
251
+ The second row is the point — the sweep did not quietly turn a suite off.
252
+
253
+ **It paid for itself immediately, three times.**
254
+
255
+ *Twelve replication tests had never run.* postgres streaming replica, mysql GTID replica, mssql Always-On AG — left out of CI on the grounds that starting them OOMs a standard runner, so they reported `passed` in every run without once executing. Replication and failover: precisely the behaviour nobody can verify by reading it. Measured rather than argued: baseline 2091 MiB, `postgres-replica` **82**, the mysql pair **1335**, the AG pair **2035**. The OOM claim is true of the WHOLE compose file (keydb, dragonfly, valkey, redis cluster) and not of these. `ci.yml` now starts them — plus the two one-shot containers that actually FORM the availability group, without which both nodes are healthy and the suite still cannot connect.
256
+
257
+ *Eighteen cache tests had tested one engine out of four.* The RESP suite iterates redis / valkey / keydb / dragonfly; only redis was started. The other three cost **27 MiB between them**.
258
+
259
+ *Three SQL Server instances were fighting over memory.* With the AG nodes running beside `mssql-test`, two mssql round-trip suites failed — and passed when run alone, which is how they would have been dismissed as flakes. Each instance now declares `MSSQL_MEMORY_LIMIT_MB`, so the stack is the same size on a laptop and on a runner. Under the full gate, with every package's suite running in parallel, sql-mssql is 59/59.
260
+
261
+ **What remains skipped is skipped for a reason, and the reason is written down.** Four tests, in `sql-sqlite` and `sql-turso`: `clusterTestSuite` gates cross-process resume on `clusterResume`, which needs the workflow runner's state in SQL, and neither dialect keeps it there. Not a missing service — a capability that does not exist for that dialect. That distinction is the whole content of the allowlist.
262
+
263
+ ### Fixed
264
+
265
+ - **@voltro/runtime, @voltro/cli, @voltro/protocol** — **The boot store (`AuthStrategyInput.store`, `PluginHttpRouteRequest.store`) applies the storage codec.** `.encrypted()` columns decrypt on read and encrypt on write, and array columns round-trip on dialects with no native array type.
266
+
267
+ Both seams are documented as "not tenant-scoped", which is correct and unavoidable: they hand out a store to code that runs BEFORE a Subject exists, so tenant scope, soft-delete filtering, audit stamping and row-level security genuinely cannot apply. What that was silently taken to mean is "the raw driver", and it dropped two things that need no Subject at all.
268
+
269
+ The consequence was a silent wrong answer rather than an error. An auth strategy reading an `.encrypted()` column got the literal string `enc:v1:…` back — which compares, concatenates, renders and logs perfectly well, and simply never matches the token it is compared to. The failure surfaces as "wrong credential". On the write side it was worse and unrecoverable: an insert through that store wrote PLAINTEXT into a column the schema declares encrypted.
270
+
271
+ The line is now **everything that does not need a Subject**, not "less than `ctx.store`". Wired in `voltro dev` and `voltro serve` in the same change (`wrapStoreWithBootCodec`); `raw()` is deliberately left as a pass-through, since it is the documented escape hatch for hand-written SQL and re-encoding rows a caller asked for verbatim would be its own surprise.
272
+
273
+ **Upgrading: a reader that compensated for the missing codec by hand now gets the decoded value.** Two shapes, and they land differently. A hand-rolled `decryptField` on the way out is HARMLESS — it passes a non-`enc:v1:` value through unchanged, so the call simply stops doing anything. A hand-rolled `JSON.parse(row.someJsonColumn)` on a column the codec now deserialises is NOT: it is handed an object and throws. If you read an `.encrypted()` or array column through `AuthStrategyInput.store` / `PluginHttpRouteRequest.store` and unpacked it yourself, grep those call sites — the ones typed as a `string | ReadonlyArray<string>` union already survive, a bare `JSON.parse` does not. (Reported by an adopter whose two such reads happened to be written defensively, which is why their upgrade was silent.)
274
+ - **@voltro/cli** — **The remaining file-moving codemods narrow their projects too**, and the shared project is no longer a stale snapshot.
275
+
276
+ `0.14.0/03_pages-suffix` is gated on `/src/pages/` throughout — plus each app's `app.config.ts`, which is how it tells a real web app's pages from a library that merely keeps components under `src/pages/`. `0.15.0/01_undo-mistaken-taxonomy-renames` only ever renames `*.component[.ui].ts(x)` and an export-less `*.types.ts(x)`. Both now declare that scope and take the importer closure instead of the workspace.
277
+
278
+ `0.14.0/04_file-taxonomy` deliberately keeps the whole project: its `isCandidate` is "any `.ts`/`.tsx` that does not already carry a convention", so there is nothing to narrow and pretending otherwise would only move the cost.
279
+
280
+ **And a real bug the equivalence test caught.** The shared project was built once, lazily, at the first whole-project codemod — *before* a narrowed one wrote its renames to disk. So `0.14.0/04` ran against the pre-`03_pages-suffix` tree and classified `alpha.tsx` as a component, where the whole-project run produced `alpha.page.tsx`. The shared project is now dropped whenever a narrowed codemod changes the disk, and each shared codemod saves before the next narrowed one reads it — the disk is the single source of truth in both directions.
281
+
282
+ Nothing in either codemod's own output hinted at it: both reported success, with different results. Verified by running the same fixture through the 0.13.0 → 0.16.0 jump both ways and diffing the trees; identical, including an aliased and a relative importer outside every scope.
283
+ - **@voltro/cli** — **`voltro update` sizes the codemod pass's heap from the machine.** Node caps the old space near 4 GB regardless of installed RAM, so a large monorepo died on a machine with memory to spare — and died in V8, with no line naming a limit.
284
+
285
+ The pass holds one ts-morph project whose cost is roughly linear in the file count: measured at ~97 KB per file on a synthetic fixture (818 MB at 2,320 files, 1,399 MB at 8,320 — median of three runs on an otherwise idle machine). An adopter's repo needed ~30 GB; at node's default it aborted in 83 seconds.
286
+
287
+ The re-exec'd child now gets `--max-old-space-size` at 75% of total RAM, leaving room for the OS. An explicit `NODE_OPTIONS` from the caller always wins, and nothing is set when 75% would be *below* node's own default — a lower ceiling than node would pick is a pure regression.
288
+
289
+ This does not make an impossible run possible; it stops an arbitrary limit from being the binding one. Where the machine genuinely lacks the memory, the scan's file ceiling reports it in words.
290
+
291
+ **Measured, not assumed — and three plausible fixes were measured and rejected first**: replacing the runner's per-codemod full-text bookkeeping, releasing ts-morph's node-wrapper cache, and hoisting the aliased-importer resolution out of the move loop. A fourth, chunking the pass into per-codemod projects, is correct (109/109 codemod tests pass with one file per project) but does not flatten the curve — peak still grows ~97 KB per file either way, because the codemods that move files must see the importers and so keep the whole-project view.
292
+ - **@voltro/cli** — **A codemod that moves files no longer loads the whole workspace.** Peak memory now follows what the codemod touches instead of how large the user's repository is.
293
+
294
+ A mover has to see its importers — `SourceFile.move()` rewrites the relative specifiers pointing at the moved file and `moveCarryingAliasedImports` does the same for aliased ones, but only for importers that are IN the project. So the movers took everything, and everything is what made the pass cost ~107 KB per file. An adopter's monorepo needed ~30 GB and died in a V8 abort.
295
+
296
+ `codemodImporterClosure` answers "who imports these" from TEXT: one streaming pass that extracts module specifiers and discards the source. A codemod declaring `scope` + `needsImporters` then gets a project of its scope plus that closure. `0.17.0/01_pages-are-directories` — the most expensive codemod in a run — is the first to use it; it only ever touches files under `src/pages/`, so the rest of the repo was pure cost.
297
+
298
+ Measured on the 0.16.0 → 0.17.0 jump, 300 pages, synthetic fixture:
299
+
300
+ | files | narrowed | whole project | |---|---|---| | 2,320 | 554 MB | 720 MB | | 8,320 | **617 MB** | 1,359 MB |
301
+
302
+ ~10 KB per file instead of ~107 KB — the curve is flat, which is the point: the run no longer gets harder because the repository grew.
303
+
304
+ **The closure's matching rule is the same one `rewriteAliasSpecifier` applies**, so a file it leaves out is one the mover would not have rewritten anyway. Directory names are indexed too, because `import x from './settings'` resolving to `settings/index.page.tsx` names the directory and never the stem — and `move()` rewrites that specifier.
305
+
306
+ Verified by equivalence, not by inspection: the same fixture run both ways produces byte-identical trees, including an aliased and a relative importer living OUTSIDE the codemod's scope. The first version of this failed that test — the specifier index was built over the union of the selected scopes, which no longer contains the importers once a codemod narrows its own.
307
+ - **@voltro/cli** — **The codemod scan's file ceiling now applies to the git enumeration path**, which is the path every real project takes.
308
+
309
+ `enumerateScopedFiles` has two branches: `git ls-files` when the root is a repository, and a glob walk otherwise. The ceiling — with the error that explains what to exclude — sat in the glob branch only. So a git repository walked straight past it, loaded the whole workspace into one ts-morph project, and died in V8 with no line naming a heap. An adopter measured it: 4 GB aborts in 83 s, 24 GB after 14 minutes, 30 GB completes in ~9 minutes. "It died" was the entire diagnosis available to them — from a guard written to prevent exactly that.
310
+
311
+ The message now also says WHY the count matters (every matched file is parsed into one project), and names three ways out in order: `.gitignore` for non-source trees, running from the app directory, or `--only <id>` one codemod at a time. `VOLTRO_CODEMOD_MAX_FILES` raises the ceiling for a workspace where the whole set really is source, alongside `NODE_OPTIONS=--max-old-space-size`. The limit is read at call time rather than frozen at import.
312
+
313
+ Regression cover asserts BOTH branches, and the git one was verified by removing the call and watching that test go red.
314
+ - **@voltro/cli** — **`component/one-per-file` counted things that are not components — and its message asserted they were, which is what made it expensive.**
315
+
316
+ The classifier read the first letter of the exported NAME. So two shapes reported clean code as broken, both found by an app adopting the taxonomy across 658 files:
317
+
318
+ - `export const DEFAULTS = { a: 1 }` beside a component was reported as `exports 2 unrelated components: DEFAULTS, OnlyOne`. A plain object, named as a component. That app split a five-line constant into its own file to satisfy a rule that was misfiring. - `export default OnlyOne` next to `export const OnlyOne` was reported as two components, the second one named `Default`. One binding, exported twice — the export FORM was being counted, not the component.
319
+
320
+ Classification now comes from the DECLARATION: a function, a class, an `FC`-annotated binding, a `memo`/`forwardRef`/`observer` wrapper, a tagged template. An object, an array, a string, a number, a `new` — not components. The `default` entry resolves to the declaration it names and de-duplicates on the node, so the same component cannot be counted once per export form.
321
+
322
+ The classifier is deliberately GENEROUS about the unknown, because the two error directions are not symmetric: calling a component "not a component" makes the rule report `exports no component` on a correct file, which is worse than letting one unusual value through.
323
+
324
+ The catalogue said a `*.component.tsx` promises "exactly one component **(+ types)**", which reads as "types are the only exception". It never was — the rule counts components, and a `const COLUMNS = […]` beside the table that renders it was always allowed. The docs now say so in both languages.
325
+ - **@voltro/cli** — **The seeded `AGENTS.md` / `CLAUDE.md` never mentioned `.serverOnly()` — the one marker that decides leak vs no leak.**
326
+
327
+ Reported from a strict-mode pass over five apps: the template documents `.encrypted()`, `audit()`, `tenant()`, `softDelete()`, `.check()`, `validate()` — and not the marker that decides leak vs no leak. *(Corrected after publication: this entry said the boot audit "hard-fails" on it. It did not — it was a single `log.warn`, in every command. The next release makes the claim true; see that entry.)* Depth existed (`database/sensitivity`, `data/crud`, both languages); the always-loaded core simply never pointed at it, so an agent or a human following the template never learned the marker exists.
328
+
329
+ The core now carries a short section on the three markers as ORTHOGONAL questions, because the substitution is the real hazard:
330
+
331
+ | Marker | Answers | Enforced by | |---|---|---| | `.serverOnly()` | may this leave the server at all? | an audit — see the correction above | | `.sensitive()` / `.safe()` | may it appear in an export? | the masking profile, fail-closed | | `.encrypted()` | is it encrypted at rest? | the store's codec |
332
+
333
+ Reading `.encrypted()` as "safe to expose" is a category error and a plausible one — the runtime decrypts for the handler, so an encrypted column reaches a client like any other unless it is ALSO `.serverOnly()`.
334
+
335
+ **The doc-drift guard is why this survived, so it grew the missing half.** It asserted the core carries the EXPORT axis (`.sensitive(`, fail-closed, the export endpoint) and said nothing about the wire axis. There is now a matching assertion for `.serverOnly()`, the enforcement, and the orthogonality — verified by deleting the section and watching it go red. *(It asked only for the words "boot audit", which the template supplied while promising a failure nothing performed. The next release makes it name the commands that gate.)*
336
+ - **@voltro/cli** — **`voltro dev` could not start at all in a strict-pnpm install: the supervisor respawned with a bare `--import tsx`.**
337
+
338
+ Node resolves a bare specifier against the CHILD's working directory. `@voltro/cli` declares tsx; the user's app does not — so under a non-hoisting layout tsx lives inside `node_modules/.pnpm/@voltro+cli@…/node_modules/tsx` and is invisible from the app root. The first respawn died with `Cannot find package 'tsx'`, naming a package the reader never asked for and cannot usefully install.
339
+
340
+ `bin/voltro.mjs` had already learned this and resolves an ABSOLUTE URL before spawning. The supervisor then threw that answer away and re-derived a worse one.
341
+
342
+ Every spawn site now goes through `tsxLoaderArgs()`, which prefers the loader already present in `process.execArgv` — literally the absolute path the shim computed, and inheriting it also preserves the user's own node flags (`--inspect`, `--max-old-space-size`), which a hand-built flag list silently dropped. Behind that: `VOLTRO_TSX_IMPORT`, now exported by the shim for grandchildren that are not themselves loader-registered; then resolution from `@voltro/cli` itself, the package that depends on tsx. The bare specifier survives only as a last resort, and it now says so instead of failing mutely.
343
+
344
+ Three of the five spawn sites were already correct — `envWatch`, the web-dev respawn, and the shim — and nothing said the other two were wrong. A test now fails if any non-test file under `src/` writes the flags by hand.
345
+ - **@voltro/cli, @voltro/devtools** — **The in-page devtools overlay had no way to authenticate against a fail-closed inspect surface — and the one channel it documented was disabled two directories away.**
346
+
347
+ Since `/_voltro/inspect/*` went fail-closed, the overlay's Traces / Webhooks / Indexes panels needed a bearer token. A browser cannot be given one: the only channel that would reach it is a `VITE_`-prefixed env var, i.e. a live credential compiled into every bundle, which this framework refuses to do anywhere. So the overlay pointed at `VITE_VOLTRO_INSPECT_TOKEN` — which `voltro dev` and `voltro build` both make unreadable on purpose, by setting vite's `envPrefix` to a sentinel that matches no real variable. A reader who followed the docs set the variable, got no header, and had no way to see why.
348
+
349
+ Three changes, one shape:
350
+
351
+ - **`voltro dev`'s vite proxy attaches the minted bearer server-side** on the `/_voltro/api/<name>` route the panels fetch through. Nothing to configure, and the token never reaches the page. It refuses two cases deliberately: a caller that already sent an `Authorization` header (the dashboard forwards a real one — overwriting it would re-scope somebody else's request), and a non-loopback target (`proxyTarget` is user config, so injecting unconditionally would hand this machine's credential to a host we do not control). - **The dead `VITE_VOLTRO_INSPECT_TOKEN` fallback is gone.** `<VoltroDevtools inspectToken>` remains for reaching an api the proxy does not front, and its doc now says plainly that whatever you pass ships in the bundle. Its test previously admitted, in a comment, that it asserted the null path and called it the fallback — which is how a documented-but-dead channel survived a green suite. - **The empty-state copy said inspect was "open in dev mode".** That stopped being true when the surface went fail-closed, so a reader who hit a 401 was told by the panel itself that it could not have been an auth failure. It now names the remedy.
352
+
353
+ Separately: the `indexes` tab-badge count polled with the overlay CLOSED. Its two neighbours (`traces`, `webhooks`) take an `enabled` flag; this one was missed, and it is the expensive one — it holds an `EventSource` open per api for the whole life of the page, plus a fallback poll whenever that stream errors.
354
+ - **@voltro/cli** — **`voltro doctor`'s boundary rules could not follow a `@/`-aliased import, so they under-reported — silently.**
355
+
356
+ The file-taxonomy walker resolved relative specifiers and nothing else. On an app that imports through a tsconfig `paths` alias — which is most of them — every such edge was invisible, and a rule that cannot see an edge cannot fire on it:
357
+
358
+ - `internal/foreign-import` and `fixture/production-import` came back CLEAN on code that violates them. That is the dangerous direction: no findings reads exactly like no problems. - `ui/unlinked` fired on all 16 of one app's presentational components, because each was reached only through `@/components/…`. A rule that fires on correct code teaches people to ignore it.
359
+
360
+ The graph now resolves aliases from the NEAREST `tsconfig.json` walking up to the scan root — `voltro doctor` runs at the project root while `@/*` is declared per app, so reading only the root config found no `paths` at all in the layout we scaffold.
361
+
362
+ Two more edge forms were missing for the same reason: `export … from` and dynamic `import()`. The re-export one is not a completeness flourish — a barrel is the file most likely to reach across a feature boundary, so `export { x } from './orders.internal'` is precisely the case `internal/foreign-import` exists to catch, and it was the one shape the rule could not see.
363
+ - **@voltro/cli** — **The release gate now fails on a skipped test it was not told about.** "All green" has to mean everything RAN, or the total is a number about how little was attempted.
364
+
365
+ Locally a skip stays fine — nobody should need six databases to run `pnpm test`, and a suite that fails without them stops being run at all. In CI it is not fine: a skipped test is an unverified claim wearing the same colour as a verified one.
366
+
367
+ `scripts/check-no-skipped-tests.mjs` reads the per-package vitest summaries out of the test step and fails on anything skipped that is not declared in its `ALLOWED` map with a reason and an **exact** count. Both directions are enforced, and the second is the one that matters:
368
+
369
+ - more skips than declared → something stopped running; - **fewer** skips than declared → the entry is stale, and a stale allowlist silently absorbs the next regression. That is the failure mode an allowlist has instead of the one it removes, and it is only survivable if the list is forced to stay exact.
370
+
371
+ It runs `--selftest` first, like the changelog and message-API gates, for the same reason: a check that has quietly stopped detecting anything still prints green. Both of its rules were verified by breaking them and watching the selftest go red — the ANSI stripping and the stale-entry direction.
372
+
373
+ **Two kinds of skip exist and only one is a defect.** "The dependency was not there" is a coverage gap — start the service. "This does not apply to this configuration" is correct — declare it. The allowlist holds exactly the second kind: four tests in `sql-sqlite` and `sql-turso` whose dialects keep no workflow-runner state in SQL, so cross-process resume is not a thing they can do. The 12 replication tests that would have been the first entries were the FIRST kind, and cost 3.4 GB on a 16 GB runner — `ci.yml` starts their services instead of declaring them away.
374
+
375
+ The check found both of its first three catches on its own first run: 18 cache tests covering one RESP engine of four, and the two dialect suites above. It also caught itself — it passed in 0.2 s on a run whose test step had aborted after 0.5 s, because an empty log has nothing to complain about. A log with no vitest summaries is now a failure, with its own selftest case.
376
+ - **@voltro/cli** — **Four `voltro dev` inspect endpoints answered 200 to any caller: `traces`, `webhooks`, `analytics`, `aggregates`.**
377
+
378
+ `handleInspectRequest` gates everything that reaches it. The branches in front of it are EARLY RETURNS — they answer and never reach it — so each had to remember to gate itself, and four did not. `traces` is the sharp one: an adopter measured 38 KB of live spans from an unauthenticated `curl`, a request-by-request record of what the process just did. Per the tracing docs those spans also carry `rpc.tag`, `subject.type` and `tenant.id`.
379
+
380
+ That is the split `0.12.0` was written to close — *"the absence of a secret is not consent"* — with `metrics` and `logs` gated and `traces`, which is strictly more revealing than either, not.
381
+
382
+ **Scope, because the reporter could not test it and asked: `voltro serve` and `voltro start` are NOT affected.** Neither mounts these branches — `serveApi.ts` contains no `/_voltro/inspect` path at all, and `start.ts` goes through the shared, gated `handleInspectRequest`. The leak is dev-only, which lowers the severity without removing it: a dev server on a shared machine or a bind-mounted container is not private either.
383
+
384
+ The gate now runs ONCE at the door, before any branch, so a new branch cannot be added without it. CORS preflight stays exempt — a browser sends `OPTIONS` with no `Authorization` header by construction, and the preflight carries no data.
385
+
386
+ **The failure mode was already written down one level below.** `inspectLogsEndpoint` carries the comment *"gating per-caller drifted (start was ungated, dev gated nothing, webDev gated disabled-but-not-token)"* and single-sources the gate inside the handler. The lesson was right; it was applied at the wrong depth.
387
+
388
+ **One branch changed behaviour beyond the four: `POST /_voltro/inspect/clientLog` on the API.** It is an ingest endpoint, so the risk it carried was injection rather than disclosure — anyone could write lines into the developer's terminal log. Nothing in the framework posts there: `@voltro/web`'s browser bridge ships to its own origin, which the web dev server serves and deliberately leaves ungated (a browser has no token, and that path accepts only log batches). The API's copy is reached by direct callers, which can carry one.
389
+
390
+ **The overlay is carried across this**, in the same release: the Vite dev proxy now attaches the minted bearer server-side (see the devtools-overlay entry), so the panels stay live and the browser still never holds the token.
391
+ - **@voltro/web** — **`<Link ref>` typechecks, which it always should have — the runtime forwarded it all along.**
392
+
393
+ `Link` spreads every prop it does not consume onto its `<a>`, and React 19 hands `ref` to a function component as an ordinary prop, so a ref has always reached the anchor. `LinkProps` extends `AnchorHTMLAttributes`, which carries no `ref` — so the type refused behaviour that worked.
394
+
395
+ Not cosmetic: every polymorphic slot that threads a ref through — `<Button component={Link}>` in MUI, Chakra's `as`, any `component=` escape hatch — was a type error. A consumer shimmed `Link` app-wide to get past it.
396
+
397
+ The test covers both halves, because neither alone is enough: a `satisfies LinkProps` that stops compiling if `ref` leaves the type, and a jsdom render asserting the ref (object AND callback form — the slots use callbacks) actually lands on the anchor.
398
+ - **@voltro/database** — **Postgres FK/PK introspection reads `pg_catalog`, not `information_schema` — this killed a `voltro dev` auto-migrate hang that never finished.**
399
+
400
+ This shipped in code without a changelog entry, so nobody upgrading was told either that the hang was fixed or that a new env var exists. Recording it now, with the numbers measured on our own hardware rather than taken from the report.
401
+
402
+ On a FK-dense schema, `voltro dev` hung indefinitely at `auto-migrate: planning schema` — pod 0/1, no error, no timeout. The FK query 4-way-joined `information_schema.{table_constraints, key_column_usage, constraint_column_usage, referential_constraints}`. `constraint_column_usage` is a security-barrier view whose `table_name IN (…)` predicate does **not** push down, so every batch re-scanned FK metadata for the whole catalog. The `blocked` refuse-gate sits *after* introspection, so its helpful message never printed.
403
+
404
+ Measured against a live 532-table / 2637-FK postgres 17:
405
+
406
+ | | one batch of 20 tables | full introspection | |---|---|---| | `information_schema` | **2041 ms** | ~55 s extrapolated over 27 batches | | `pg_catalog` OID join | **8.7 ms** | **404 ms cold, 368–374 ms warm** |
407
+
408
+ Both return the identical 90 rows for that batch, so this is a correctness-preserving rewrite, not a narrowing. `PgFkRow` and `mapPgRule` are unchanged — `confdeltype`/`confupdtype` chars map back to the information_schema rule tokens in SQL. Multi-column FKs pair positionally via `unnest(conkey/confkey) WITH ORDINALITY`; composite PKs keep declared (`conkey`) order.
409
+
410
+ Batching is kept, not removed: it exists for pooler mis-framing of large responses, and with the OID join the batch filter pushes down, so each batch scans only its own tables.
411
+
412
+ **New env var: `VOLTRO_INTROSPECT_TIMEOUT_MS`** (default 30000, `0` disables). The whole postgres introspection runs in one read transaction under `SET LOCAL statement_timeout`, so a query that ever degenerates again dies with an actionable error instead of freezing a pod at 0/1. `SET LOCAL` reverts at commit — no pooler session-state leak.
413
+
414
+ MySQL/MariaDB were never affected: their FK query already joins `key_column_usage` to `referential_constraints` with no `constraint_column_usage` cross-join.
415
+ - **@voltro/cli** — **`voltro update --only <id>` works in the spelling the tool itself prints**, and an unknown flag is now refused instead of ignored.
416
+
417
+ `--only` was read by a separate pass that the positional-argument loop knew nothing about, so the flag was skipped and its VALUE — which does not start with `--` — fell through and was resolved as the app directory. The report named a path the user never typed:
418
+
419
+ ```
420
+ $ voltro update --codemods-only --only 0.17.0/01_pages-are-directories
421
+ voltro update: no package.json at …/web/0.17.0/01_pages-are-directories
422
+ ```
423
+
424
+ Both the usage text and the dirty-tree hint print `--only <id>`, so the documented form was the broken one. `--only=<id>` and `--only <id>` now both work, on `voltro update` and on the `_apply-codemods` entry it re-execs into.
425
+
426
+ Separately, an unrecognised flag now exits 1 with `unknown flag <name>`. A typo'd `--codemod-only` (singular) used to be dropped silently — and the command then ran the FULL update, bump and install included, on a tree the user had asked to touch as little as possible.
427
+ - **@voltro/cli** — **`voltro update` dying of memory now says that is what happened.**
428
+
429
+ An adopter upgrading 0.14 → 0.17 hit it twice — 4 GB after 83 s, 24 GB after ~14 min, succeeding only at 30 GB — and reported the part that actually cost them: *"in der Ausgabe stand nichts von einem Heap-Limit — der Prozess starb, ohne die Ursache zu nennen."* V8 does not fail an out-of-memory politely; it aborts, so the child was gone by SIGABRT with nothing naming a limit, and the exit code was passed through in silence.
430
+
431
+ The cause is separately addressed and unreleased at the time of their report: `withCodemodHeap` sizes the child's `--max-old-space-size` from the machine rather than node's ~4 GB guess, and the codemod pass now builds an importer closure instead of the whole workspace (measured ~10 KB per file against ~107 KB before). Their 30 GB should not be needed again.
432
+
433
+ But a cause that is fixed is not the same as a failure that explains itself, so the exit is now read rather than forwarded: a SIGABRT / 134 names the memory, the `--max-old-space-size` knob, and the two ways to narrow the pass (`--only`, `--root`); a SIGKILL is reported as something having stopped it — usually a container's memory cgroup — rather than as a codemod failure, which would send you to debug the wrong thing. An ordinary non-zero exit stays silent, because the child has already explained itself.
434
+
435
+ ### Internal (no consumer-facing effect)
436
+
437
+ - **The API goldens now cover every published entry point, not just `dist/index.d.ts`.**
438
+
439
+ The changelog already states the rule: "The public API of each package is its `publishConfig.exports` entry points." Entry pointS — but every generated `api-extractor.json` pointed at the package's main entry and nothing else, so **87 declared subpaths had no golden at all**: `@voltro/protocol/apikey`, the nine `@voltro/plugin-auth/*`, `@voltro/web/hooks`, `@voltro/database/sql`, and the rest.
440
+
441
+ That is the signal that forces a `BREAKING` changelog entry and its codemod, and on a subpath it was simply absent. `ApiKeyStrategyOptions.resolveKey` gained a parameter in this same release and no gate said anything — additive, so harmless, but the repo's own "more precise is still breaking" rule (the one that cost an adopter 102 hand-fixes) would have shipped silently through the same hole.
442
+
443
+ `gen-api-extractor.mjs` — already the single source of truth for this wiring — now emits one config per entry point (73 → 160) and one golden each, derived from the exports map so the checked set is BY CONSTRUCTION the published set. It also deletes configs and goldens for entry points a package no longer exports: a golden nothing runs reads exactly like covered surface. The per-package `api:check` chains its configs with `&&` rather than a loop, so the first failure stops and reports — a loop is what let the local gate score a green `api-surface` over two stale goldens.
444
+
445
+ Verified by breaking a subpath signature on purpose: `api:check` now exits 1 and names `protocol-apikey.api.md`.
446
+
447
+ Regenerating also fixed real drift in the shared path map — `@voltro/cli/serveEntry`, `/startEntry` and `/devActivity` ship but were missing from every package's `tsconfig.api-extractor.json`.
448
+ - `pnpm gate` ran each ci.yml `run:` block with `pipefail` but not `-e`, while GitHub Actions' default shell is `bash --noprofile --norc -eo pipefail`. A multi-command block whose MIDDLE command failed carried on, and the step's status became the status of the last command — so the `api-surface` step, a `for` loop over every package's `api:check`, printed two API-drift warnings and still reported `✓`. The gate said green on a tree whose CI job goes red, which is the one thing it exists to prevent.
449
+
450
+ Fixed, and it now ships a `--selftest` that runs first (silent unless it finds something), matching the changelog and message-API gates. Its first case is the exact shape that hid this — a failing middle command with a passing last one — and it goes red without the `-e`.
451
+ - **The skipped-tests gate could not read the output CI actually produces.**
452
+
453
+ It parsed turbo's STREAMING shape, where every line carries a `@voltro/kv:test:` prefix. On a GitHub runner turbo detects Actions and switches to GROUPED output instead: the package name moves into a `::group::@voltro/kv:test` header and the lines inside carry no prefix at all. The summary regex requires the prefix, so it matched nothing.
454
+
455
+ The consequence was not a wrong answer — it was no answer. The check found zero summaries and refused to judge a run in which all 110 tasks had passed, which is exactly what it is built to do when it cannot confirm anything. It failed the release it was gating.
456
+
457
+ It had never once run in CI. A push to main runs static checks only, so between the commit that introduced it and the release that used it, nothing executed it against real runner output. Its nine selftest cases all passed throughout: they exercise the RULES, and the bug was the INPUT FORMAT.
458
+
459
+ Both shapes parse now, group attribution closes on any non-`:test` group boundary so a stray summary is never credited to the wrong package, and a log downloaded with `gh run view --log` (which renders ESC as the two characters `^[`) parses too — that is how a red run gets debugged offline, and it is how this fix was verified: against the failing release run's own log, where the old parser reports "no summaries" and the new one reports the same `4 skipped in 2 packages` the local gate reported.
460
+
461
+ Eight selftest cases cover the grouped shape, for the reason the selftest exists at all — a check that has quietly stopped detecting anything still prints green.
462
+
463
+ ---
464
+
42
465
  ## [0.17.0] — 2026-07-27
43
466
 
44
467
  ### ⚠ BREAKING
package/dist/index.d.ts CHANGED
@@ -15,6 +15,31 @@ export declare const audit: () => MixinDefinition<{
15
15
 
16
16
  export declare const AUDIT_LOG_TABLE = "_voltro_audit_log";
17
17
 
18
+ /**
19
+ * One actor's audit rows, newest first — the `byAuditSubjectStatus` index's
20
+ * caller. `status: 'error'` is the question asked under pressure ("every
21
+ * refusal by X"), which is why it is a first-class argument rather than
22
+ * something you filter in JS after fetching everything.
23
+ *
24
+ * `limit` defaults to 100: an actor's history is unbounded, and an entry point
25
+ * that returns all of it by default is one you call once in production.
26
+ */
27
+ export declare const auditBySubject: (store: AuditQueryStore, subjectId: string, options?: {
28
+ readonly status?: "ok" | "error";
29
+ readonly limit?: number;
30
+ }) => Promise<ReadonlyArray<Record<string, unknown>>>;
31
+
32
+ /**
33
+ * Every audit row for ONE call — the other half of the correlation join.
34
+ *
35
+ * `byAuditTrace` existed with no caller, which is the same defect the versioning
36
+ * side had: an index nobody can enter is a query you still have to hand-write.
37
+ * Pair it with `historyByTrace` from `@voltro/plugin-versioning` on the same
38
+ * `traceId` and you have "who called, whether they were refused, and what they
39
+ * changed" in two reads.
40
+ */
41
+ export declare const auditByTrace: (store: AuditQueryStore, traceId: string) => Promise<ReadonlyArray<Record<string, unknown>>>;
42
+
18
43
  /** Narrow slice of the framework DataStore the durable sink needs. */
19
44
  export declare interface AuditDataStore {
20
45
  insert: (table: string, row: Record<string, unknown>) => Promise<unknown>;
@@ -111,6 +136,57 @@ export declare interface AuditPluginOptions {
111
136
  * Typically `/^debug\./` or similar. Default: skip nothing.
112
137
  */
113
138
  readonly exclude?: RegExp;
139
+ /**
140
+ * Which OUTCOMES are recorded. `include`/`exclude` filter by tag, before the
141
+ * call runs; this filters by what happened, after.
142
+ *
143
+ * - `'all'` (default) — every invocation.
144
+ * - `'errors'` — refusals only. The forensic core: a denied guard, a revoked
145
+ * key, a validation rejection. Pairs with `plugin-versioning`, which
146
+ * records the SUCCESSFUL writes, so the two together still cover
147
+ * everything while this table stays small enough for retention to be a
148
+ * footnote.
149
+ * - a predicate — anything else (`(e) => e.outcome.kind === 'error' ||
150
+ * e.tag.startsWith('auth.')`).
151
+ *
152
+ * **`'all'` is the default deliberately, though `'errors'` is often the right
153
+ * choice.** Defaulting to errors would silently stop recording successes for
154
+ * every app that upgrades, and "what did this compromised account touch" is
155
+ * answered by successes. Shrinking the trail is a decision an app makes with
156
+ * its eyes open, not one an upgrade makes for it.
157
+ */
158
+ readonly record?: 'all' | 'errors' | ((event: AuditEvent) => boolean);
159
+ /**
160
+ * What happens to `AuditEvent.input` before it is handed to the sink.
161
+ *
162
+ * - `'all'` (DEFAULT) — the payload is replaced by `{ __redacted: 'all' }`.
163
+ * The row still proves a payload existed; it just does not carry it.
164
+ * - `'none'` — the raw input, verbatim. What every sink did before this
165
+ * option existed.
166
+ * - a function — `(event) => unknown`, for field-level control.
167
+ *
168
+ * **Why the default is `'all'`, and why it is NOT marker-driven.** The obvious
169
+ * design — redact using `.serverOnly()` / `.sensitive()` — cannot work: those
170
+ * markers live on TABLE COLUMNS, and this is a mutation's INPUT. A
171
+ * `changePassword({ oldPassword, newPassword })` has no column to consult, so
172
+ * a marker-driven default would cover exactly 0% of the case it exists for
173
+ * while reading, to anyone configuring it, like protection. (`.sensitive()` is
174
+ * also the EXPORT axis, not "unsafe to log" — treating one as the other is the
175
+ * category error the three-marker table warns about.)
176
+ *
177
+ * So the choice is between recording credentials by default and recording no
178
+ * payload by default. A password change, an API key at issuance and a PAT all
179
+ * land in `input`, and an audit table is the one place nobody thinks to look
180
+ * for a credential. Losing payload detail is visible the first time you read a
181
+ * row; leaking a credential is not visible at all. Opt in per app with a
182
+ * function once you know your own inputs.
183
+ */
184
+ readonly redactInput?: 'all' | 'none' | ((event: AuditEvent) => unknown);
185
+ }
186
+
187
+ /** The narrow read surface the entry points need. */
188
+ export declare interface AuditQueryStore {
189
+ query: (descriptor: unknown) => Promise<ReadonlyArray<Record<string, unknown>>>;
114
190
  }
115
191
 
116
192
  export declare type AuditSink = (event: AuditEvent) => void | Promise<void> | Effect.Effect<void>;
package/dist/index.js CHANGED
@@ -2,21 +2,30 @@ import { audit as e } from "./mixin.js";
2
2
  import { Effect as t } from "effect";
3
3
  import { definePlugin as n } from "@voltro/protocol";
4
4
  import { createLogger as r } from "@voltro/logger";
5
- import { id as i, json as a, table as o, text as s, timestamp as c } from "@voltro/database";
5
+ import { and as i, eq as a, id as o, json as s, registerRetention as c, retentionTtlMsFromEnv as l, table as u, text as d, timestamp as f } from "@voltro/database";
6
6
  //#region src/store.ts
7
- var l = "_voltro_audit_log", u = o(l, {
8
- id: i({ prefix: "audit" }),
9
- tag: s(),
10
- at: c(),
11
- subjectId: s().nullable(),
12
- tenantId: s().nullable(),
13
- traceId: s(),
14
- status: s(),
15
- durationMs: s(),
16
- subject: a(),
17
- input: a(),
18
- outcome: a()
19
- }).index("byAuditTag", ["tag"]).index("byAuditTrace", ["traceId"]), d = [u], f = (e) => ({
7
+ var p = "_voltro_audit_log", m = u(p, {
8
+ id: o({ prefix: "audit" }),
9
+ tag: d(),
10
+ at: f(),
11
+ subjectId: d().nullable(),
12
+ tenantId: d().nullable(),
13
+ traceId: d(),
14
+ status: d(),
15
+ durationMs: d(),
16
+ subject: s(),
17
+ input: s(),
18
+ outcome: s(),
19
+ errorTag: d().nullable()
20
+ }).index("byAuditTag", ["tag"]).index("byAuditTrace", ["traceId"]).index("byAuditSubjectStatus", [
21
+ "subjectId",
22
+ "status",
23
+ "at"
24
+ ]).index("byAuditTenantStatus", [
25
+ "tenantId",
26
+ "status",
27
+ "at"
28
+ ]), h = [m], g = (e) => ({
20
29
  tag: e.tag,
21
30
  at: new Date(e.ts),
22
31
  subjectId: e.subject.id ?? null,
@@ -26,14 +35,37 @@ var l = "_voltro_audit_log", u = o(l, {
26
35
  durationMs: String(e.outcome.durationMs),
27
36
  subject: e.subject,
28
37
  input: e.input,
29
- outcome: e.outcome
30
- }), p = (e) => async (t) => {
31
- await e.insert(l, f(t));
32
- }, m = r({ scope: "@voltro/plugin-audit" }), h = 1e3, g = [], _ = () => [...g], v = () => {
33
- g.length = 0;
34
- }, y = (e) => {
35
- g.push(e), g.length > h && g.splice(0, g.length - h);
36
- }, b = (e) => {
38
+ outcome: e.outcome,
39
+ errorTag: e.outcome.kind === "error" ? _(e.outcome.error) : null
40
+ }), _ = (e) => {
41
+ if (typeof e != "object" || !e) return null;
42
+ let t = e._tag;
43
+ return typeof t == "string" && t !== "" ? t : null;
44
+ }, v = async (e, t) => e.query({
45
+ table: p,
46
+ predicate: a("traceId", t),
47
+ order: [{
48
+ column: "at",
49
+ direction: "asc"
50
+ }]
51
+ }), y = async (e, t, n = {}) => {
52
+ let r = a("subjectId", t);
53
+ return e.query({
54
+ table: p,
55
+ predicate: n.status === void 0 ? r : i(r, a("status", n.status)),
56
+ order: [{
57
+ column: "at",
58
+ direction: "desc"
59
+ }],
60
+ take: n.limit ?? 100
61
+ });
62
+ }, b = (e) => async (t) => {
63
+ await e.insert(p, g(t));
64
+ }, x = r({ scope: "@voltro/plugin-audit" }), S = 1e3, C = [], w = () => [...C], T = () => {
65
+ C.length = 0;
66
+ }, E = (e) => {
67
+ C.push(e), C.length > S && C.splice(0, C.length - S);
68
+ }, D = (e) => {
37
69
  let t = e.subject.id ? `${e.subject.type}:${e.subject.id}` : e.subject.type, n = e.outcome.kind === "ok" ? "ok" : "error", r = {
38
70
  subject: t,
39
71
  tenant: e.subject.tenantId ?? null,
@@ -41,8 +73,8 @@ var l = "_voltro_audit_log", u = o(l, {
41
73
  durationMs: e.outcome.durationMs,
42
74
  traceId: e.traceId
43
75
  };
44
- n === "error" ? m.warn(e.tag, r) : m.info(e.tag, r);
45
- }, x = (e, n) => typeof e == "function" ? (n) => t.suspend(() => {
76
+ n === "error" ? x.warn(e.tag, r) : x.info(e.tag, r);
77
+ }, O = (e, n) => typeof e == "function" ? (n) => t.suspend(() => {
46
78
  let r = e(n);
47
79
  return r === void 0 ? t.void : t.isEffect(r) ? r : t.tryPromise({
48
80
  try: () => Promise.resolve(r),
@@ -54,7 +86,7 @@ var l = "_voltro_audit_log", u = o(l, {
54
86
  try: () => r(e),
55
87
  catch: (e) => e
56
88
  }) : t.void;
57
- }) : e === "memory" ? (e) => t.sync(() => y(e)) : (e) => t.sync(() => b(e)), S = (e) => {
89
+ }) : e === "memory" ? (e) => t.sync(() => E(e)) : (e) => t.sync(() => D(e)), k = (e) => {
58
90
  let t = [], n = [], r = (e) => {
59
91
  if (e._tag !== "Empty") {
60
92
  if (e._tag === "Fail") {
@@ -78,10 +110,25 @@ var l = "_voltro_audit_log", u = o(l, {
78
110
  }
79
111
  };
80
112
  return r(e), t[0] ?? n[0] ?? e;
81
- }, C = (e = {}) => {
82
- let r = e.sink === "datastore", i, a = x(e.sink, () => i), o = (t) => !(e.exclude?.test(t) || e.include && !e.include.test(t)), s = (e, n) => o(n.tag) ? t.suspend(() => {
113
+ }, A = (e = {}) => {
114
+ let r = e.sink === "datastore";
115
+ r && c({
116
+ table: p,
117
+ timeColumn: "at",
118
+ ttlMs: l(process.env.VOLTRO_AUDIT_LOG_TTL_HOURS, 24 * 365)
119
+ });
120
+ let i, a = O(e.sink, () => i), o = (t) => !(e.exclude?.test(t) || e.include && !e.include.test(t)), s = (t) => {
121
+ let n = e.record ?? "all";
122
+ return n === "all" ? !0 : n === "errors" ? t.outcome.kind === "error" : n(t);
123
+ }, u = { __redacted: "all" }, d = (t) => {
124
+ let n = e.redactInput ?? "all";
125
+ return n === "none" ? t : {
126
+ ...t,
127
+ input: n === "all" ? u : n(t)
128
+ };
129
+ }, f = (e) => s(e) ? a(d(e)) : t.void, m = (e, n) => o(n.tag) ? t.suspend(() => {
83
130
  let r = Date.now();
84
- return e.pipe(t.tap((e) => a({
131
+ return e.pipe(t.tap((e) => f({
85
132
  ts: r,
86
133
  tag: n.tag,
87
134
  subject: n.subject,
@@ -92,7 +139,7 @@ var l = "_voltro_audit_log", u = o(l, {
92
139
  value: e,
93
140
  durationMs: Date.now() - r
94
141
  }
95
- }).pipe(t.catchAllCause(() => t.void))), t.tapErrorCause((e) => a({
142
+ }).pipe(t.catchAllCause(() => t.void))), t.tapErrorCause((e) => f({
96
143
  ts: r,
97
144
  tag: n.tag,
98
145
  subject: n.subject,
@@ -100,7 +147,7 @@ var l = "_voltro_audit_log", u = o(l, {
100
147
  input: n.input,
101
148
  outcome: {
102
149
  kind: "error",
103
- error: S(e),
150
+ error: k(e),
104
151
  durationMs: Date.now() - r
105
152
  }
106
153
  }).pipe(t.catchAllCause(() => t.void))));
@@ -110,13 +157,13 @@ var l = "_voltro_audit_log", u = o(l, {
110
157
  description: "Records every mutation invocation; ships an audit() schema mixin for row-level metadata.",
111
158
  permissions: r ? ["rpc:intercept:mutation", "store:write"] : ["rpc:intercept:mutation"],
112
159
  ...r ? {
113
- extendSchema: { tables: d },
160
+ extendSchema: { tables: h },
114
161
  bindDataStore: (e) => {
115
- i = p(e);
162
+ i = b(e);
116
163
  }
117
164
  } : {},
118
- interceptMutation: s
165
+ interceptMutation: m
119
166
  });
120
167
  };
121
168
  //#endregion
122
- export { l as AUDIT_LOG_TABLE, e as audit, f as auditEventToRow, u as auditLogTable, d as auditLogTables, C as auditPlugin, v as clearAuditBuffer, p as dataStoreAuditSink, _ as readAuditBuffer };
169
+ export { p as AUDIT_LOG_TABLE, e as audit, y as auditBySubject, v as auditByTrace, g as auditEventToRow, m as auditLogTable, h as auditLogTables, A as auditPlugin, T as clearAuditBuffer, b as dataStoreAuditSink, w as readAuditBuffer };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/plugin-audit",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
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.17.0",
41
- "@voltro/logger": "0.17.0",
42
- "@voltro/protocol": "0.17.0"
40
+ "@voltro/database": "0.19.0",
41
+ "@voltro/logger": "0.19.0",
42
+ "@voltro/protocol": "0.19.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "effect": "^3.21.4"