@voltro/plugin-audit 0.26.0 → 0.28.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 +532 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.js +41 -9
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,538 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## [0.28.0] — 2026-08-06
|
|
43
|
+
|
|
44
|
+
### ⚠ BREAKING
|
|
45
|
+
|
|
46
|
+
- **@voltro/plugin-atlassian** — `credentialsResolver` receives `{ subject, store }` instead of a bare `Subject`.
|
|
47
|
+
|
|
48
|
+
The Subject was the only input, so an app doing per-user Atlassian auth had nowhere to keep the caller's PAT except `subject.metadata` — from where it travelled with the identity into everything that persists a Subject. That is the other half of the credential leak a reporter found in their audit table, and the half that actually closes it: with a store handle, the credential never has to enter the Subject at all.
|
|
49
|
+
|
|
50
|
+
**Worth saying plainly, because it corrects the ask:** the seam for keeping a token out of the Subject already existed. `connectionCredentials({ connectionId, baseUrl })` puts it in the framework's vault, and it is the right answer for most apps. What did not exist was a way for an app with its OWN token table to read it here — the doc comment said such an app "keeps working exactly as before", which was true and meant "keeps the token in the Subject".
|
|
51
|
+
|
|
52
|
+
**Migration:** `(subject) => …` becomes `({ subject }) => …`. `store` is optional — absent when the app bound no data store — and the type forces a resolver that needs it to say what happens then. The codemod lists the three options in order of preference rather than rewriting the destructure, because the mechanical fix silently blesses the shape that caused the leak.
|
|
53
|
+
|
|
54
|
+
Measured while fixing it, and worth knowing: the leak was ONE surface. Traces carry only `subject.type`, `@voltro/plugin-sentry` sends only `type` and `tenantId`, and the console sink prints `type:id`. Only the durable audit column held the whole Subject.
|
|
55
|
+
- **@voltro/plugin-audit** — `auditPlugin` redacts `subject.metadata` by default (`redactSubject`), and `resolveScope` now receives the call's `input`.
|
|
56
|
+
|
|
57
|
+
**A reporter found a working Jira Personal Access Token in plaintext in 12 of 23 rows of their `_voltro_audit_log`.** Neither plugin involved was wrong on its own: `@voltro/plugin-atlassian`'s `credentialsResolver` took a `Subject` and nothing else, so a per-user PAT had nowhere to live but `subject.metadata`; this plugin serialised the Subject verbatim into a json column. Two correct contracts disagreeing about what a Subject IS — an identity, or a credential envelope — with nothing reconciling them.
|
|
58
|
+
|
|
59
|
+
The reasoning is `redactInput`'s, word for word, applied to the field it did not cover: `metadata` is not a table column either, so no schema marker protects it, it is app-controlled so its contents cannot be reasoned about here, and the framework's own per-user-credential mechanism puts a credential in it. **No configuration avoided this** — `redactInput` covers the wrong field, `record: 'errors'` reduces the count rather than the leak, and a function `sink` means giving up the table, its retention sweep and its query helpers.
|
|
60
|
+
|
|
61
|
+
**Migration:** none required — `type`, `id`, `tenantId` and `scopes` still land in the row. Opt back in with `redactSubject: 'none'` or, better, a function that names the keys you meant. **Rows you already have are not fixed by a safer default: purge and rotate.** The codemod carries the query.
|
|
62
|
+
|
|
63
|
+
`resolveScope` also gains `input`, because the subject-only version covered the wrong half: a reporter's users belong to many teams, so their session carries no "current team", while their API-key subjects DO carry a `teamId` — which made subject-only worse than nothing for them, populating for key-authenticated calls and null for every human one, so a filtered view would have looked like it worked. The input is RAW, before `redactInput`; return the dimension, never the payload, because `scope` is not redacted.
|
|
64
|
+
|
|
65
|
+
It is also resolved ONCE per event now. The `...(x !== undefined ? { scope: x } : {})` spread evaluated the resolver twice — invisible, because both calls return the same thing, until a resolver reads a store or counts.
|
|
66
|
+
- **@voltro/plugin-notifications** — `resolveSubjectId` may now return `string | undefined` **or a promise of one**, and the exported `makeSubjectId` helper returns `Promise<string>`.
|
|
67
|
+
|
|
68
|
+
The seam exists for an app whose addressing unit is its own — an employee, a member, a contact. Every one of those is a ROW, so resolving one is a store read, so it returns a promise. The sync-only signature meant the call written in the option's own docstring (`resolveSubjectId: (ctx) => resolveCallerEmployeeId(ctx)`) did not typecheck for the only apps the option was built for. Reported by a consumer whose resolver reads `employees`.
|
|
69
|
+
|
|
70
|
+
**Migration:** plugin configuration needs no change — a resolver returning a plain string still satisfies the widened type. If you call `makeSubjectId` directly, `await` its result. It is deliberately NOT cached: a per-connection cache would let the first call decide the answer for the life of the connection, and only the app knows its own invalidation.
|
|
71
|
+
- **@voltro/cli, @voltro/runtime** — `pluginRef` orphan rules now run under `voltro serve`. They were wired into `voltro dev` and nowhere else.
|
|
72
|
+
|
|
73
|
+
The rule shipped inert (the collector read a builder `table()` had already consumed, so it produced zero rules for everyone), was fixed — and was still bound in exactly one of the two boot paths. So the behaviour a consumer would have lived through is: a row pointing at a deleted plugin row is cleaned up while you develop and left behind forever once you deploy. Nothing crashes; the two paths simply do different things.
|
|
74
|
+
|
|
75
|
+
The wiring is a shared builder both paths call (`wirePluginRefRules`), not an inline block mirrored by hand, and the guard now asserts a SET of boot paths rather than reading `dev.ts` alone — the previous version passed every one of its assertions while production was unwired, because it never asked whether a second boot path existed.
|
|
76
|
+
|
|
77
|
+
**Migration:** `PluginRefChange` is deleted — `isSoftDelete` and `applyPluginRefRules` take the change channel's own `ChangeEvent`. Its `rowId` / `tenantId` fields are gone; the id and the tenant are derived from the row, so put the row in `old`. In tests, reach for `@voltro/testing`'s `changeDelete` / `changeSoftDelete` instead of a literal.
|
|
78
|
+
|
|
79
|
+
The copy is what made this feature fail twice. A second, hand-written shape is what let the original `onSoftDelete` tests assert against `{ op: 'delete', softDeleted: true }` — a combination the channel cannot emit — and stay green while the option could never fire.
|
|
80
|
+
- **@voltro/plugin-webhooks, @voltro/cli** — The outgoing fan-out is tenant-scoped, `ctx.webhooks.emit` is post-commit, and a target's routing filter has a real type.
|
|
81
|
+
|
|
82
|
+
Four findings from a consumer building a real outgoing-webhook feature — 29 declared events, one URL per endpoint, third-party receivers.
|
|
83
|
+
|
|
84
|
+
**Tenant confinement was the app's job and nothing said so.** `_voltro_webhook_targets` carries `.with(tenant())`, but the service is built once at boot with the app-level store and no subject, so the mixin had nothing to scope by: the target lookup was `eq('event', name)` and nothing else. Confinement rested entirely on each target's own `filter`. It looked safe because filters usually predicate on a globally unique app id — a cross-tenant match was impossible *by accident*, and stopped being so the moment they introduced a value deliberately equal across teams. `ctx.webhooks` now binds the acting subject's tenant onto every emit; a system emit (no tenant) stays unscoped, and an explicit `{ tenantId }` at the call site still wins.
|
|
85
|
+
|
|
86
|
+
**`emit` dispatched before commit.** A mutation that emitted and then threw rolled its rows back while the POST went out. `ctx.workflows.start` is post-commit safe and documented as such; this was the one place the rule did not apply to itself. Inside a mutation the dispatch rides `afterCommit` now and the result carries `deferred: true` rather than an unmarked empty delivery list — which would read as "no endpoint wanted it".
|
|
87
|
+
|
|
88
|
+
**`filter?: Readonly<Record<string, unknown>>` cost them a feature for a year.** They wrote in a comment that the filter was key-path equality and could not express "id is one of these", refused the capability in their own API with a typed error, and shipped that — while `in` had been supported the whole time. `WebhookFilter` now names all six operators. It is the one place where being wrong is silent in both directions: a predicate matching nothing reads as "no endpoint wanted it", one matching everything reads as working.
|
|
89
|
+
|
|
90
|
+
**`subscribe({ scope, events })` inherits the endpoint's secret** — the last reason to read a plugin column. It refuses a scope whose rows do not all share one secret: that is not one endpoint, and signing it as one would re-sign half a group with a key the receiver does not hold.
|
|
91
|
+
|
|
92
|
+
`codemod: none` — no user-authored code changes shape. The tenant scope and the commit ordering are behaviour, and both make a previously-possible wrong outcome impossible.
|
|
93
|
+
|
|
94
|
+
### Added
|
|
95
|
+
|
|
96
|
+
- **@voltro/cli** — `voltro serve` prints the connection-pool arithmetic at boot:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
db pool: max=10 per replica (DB_MAX_CONNECTIONS) × 4 replicas = up to 40 connections.
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Reported by an operator whose SECOND pod died on `Connection timed out`. The cause is arithmetic, not a bug — the framework opens one pool per process, so a fleet opens `pool × replicas` against a database limit that does not move with `replicaCount` — but nothing in the boot said what the pool size was, so the multiplication was invisible until the moment it failed. It failed on the second pod, which is the worst place to learn it: the first one proved the configuration "works".
|
|
103
|
+
|
|
104
|
+
Set `REPLICA_COUNT` (Helm: `{{ .Values.replicaCount }}`) and the line does the multiplication; without it the line still names the formula. When `DB_MAX_CONNECTIONS` is unset it says UNSET rather than guessing a driver default — a wrong number from us is worse than an omission the operator can look up.
|
|
105
|
+
|
|
106
|
+
`voltro dev` deliberately does NOT print it: one process, no replicas, no arithmetic. That exception is asserted by a test so a later parity fix has to argue with it rather than silently undo it.
|
|
107
|
+
- **@voltro/cli** — Webhook management and a MASKED data browser answer in production.
|
|
108
|
+
|
|
109
|
+
**`webhookActions`** — subscribe / pause / resume / delete / rotateSecret / replay / repin. Without them the Webhooks panel, now visible in production, renders every target and answers 404 to every button: a read-only page over a management surface, which is the shape that makes an operator distrust all of it. Read lazily off the serve handle, because `serveApi` builds the service (it needs the delivery workflow's trigger) after the inspect manifest exists.
|
|
110
|
+
|
|
111
|
+
**`inspectTables` / `inspectRows`, masked.** The browser reads arbitrary rows from arbitrary tables; the deciding question was never whether operators should see production data but WHICH. The schema already answers it — `.sensitive()`, `.encrypted()` and `.serverOnly()` are the three exposure axes this repo already maintains — so production shows the shape of every row and the value of everything unmarked. Inventing a fourth "do not browse" axis is precisely what the note governing those three warns against.
|
|
112
|
+
|
|
113
|
+
**Masked, not omitted.** A dropped column reads as "this row has no email", which is a different and wrong fact; the cell says which marker hid it. A `null` stays `null`, because an empty optional column is not a secret and masking it would turn a half-filled table into a wall of markers.
|
|
114
|
+
|
|
115
|
+
`voltro dev` deliberately does NOT mask: a developer owns their local database and the overlay's editor writes to it, so masking there would hide a secret the developer put in themselves. The asymmetry is asserted with that reason, and both paths go through one builder so the masking cannot exist in one and rot in the other.
|
|
116
|
+
|
|
117
|
+
The eight workflow WRITE actions are decided and not yet built — they need the workflow runtime, proxy, run-recorder and definitions exposed on the serve handle, and threading them means either exporting four inferred types or casting at the seam. A context object reaching a boot path through a cast is the defect this repo has three write-ups about, so it gets the plumbing or it waits.
|
|
118
|
+
- **@voltro/testing, @voltro/database** — `makeSubscribeContext` plus `changeInsert` / `changeUpdate` / `changeDelete` / `changeSoftDelete` — test doubles for the OTHER context a user writes handlers against.
|
|
119
|
+
|
|
120
|
+
`makeTestContext` covers `AppContext`. A `*.subscribe.ts` handler receives a change EVENT and a `SubscribeContext`, and there was no constructor for either, so every subscriber test hand-built both.
|
|
121
|
+
|
|
122
|
+
Suggested by a consumer, after they named the failure class about their own contract test: *ein Harness, der die falsche Annahme des Codes teilt, prüft nichts.* Six instances turned up in one session, four theirs and two ours, and both of ours were in this gap — including `onSoftDelete` tests built on `{ op: 'delete', softDeleted: true }`, a combination the change channel cannot emit. The flag "worked" against a shape that does not exist while the feature could never fire in production.
|
|
123
|
+
|
|
124
|
+
`changeSoftDelete` is the whole argument: there is no `op: 'softDelete'` and there never will be — a soft delete is an ordinary update that sets `deletedAt` — so an author who does not know that writes a delete. The knowledge now lives in a function name rather than in each author's head.
|
|
125
|
+
|
|
126
|
+
`makeSubscribeContext().store` has no default and **throws naming itself** when touched. A silent empty store would let a subscriber reading the wrong table pass its test, which is the same silent-nothing the constructors remove.
|
|
127
|
+
- **@voltro/cli** — The eight workflow write actions answer in production: start, cancel, suspend, resume, discard, retry, signal, update.
|
|
128
|
+
|
|
129
|
+
`inspectRedriveWorkflowRun` was already wired into serve on the reasoning that dead-letter recovery happens where the incident is. The rest of the family was dev-only, which made the redrive an odd exception rather than a policy — an operator could revive a terminally-failed run and could not retry, discard or cancel one.
|
|
130
|
+
|
|
131
|
+
**The first attempt at this took the deps as `unknown` and cast at each use.** It compiled. It was also the wrong answer: a context object reaching a boot path through a cast is the defect this repo has three separate write-ups about, and the entire point of extracting these is that the two paths cannot drift — a cast is the hole a drift walks through. It was deleted rather than shipped.
|
|
132
|
+
|
|
133
|
+
They are typed structurally now, by what the bodies actually use: a runtime that can run an Effect, a recorder that can record an event, a definition that can be interrupted or resumed. The engine ENVIRONMENT is a type parameter, because `interrupt`/`resume` are `Effect<void, never, WorkflowEngine>` and pinning `R` to `never` would have forced back exactly the cast being removed. The compiler found two real shape differences on the way — the event-type union and that environment — which is the property `unknown` throws away.
|
|
134
|
+
|
|
135
|
+
Built inside `serveApi`, where the runtime lives, and delegated from `serveCommand`'s manifest — the same seam as the redrive and the scheduler's `fireNow`. An app with no workflows gets a refusal naming itself rather than a `TypeError` on undefined.
|
|
136
|
+
|
|
137
|
+
The parity guard needed a correction of its own: entries that move into a shared spread leave the key scan's view, so a builder is now asserted against the serve PATH (both `serveCommand` and `serveApi`) rather than one file. Asserting `serveCommand` alone failed a correct wiring — the check was measuring the wrong thing, which is what it exists to catch elsewhere.
|
|
138
|
+
|
|
139
|
+
### Fixed
|
|
140
|
+
|
|
141
|
+
- **@voltro/cli** — `*.startup.tsx` and `*.email.tsx` run in production. And every dev/serve difference is now derived and enforced rather than remembered.
|
|
142
|
+
|
|
143
|
+
**The two gaps.** A startup is documented as a boot hook and ran under `voltro dev` only — so an app that opens a connection there, warms a cache or starts an SSE bridge got none of it where it is deployed, with no error, because nothing was asked to happen. Declared mail templates were registered in dev only, so `ctx.mail.send({ template })` resolved locally and could not resolve in production. Both go through one shared runner now, proven by boot: `startup ran` appears once in each path.
|
|
144
|
+
|
|
145
|
+
**The audit is the real change.** Three defects of this class shipped in a single day — `pluginRef` orphan rules, the inspect router, and plugin RPC interceptors (which meant `plugin-audit` recorded nothing in production). Each was found by accident, each after the rule against it had been written down twice, and each while the existing parity guard was green — because that guard compares the things somebody thought to compare.
|
|
146
|
+
|
|
147
|
+
`devServeSurfaceAudit.test.ts` asks the general question instead: which boot symbols does `dev.ts` call that the serve path never reaches? Every answer is wired, justified in writing, or counted as backlog, so a new one is a red test rather than a discovery six months later.
|
|
148
|
+
|
|
149
|
+
Two refinements it took three wrong answers to find, both making the check WEAKER on purpose — a guard that cries wolf is one the next reader switches off:
|
|
150
|
+
|
|
151
|
+
- `dev.ts` EXPORTS the builders serve imports, so anything called inside `buildStore` / `loadDiscovered` / `buildResolveSubject` is reached. Ignoring that reported read replicas, relations registration and auth composition as production gaps. None of them are. - a symbol absent from serve's source may still be reached — through a namespace, or through shared runtime code (write attribution is stamped in `bindMutation`, so both transports have it).
|
|
152
|
+
|
|
153
|
+
**Backlog: three entries, counted.** Boot SEEDS still run in dev only. Whether production should auto-seed on every pod start is a real question — a rolling deploy would run it once per replica — so it is recorded with that reasoning rather than decided in a sweep. Plugin services are still provided to workflow STEPS in dev only.
|
|
154
|
+
- **@voltro/cli** — The declared-event consumer scan resolves IMPORTS instead of guessing from the event's name, and stops walking once no further file can change the answer.
|
|
155
|
+
|
|
156
|
+
Two defects, one report. A consumer had ten events consumed in one sibling file and got one `no-consumer` warning for a **live** subscription: the scan matched the wire name's last segment as a substring of the file, nine matched by accident because the import identifier happened to contain it, and the tenth did not — `import employeeAttendance from '…/employeeAttendance.event'` carries the FILE name, while the event is `employee.attendanceChanged`. The rule was comparing a name to a name and calling agreement evidence. It now also credits a binding imported from the event's own module and used outside the import, which is additive: it can only turn a "no" into a "yes", never invent an orphan.
|
|
157
|
+
|
|
158
|
+
Their workspace also tripped the sibling-file bound (`stopped after 4000`). The scan is a fold now, settling once every event is both published and consumed, so the walk ends at the file that answers the last question — for them, inside the first sibling app. The bound is raised to 20000 for the genuinely-orphaned case that does read the whole tree, and a walk that STOPPED no longer reports truncation: that scan was complete.
|
|
159
|
+
- **@voltro/cli** — The declared-event wiring check knows about the webhook audience.
|
|
160
|
+
|
|
161
|
+
A consumer declared 29 events for outbound delivery and got 58 warning lines on every boot — 29 "never published" and 29 "no `useEvent` consumer" — all wrong. They publish through `ctx.webhooks.emit`, which the producer scan did not look for, and their consumers are rows in someone else's deployment, which no `useEvent` scan can ever see.
|
|
162
|
+
|
|
163
|
+
An event declaring `webhook:` now counts an `emit(` as publishing, and is not reported as missing a `useEvent` consumer. Both halves are qualified: the emit must appear in a file that reaches the webhooks service (`emit` is far too common a method name to accept bare), and an event WITHOUT a webhook audience is unaffected — which keeps the finding that mattered. The same reporter had seven of eleven advertised events with no emit call site at all; ticking one returned 200, showed the endpoint healthy, and delivered nothing forever. That case is still reported.
|
|
164
|
+
|
|
165
|
+
The warning's own reasoning is right and unchanged — a consumer with no producer waits forever and looks exactly like a quiet channel. A check that is wrong 58 times is one nobody reads the 59th.
|
|
166
|
+
- **@voltro/cli** — `plugins`, `env`, `dataCache` and `subscriptions` answer in production. The "unaudited" inspect backlog is audited.
|
|
167
|
+
|
|
168
|
+
Each was carried as an entry nobody had looked at, and the audit found the same thing four times: nothing dev-specific, the inputs already present in `serveCommand`, and the entry simply never moved. Which is how a backlog like that forms — an omission is invisible from the manifest, so it survives every reading of the file.
|
|
169
|
+
|
|
170
|
+
**`metrics` is the one that did not resolve that way, and it is a bigger finding.** serve builds a metrics collector, hands it to a single consumer, and never wraps its interceptors with it. Production is therefore not COLLECTING the numbers the endpoint would report — wiring the entry alone would have shipped an honest-looking zero, which is worse than the 404 it shows today. It stays listed, with that reason, because fixing it is a behaviour change rather than a manifest line.
|
|
171
|
+
|
|
172
|
+
`subscriptions` reads the dispatcher lazily off the serve handle, the same way `events` and `members` already do — serveApi owns it and is built after the manifest object exists.
|
|
173
|
+
|
|
174
|
+
Backlog: three entries left (`inspectTables`, `inspectRows`, `cluster`), down from eight, and the count is asserted so it cannot grow quietly.
|
|
175
|
+
- **@voltro/cli** — Schedules and the workflow read endpoints answer in production. They were wired into `voltro dev` and nowhere else.
|
|
176
|
+
|
|
177
|
+
**The written reason for the schedules omission was wrong**, and a reader asking the obvious question — why would schedules be missing when they run in the core? — is what exposed it. The comment said dev computes `nextFiringAt` and the EFFECTIVE coordination from values in its own boot closure. Neither holds: `nextFiring` is a pure function exported from `@voltro/runtime`, and serve already computes the coordination itself as `effectiveScheduleCoordination`, with the same cluster→advisoryLock→single degradations, twenty lines above the manifest it never handed it to.
|
|
178
|
+
|
|
179
|
+
The incoherence that gives it away is in the same object: serve wires `inspectFireSchedule`, so an operator could **run** a schedule they could not **list** — while `voltro inspect schedules --failing` is documented as a post-deploy gate, against the deployment, which was the one place it did not answer.
|
|
180
|
+
|
|
181
|
+
The six workflow READ endpoints are the same shape, next to the same tell: `inspectRedriveWorkflowRun` is wired in serve on purpose ("dead-letter recovery happens where the incident is"), which is an argument for looking at a run before it is an argument for reviving one. All six are plain queries of `_voltro_workflow_*` tables.
|
|
182
|
+
|
|
183
|
+
**The guard that was supposed to report this was itself under-reporting.** It brace-counted to find the manifest's keys, a `{` inside a string literal truncated the walk, and it found 8 dev-only entries where there are 34 — while its own non-vacuity check (`> 5`) passed the whole time. A guard that finds a third of the truth reads exactly like one that found all of it. It is indent-anchored now, with a floor near the real number.
|
|
184
|
+
|
|
185
|
+
What remains dev-only is the WRITE surface — arbitrary row edits, seeds, a migration rollback, and the eight workflow control actions — each named with its reason, plus eight read endpoints carried as an explicit, counted backlog. The `webhookActions` entry says plainly that it is a decision nobody has taken rather than a surface anyone rejected.
|
|
186
|
+
- **@voltro/cli** — The inspect ROUTER is shared. Every endpoint wired into `voltro serve` this week was answering 404.
|
|
187
|
+
|
|
188
|
+
`voltro dev` dispatched through twenty-one handlers before reaching `handleInspectRequest`; serve called only the last one. So schedules, webhooks, the workflow reads and writes, the data browser and cluster all had their manifest entries in production and no URL that reached them. **The data was wired and the door was not.**
|
|
189
|
+
|
|
190
|
+
The parity guard was green throughout, and correctly so by its own definition: it compares which keys a manifest CONTAINS, and this is a missing router CALL. Same shape as every guard in this repo that has needed correcting, one layer further out — a source rule is a map, and this was territory. It was found by booting a fixture and curling it, ninety seconds of work that no amount of reading would have replaced.
|
|
191
|
+
|
|
192
|
+
The shared branches are one function both paths call, in one order. `voltro dev` keeps its own overlay-only chain (client-log ingest, the trace ring, the timeline, dashboard mounts) — those answer questions a production process has no data for.
|
|
193
|
+
|
|
194
|
+
**And `cluster` nearly got certified wrong by the same smoke.** With no manifest entry, `handleInspectCluster` answers **200** with a hardcoded single-process memory shape: `dialect: 'memory'`, `runnerHost: 'localhost'`, `runnerStorage: 'none'`. On a postgres deployment that is a confidently wrong answer to "is my cluster healthy" — worse than the 404 it replaced. The fixture is a memory app, so the fallback and the truth agreed and the endpoint looked fine. Wired properly now, from the same builder both paths call.
|
|
195
|
+
|
|
196
|
+
Guard additions: both paths must call the shared handler, and neither may dispatch a shared handler on its own — two copies of a router is the same defect as two copies of a wiring.
|
|
197
|
+
- **@voltro/runtime, @voltro/cli** — `makeQueryFinalizer` — the tenant + soft-delete scoping composition now exists once, and both boot paths call it.
|
|
198
|
+
|
|
199
|
+
`applyTenantScope` and `applySoftDeleteScope` were extracted so that "there is no second copy that could drift" — the words are in that file's own header. Then the COMPOSITION became the second copy: `voltro dev` wrapped the pair in a local helper, `serveApi` inlined the same pair in the other spelling.
|
|
200
|
+
|
|
201
|
+
They agreed, which is the dangerous state rather than the safe one. Nothing kept them agreeing, and a third scoping concern would have landed in whichever file the author had open — producing a live query that filters one way in development and another in production, with no error on either side.
|
|
202
|
+
|
|
203
|
+
This is the variant a source-reading parity guard cannot catch: both paths supply something, both are right at their own call site, and they disagree about CONTENT. Detection does not help; one function does, because the disagreement then has nowhere to live. `bootPathParity.test.ts` additionally refuses a direct call to either primitive from a boot path, with a non-vacuity check so deleting the finalisation entirely cannot satisfy the rule.
|
|
204
|
+
|
|
205
|
+
Where the paths are genuinely allowed to differ is an `observe` hook: `voltro dev` warns about an org-less subject, an empty tenant scope and a non-indexed predicate; production pays for none of that. The observer receives a COPY — the first version handed it the live descriptor, so a warning could have rewritten the query, reintroducing divergence through the seam built to stop it. Its own test caught that.
|
|
206
|
+
- **@voltro/cli** — **Plugin RPC interceptors did not run under `voltro serve`.** `plugin-audit` recorded nothing in production, `plugin-sentry` reported nothing from it, and `plugin-rbac` published no scopes there.
|
|
207
|
+
|
|
208
|
+
`wrapInterceptorsForKind` was called in `dev.ts` and in no other file, so every plugin's `interceptMutation` / `interceptAction` / `interceptQuery` was dead on the production wire.
|
|
209
|
+
|
|
210
|
+
Measured, not inferred: with `auditPlugin({ sink: 'console' })` installed and one action invoked over the wire, `voltro dev` logs the audit line and `voltro serve` logs nothing. After the fix both do.
|
|
211
|
+
|
|
212
|
+
It is the worst instance of the dev/serve class this repo has found, and it hid in the shape that makes the class hard. Nothing crashed. Nothing warned. The plugin manifest reported `interceptMutation: true` for each plugin — accurately, since the plugin does declare the hook. A consumer reading their DEV database found audit rows exactly where they expected them. Nobody was lying anywhere; the wire simply never called the hook.
|
|
213
|
+
|
|
214
|
+
**`metrics` is fixed by the same change and was the sibling defect.** serve built a metrics collector, handed it to one consumer and never wrapped its interceptors with it — so the endpoint would have reported an honest-looking zero. There is one collector now, the interceptors feed it, and the inspect endpoint reads that one off the serve handle.
|
|
215
|
+
|
|
216
|
+
Both are shared builders called by both paths, and `bootPathParity.test.ts` refuses a path that composes interceptors itself.
|
|
217
|
+
|
|
218
|
+
**If you run `@voltro/plugin-audit` in production: your trail has a hole for every release before this one.** Nothing was written. The rows you have are the ones your dev and any `voltro dev` deployment produced.
|
|
219
|
+
- **@voltro/cli** — Plugin services reach workflow STEPS in production; the seed policy is stated instead of silent; and two loaders that could only ever work under `tsx` are fixed.
|
|
220
|
+
|
|
221
|
+
**Workflow steps.** `makePluginWorkflowStepLayer` was provided in dev only, so a step that yields a plugin service worked locally and failed in production with `Service not found` — the plugin's `onWorkflowStep` had nothing to attach to. Same builder, same metrics collector, both paths.
|
|
222
|
+
|
|
223
|
+
**Seeds are a decision now, and it is written down.** Production does NOT auto-seed: a rolling deploy starts N replicas, so an auto-seed runs N times, and the idempotency that makes that safe belongs to the app. `voltro db seed` from a pre-deploy job is the deliberate step, where migrations already live. What was wrong was the SILENCE — an app with seeds booted in production and nothing said they had not run, which is indistinguishable from them running and finding nothing to do. `voltro serve` reports them now, by name, with what to run instead.
|
|
224
|
+
|
|
225
|
+
**And wiring seeds into production surfaced a crash on the first boot.** `seedRunner` and `emailDiscovery` loaded app modules with a raw `import(pathToFileURL(file))`, which resolves the `.ts` SOURCE. Plain node cannot load it — the framework's own `@voltro/*` sources use extensionless relative imports — so it works under `voltro dev` (tsx resolves them) and dies under `voltro serve` with `Cannot find module …/packages/database/src/columns`. Both go through `importAppModule` now, and a guard refuses a declaration loader that does not. They were invisible for exactly as long as their surface was dev-only.
|
|
226
|
+
|
|
227
|
+
**The dev/serve backlog is now ZERO.** Every difference is either wired or a written decision, and the audit asserts the count is zero rather than "small".
|
|
228
|
+
- **@voltro/cli** — Production records per-rpc metrics, and its traces carry the app's name.
|
|
229
|
+
|
|
230
|
+
Both found by the second axis of the dev/serve audit — **the same function called with different arguments**, which is the variant that does not crash and the one a "does serve call this?" scan cannot see.
|
|
231
|
+
|
|
232
|
+
`makeMutationRunner` / `makeActionRunner` took a `recordMetric` in dev and not in serve, so the metrics endpoint in production reported plugin buckets and no rpc buckets: half an answer, which reads like a whole one. Proven by boot — one action now yields `rpc action.people.add` alongside the plugin bucket, where before there was only the plugin one.
|
|
233
|
+
|
|
234
|
+
`buildTracingLayer` took a `serviceName` in dev and not in serve, so the same service appeared in a collector under its app name from development and as `voltro-app` from production. It stayed invisible because the runtime reads `OTEL_SERVICE_NAME` itself — so the production-hardening docs' instruction did work, and only an operator who had NOT followed it would have seen the difference.
|
|
235
|
+
|
|
236
|
+
Neither is dramatic. They are recorded in this shape because the class is: a difference in an argument, in code that runs on both paths, with nothing failing.
|
|
237
|
+
- **@voltro/cli, @voltro/devtools-ui** — The Webhooks panel answers in production, and its Events tab shows **"subscribed, never emitted"**.
|
|
238
|
+
|
|
239
|
+
The inspect entry was wired into `voltro dev` and nowhere else, so the panel read a dev database — where nobody has real third-party subscribers — and replied "not configured" against the deployment that has them. All three layers it shows are plain reads of the app's own tables; nothing about them is dev-specific. It is the shared builder both boot paths call now.
|
|
240
|
+
|
|
241
|
+
That omission is worth separating from a deliberate one. `inspectSchedules` is absent from serve on purpose, with a written reason (dev computes `nextFiringAt` from values in its own boot closure, and reporting a guess to a post-deploy gate is worse than reporting nothing). Webhooks had no such reason — **and from the manifest the two read identically.** `bootPathParity.test.ts` now requires every dev-only inspect entry to be named with its justification, and carries the other eight as an explicit, counted backlog rather than as silence.
|
|
242
|
+
|
|
243
|
+
The new column comes from a consumer's suggestion. They shipped a create dialog offering eleven event checkboxes of which four were wired: ticking `team.updated` returned 200, showed the endpoint enabled and healthy, and delivered nothing, forever. Nothing in the framework could catch that inside their code — but the deployment knows which events have targets and which have ever produced a delivery, and the difference is the defect.
|
|
244
|
+
|
|
245
|
+
`everDelivered` is **not** derived from the deliveries list. That list is the most recent 200 rows, so an event delivered steadily but long ago would have read as never delivered — the exact false positive the column exists to avoid producing. It is its own bounded query, one per distinct subscribed event. The cell reports facts (`N subscribed · never emitted`), never a verdict: a target subscribed a minute ago is not a fault, and any threshold would be wrong for someone.
|
|
246
|
+
|
|
247
|
+
### Internal (no consumer-facing effect)
|
|
248
|
+
|
|
249
|
+
- **@voltro/cli** — `bootPathParity.test.ts` — dev/serve parity enforced from DERIVED sets rather than a curated list.
|
|
250
|
+
|
|
251
|
+
Seven capabilities have shipped wired into `voltro dev` and absent from `voltro serve`, each a silent production no-op, each found by a human noticing. The rule against it has been written in two `CLAUDE.md` files, with a checklist, since long before the seventh.
|
|
252
|
+
|
|
253
|
+
Every previous guard is per-feature and asks "does path X mention thing Y", so a NEW wiring is invisible to all of them — nobody remembered to add it. This one derives three sets from the source: modules that bind a change channel, imported symbols called inside an inline `onChange` body, and modules exporting a `wire*` / `attach*` boot builder. Each must be reached by both paths or appear in `DEV_ONLY` with a written reason. A new wiring is included automatically and fails until someone wires serve or says why not, which inverts the default.
|
|
254
|
+
|
|
255
|
+
Red-verified against the shipped `pluginRef` state: three failures, one from each rule. The inline rule is the one that matters — the module rule alone would not have caught it, because that wiring lived inside `dev.ts`.
|
|
256
|
+
|
|
257
|
+
Two entries currently justify themselves: the dev inspect CDC bus and the `VOLTRO_TIMELINE` recorder. A `DEV_ONLY` entry that is no longer asymmetric fails, so the exception list cannot decay back into a curated one.
|
|
258
|
+
- **@voltro/cli** — The dev/serve audit gained its THIRD axis, for the one place it costs most.
|
|
259
|
+
|
|
260
|
+
The first two axes are "a call serve never makes" and "the same call with different arguments". The third — one value present in both paths and BUILT differently — has no general check, and it is the variant that costs the most while showing the least: both paths supply something, both are right at their own call site, and they disagree about content. Historically that was a cron reading one tenant in dev and every tenant in production, silently, because `tenantId == null` means "system".
|
|
261
|
+
|
|
262
|
+
What is checkable is the one constructor where it would hurt most. Every field a handler can reach comes from `makeAppContextBuilder`, so its input key set is the closest thing to an enumeration of the context surface — and the two paths are compared key for key, in both directions.
|
|
263
|
+
|
|
264
|
+
They currently agree, with one justified exception (`onEventEmit`, the dev overlay's SSE tap). Red-verified by dropping `store` from serve's call.
|
|
265
|
+
- **@voltro/cli** — The `memory-api` e2e fixture grew the declarations its smokes were pretending to cover.
|
|
266
|
+
|
|
267
|
+
Every gap here was found by booting, not by reading:
|
|
268
|
+
|
|
269
|
+
- **no marked column**, so `GET /_voltro/inspect/data/rows` returned 200 and proved the endpoint answers — nothing about what it withholds. It now carries `.sensitive()` and `.serverOnly()` columns and an action that writes them over the real wire, so the production masking is asserted against a running server: `email` and `internalNote` come back `{ "__masked": … }` from `voltro serve` and in plaintext from `voltro dev`, with zero occurrences of the protected values anywhere in the response. - **no schedule**, so `inspectSchedules` and its FALLBACK both answered `{ schedules: [], coordination: 'single' }` — indistinguishable. One schedule makes the two answers different. - **no webhook-audience event**, so `inspect/webhooks` was empty either way.
|
|
270
|
+
|
|
271
|
+
Two things the fixture cannot prove, said here rather than implied:
|
|
272
|
+
|
|
273
|
+
- **`.encrypted()` is absent on purpose.** One encrypted column makes the whole table unwritable without a registered field cipher — the insert fails even when that column is left unset — and this fixture is also the driverless, plugin-light serve smoke. That axis is asserted in `inspectDataBrowser.test.ts`. - **`voltro serve` runs neither boot SEEDS nor `*.startup.tsx`.** A seeded row appeared in dev and never in production, which is why the fixture inserts through an action instead. Whether that is deliberate is a separate question and not answered here — it is recorded because it was discovered, and because an app with a `*.startup.tsx` gets it in development and not where it runs.
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## [0.27.0] — 2026-08-05
|
|
278
|
+
|
|
279
|
+
### Added
|
|
280
|
+
|
|
281
|
+
- **@voltro/plugin-audit, @voltro/plugin-versioning** — `scope` — the app's own scoping dimension on `_voltro_audit_log` **and** `_voltro_row_history`, supplied by a `resolveScope` option on each plugin.
|
|
282
|
+
|
|
283
|
+
The last thing between a consumer and deleting a 2900-row, 300-call-site hand-rolled audit trail. Their trail and its retention are per-TEAM; a tenant has many teams, so `.with(tenant())` is one level too coarse and every view they render filters by team first. It is the same column `_voltro_webhook_targets.scope` already carries: opaque json in, opaque json out, equality filtering.
|
|
284
|
+
|
|
285
|
+
**Deliberately not `metadata`.** They offered to carry `teamId` there and filter in memory, and were right to dislike it: `metadata` is documented as the app's free-form note — the noun a diff cannot contain — so filtering on it builds a read path against a column whose contract says it is not one. Two columns, two jobs.
|
|
286
|
+
|
|
287
|
+
The app supplies the value, because the framework does not know what a team is — which is the whole reason the column is opaque. `auditPlugin` derives it from the call (`(ctx) => ({ teamId: ctx.subject.metadata?.teamId })`); `versioningPlugin` from the changed ROW (`(row) => ({ teamId: row.teamId })`), because that is what that plugin has and where a per-table dimension lives. Configure both or half of every view is unfiltered.
|
|
288
|
+
|
|
289
|
+
Neither resolver can fail the write it annotates: an underivable scope is `null`, the same answer as not configuring one.
|
|
290
|
+
|
|
291
|
+
codemod: none
|
|
292
|
+
- **@voltro/plugin-notifications** — `notificationsPlugin({ resolveSubjectId })` — the app names its own addressing unit.
|
|
293
|
+
|
|
294
|
+
An inbox belonged to `subject.id`. That is the framework's answer and not always the app's: a shift change, an absence request or a task reminder is addressed to a PERSON, and a person does not necessarily have an auth user. A reporter measured it on 14 670 rows — 4 677 addressable through a user, and **668 live, read rows belonging to four people who have none**.
|
|
295
|
+
|
|
296
|
+
The worse half is what follows a migration without it: every producer resolves person → user and **silently delivers nothing** for anyone missing one. That is the failure this plugin's own docstring warns about, one level up and structural rather than accidental.
|
|
297
|
+
|
|
298
|
+
One function, thirteen call sites — the same seam `auth.resolveScopes` already offers for this shape. Absent keeps `subject.id`, so nothing changes for an app whose units line up. A resolver that returns `undefined`, an empty string, or throws falls back to the subject rather than failing the read: an inbox must not go down because one caller has no employee record, or because a lookup hit a database that was briefly unavailable.
|
|
299
|
+
|
|
300
|
+
**`useEvent` already returns what the same report asked for.** `{ status, missed, lastMiss }`, where `status === 'live'` is the connected flag — the ask was for discoverability, not an API, so the docs now name the case that motivated it: a wall display nobody is standing at keeps rendering the last thing it received, and from across the room stale and current look identical.
|
|
301
|
+
|
|
302
|
+
codemod: none
|
|
303
|
+
- **@voltro/plugin-presence** — The presence tracker gets a perf suite — the last of the four realtime surfaces without pinned numbers — and the four are now documented side by side.
|
|
304
|
+
|
|
305
|
+
The presence figures existed in the docs (a heartbeat, a 10 k roster) and were measured once by hand, which cannot fail. Same gap the event bus had, closed the same way: a `*.perf.test.ts` that prints what it measured and asserts the SHAPE rather than the microseconds.
|
|
306
|
+
|
|
307
|
+
Measured across all four, each asserted by a test:
|
|
308
|
+
|
|
309
|
+
| primitive | operation | cost | scales with | | --- | --- | --- | --- | | Events | `ctx.events.publish` | 4.3 µs (~232 k/s) | nothing | | Events | delivery to a subscriber | 0.027 µs | subscribers, cheaply | | Presence | a heartbeat | 0.16 µs | nothing | | Presence | a roster read, 10 k members | 547 µs | the ROOM | | Records | a live-query re-diff, 5 000 rows | 3 062 µs | the RESULT SET | | Broadcast | cross-replica over real Redis | p50 1.1 ms · p99 11.2 ms | the network |
|
|
310
|
+
|
|
311
|
+
**The comparison is what was missing, not the numbers.** Publishing an event costs about a thousandth of re-diffing a large live query, and that ratio is what should decide between them — a 60 Hz value belongs in an event, because the same value written to a table wakes every subscriber of every query reading it and each pays the full walk.
|
|
312
|
+
|
|
313
|
+
Two of the four are flat and two are not. That is the property the tests assert: the per-member cost of a roster read must not grow with the room, and the per-row cost of a diff must not grow with the result set. Either one growing is the difference between expensive and unusable.
|
|
314
|
+
|
|
315
|
+
codemod: none
|
|
316
|
+
- **@voltro/cli** — A capability matrix for the realtime surface — fifteen things people build, each mapped to the primitive that carries it, each asserted by a test.
|
|
317
|
+
|
|
318
|
+
"Nothing is missing" is not a checkable sentence. This turns it into one: `realtimeCapabilities.test.ts` asserts every row's primitive is still exported, so a capability that loses its primitive to a rename goes red in CI rather than being discovered by whoever tries to build it.
|
|
319
|
+
|
|
320
|
+
It caught one on its first run — the matrix claimed `useUpload` lived in `@voltro/plugin-storage` and it is in `@voltro/client`. A row pointing at the wrong package is exactly what a table in a document does silently.
|
|
321
|
+
|
|
322
|
+
It asserts EXPORTS rather than behaviour on purpose. Behaviour is what the other suites are for, and duplicating them here would make this a slower copy of them. What it catches is the gap between "we support that" and "the thing that supports it still exists".
|
|
323
|
+
|
|
324
|
+
The same table is in the docs, with the three capabilities people usually reach for wrongly called out: a value changing many times a second is an EVENT and not a row (writing it to a table wakes every subscriber of every query reading that table, each paying a full re-diff); "who is online" is presence rather than a table; and "did anything get lost" has a computed answer in `missed`, so nobody needs to build a heartbeat of their own to find out.
|
|
325
|
+
|
|
326
|
+
codemod: none
|
|
327
|
+
- **@voltro/cli** — The ten hard questions a realtime system is judged on, with this framework's answer and — enforced by a test — the proof behind each.
|
|
328
|
+
|
|
329
|
+
`realtimeProperties.test.ts` fails if a row's proof disappears: a property may not be CLAIMED without something in the repository that demonstrates it. Red-verified by re-pointing one row at a test that does not exist.
|
|
330
|
+
|
|
331
|
+
The questions, because they are the deliverable rather than the mechanism: is a missed delivery reported or silently dropped; can a late arrival tell "nothing happened" from "I was not listening"; is a SUBSCRIPTION authorized or only the connection; does a subscription outlive its credential; does the link heal itself after an outage; does a degraded network lose messages or only slow them; does fan-out cost grow with subscribers; are channels typed or strings; is a declared event nobody publishes reported; is cross-replica traffic separated per app by default.
|
|
332
|
+
|
|
333
|
+
**Why this replaces a benchmark against hosted competitors.** A table of our measured numbers beside someone else's published ones is not a comparison, it is two things in a row. Measuring a hosted product honestly needs its accounts, regions, tiers and retry policies, and a wrong number about someone else's product is worse than no number. What decides a choice is not the microseconds anyway — it is whether the system answers these questions at all, and every answer above is checkable against this repository by anyone.
|
|
334
|
+
|
|
335
|
+
codemod: none
|
|
336
|
+
- **@voltro/cli** — A real competitive measurement — against socket.io, on this machine, in the same topology.
|
|
337
|
+
|
|
338
|
+
This was declined twice on the grounds that a benchmark needs the competitor's accounts and regions. That reasoning holds for hosted products and **does not hold for socket.io**, which is an npm package: it can be installed, run and measured here with the same method. Declining it was over-broad.
|
|
339
|
+
|
|
340
|
+
Back to back, two server instances sharing one Redis, client on B, emits on A:
|
|
341
|
+
|
|
342
|
+
| | p50 | p99 | delivered | | --- | --- | --- | --- | | Voltro cross-replica | **1.29 ms** | 6.80 ms | 200/200 | | socket.io + redis-adapter | 1.89 ms | **3.81 ms** | 200/200 |
|
|
343
|
+
|
|
344
|
+
**~32% faster at the median, ~44% worse at the tail.** Both lossless. The p99 is ours to improve and is published rather than omitted, because a benchmark you only show when you win is advertising.
|
|
345
|
+
|
|
346
|
+
**The topology is what makes it a comparison.** The first attempt measured socket.io on a plain localhost websocket with no adapter and came out 3x faster — which proved nothing: that is one hop, ours is two through a broker. It would have flattered socket.io and been dishonest in their favour, which is the same defect as flattering ourselves.
|
|
347
|
+
|
|
348
|
+
Also measured and NOT published as a headline: socket.io's `emit` to 100 subscribers costs 13.7 µs against our 2.7 µs, but at that point ours has already run every listener while socket.io has only enqueued to 100 sockets — zero had arrived when the measurement ended. Two different quantities; comparing them would have been the same mistake in the other direction.
|
|
349
|
+
|
|
350
|
+
`scripts/bench/socketio-cross-replica.mjs` carries the method and the numbers so they can be re-taken. Deliberately a script, not a test: keeping a competitor in the dependency tree to hold a number green is the wrong trade.
|
|
351
|
+
|
|
352
|
+
codemod: none
|
|
353
|
+
- **@voltro/plugin-webhooks** — **A subscription is a SET of events, and the service now has a word for it.**
|
|
354
|
+
|
|
355
|
+
`subscribe({ events: [...] })` creates the rows in one call; a `{ scope }` selector addresses them as a group wherever a target id is accepted — `pauseTarget`, `resumeTarget`, `updateTarget`, `deleteTarget`, `rotateSecret`, `listDeliveries`.
|
|
356
|
+
|
|
357
|
+
A row is one event, but a subscription — as every webhook UI models it, ours included — is one URL with a list of event checkboxes. Without a name for the group, five checkboxes are five rows and every operation a user thinks of as single becomes a fan-out the app writes by hand: N pauses, N updates, N delivery reads merged and re-sorted, and a rotate that is delete + re-subscribe.
|
|
358
|
+
|
|
359
|
+
**The shared secret is why this is correctness and not ergonomics.** The receiver verifies ONE signature for ONE url, so N rows for one endpoint must sign identically — and there was no way to say so. `subscribe` mints a secret per call, `SubscribeResult` surfaces it once, `TargetPatch` cannot set it. So ticking a sixth event meant reading the secret column back out of `_voltro_webhook_targets` through the app's own database handle. That is exactly the coupling `listDeliveries` was added to remove, re-entered through a different door one release later.
|
|
360
|
+
|
|
361
|
+
`subscribe` now mints one secret for the whole set, and `rotateSecret({ scope })` rotates every row to the same new value — which also replaces the delete-and-re-subscribe that minted new target ids and orphaned the delivery history.
|
|
362
|
+
|
|
363
|
+
**`secret` is deliberately still not patchable.** Adding it to `TargetPatch` would close the same gap by making a live credential app-writable, trading a coupling for a weaker invariant. The reporter proposed the constraint and declined that shortcut themselves.
|
|
364
|
+
|
|
365
|
+
A scope matching no row is an error rather than a no-op: "pause the endpoint" that pauses nothing and reports success is the silent shape this selector exists to avoid.
|
|
366
|
+
|
|
367
|
+
codemod: none
|
|
368
|
+
|
|
369
|
+
### Fixed
|
|
370
|
+
|
|
371
|
+
- **@voltro/cli** — Cross-replica delivery is now tested over a network that is not loopback.
|
|
372
|
+
|
|
373
|
+
This closes the one item repeatedly written off as needing external infrastructure — "two real pods over a real network". That was the wrong variable. What a loopback number cannot show is a path with LATENCY, JITTER and a bandwidth ceiling, and injecting those is not only possible in the test stack, it is BETTER than a real network for a test: reproducible, and degradable on purpose.
|
|
374
|
+
|
|
375
|
+
`toxiproxy-test` joins `test/docker-compose.yml` as a degradable path to `redis-test`. Measured through it:
|
|
376
|
+
|
|
377
|
+
| condition | p50 | p99 | delivered | | --- | --- | --- | --- | | 20 ms ± 10 jitter | 26 ms | 89 ms | 200/200 | | + a 50 KB/s ceiling | 188 ms | 354 ms | 200/200 |
|
|
378
|
+
|
|
379
|
+
Seven times slower at the median under the second, and not one envelope lost. That is the property the new suite asserts: **degradation costs latency, never messages.**
|
|
380
|
+
|
|
381
|
+
The latency BUDGET is deliberately left in the healthy-path suite. Asserting it here would produce a test that goes red when the network is bad rather than when the code is — and the second row above is exactly that case.
|
|
382
|
+
|
|
383
|
+
codemod: none
|
|
384
|
+
- **@voltro/plugin-audit, @voltro/cli, @voltro/runtime** — **`@voltro/plugin-audit` could not boot — a release blocker, reported within a day.** 0.26.0 attached `interceptAction` and `interceptQuery` and declared neither scope, so the boot permission audit (`level: fatal`) refused to start EVERY app carrying the plugin, whether or not it had opted into query auditing. The audit inspects the presence of a hook, not what it does, so the identity passthrough counted.
|
|
385
|
+
|
|
386
|
+
The manifest declares both now — and `interceptQuery` is **attached** only when `recordQueries` is on, with its scope declared conditionally the way `store:write` already is. That is the reporter's suggestion and it is the better half of the fix: listing the scope unconditionally clears the boot while making every deployment DECLARE that it intercepts queries when almost none do, and a permission manifest is worth reading only if it describes what the plugin actually touches.
|
|
387
|
+
|
|
388
|
+
Their diagnosis of why it escaped is what the guard is built from: *a plugin's own test suite exercises the plugin, not a boot with the plugin installed*. The same shape as the `gc-snapshots` dialect bug one round earlier — the check that would have caught it is the one nobody ran on the affected path. There is now a test over the WHOLE `packages/plugin-*` set asserting that every hook a plugin ships has its scope named in its source, red-verified by reproducing 0.26.0.
|
|
389
|
+
|
|
390
|
+
**The stale-`source:` warning fired on the framework's own tables.** It resolved against the app's discovered entities, so every table the framework contributes conditionally — `_voltro_agent_messages` / `_voltro_agent_threads` behind a `*.agent.tsx`, and every plugin's `extendSchema.tables` — read as missing. The reporter got two warnings on every boot, for two sources that were correct, about a table the framework itself had created.
|
|
391
|
+
|
|
392
|
+
Their argument for why that is worse than cosmetic is the one that shaped the fix: this warning exists because a stale `source` is otherwise silent, so its entire value is being trusted. Firing on correct rows teaches the reader it is noise, and the next real one arrives into a warning nobody reads.
|
|
393
|
+
|
|
394
|
+
It resolves against the full live set now — app entities + plugin `extendSchema.tables` + framework tables, the same set auto-migrate emits DDL for — which means it runs after that set is assembled rather than inside `loadDiscovered`. Both boot paths do it, pinned by an ordering test.
|
|
395
|
+
|
|
396
|
+
codemod: none
|
|
397
|
+
- **@voltro/plugin-broadcast, @voltro/cli** — The broadcast namespace is normalised silently, and the silence reintroduces the hazard the namespace removes.
|
|
398
|
+
|
|
399
|
+
Found by probing the broadcast surface the way the events and records surfaces were probed. `broadcastPlugin` accepted all nine bad shapes tried — whitespace, a bare `>`, a trailing dot, an empty string, no options at all — and the sanitiser handles every one of them correctly. **No declaration-time refusal is warranted, and that is the finding**, not a gap.
|
|
400
|
+
|
|
401
|
+
What the probe surfaced is one step on: `my app` and `my.app` BOTH resolve to `my-app`. Two deployments configured DIFFERENTLY therefore share a channel, which is precisely what this option exists to prevent — arrived at by way of the option itself. The docs already say that staging and production of one app share a name and only this variable separates them, which is exactly the case where someone types two values believing they differ.
|
|
402
|
+
|
|
403
|
+
Nothing refuses: the resolved value is broker-safe either way, and failing a boot over a dot would be worse than the collapse. Both boot paths log the substitution when it changes what was written, and the message names the COLLAPSE rather than only the substitution — the substitution alone reads as cosmetic. Silent when the value survives unchanged, and silent for the derived app-name default, which is not something an operator can act on.
|
|
404
|
+
|
|
405
|
+
codemod: none
|
|
406
|
+
- **@voltro/plugin-broadcast, @voltro/cli** — **`broadcastPlugin()` with `REDIS_URL` set no longer stays silently on `memory`.** `REDIS_URL` counted for RESOLUTION but not for INFERENCE — it took an explicit `connection` option to be considered — so the plugin fell through to the in-process bus while the branch that would have read the variable sat directly below. Two doc strings promised the fallback ("inferred from … `REDIS_URL`", "falls back to `REDIS_URL`").
|
|
407
|
+
|
|
408
|
+
The asymmetry is what made it expensive rather than merely wrong: cache, kv and ratelimit all follow `<NAME>_REDIS_URL` → `REDIS_URL`, so an operator sets one variable, reads `cache backend resolved: redis` in the boot log, and concludes the bus did the same. A reporter did exactly that, on a single-replica deployment where the difference is unobservable — it appears on scale-up, as "some screens miss some events".
|
|
409
|
+
|
|
410
|
+
The caution the opt-in encoded is obsolete: every channel now carries the app-derived namespace, so attaching to a shared server no longer means two apps read each other's traffic. The test that pinned the old decision is reversed with that reasoning in it rather than deleted.
|
|
411
|
+
|
|
412
|
+
**The producer scan sees a locally bound publisher.** `\.publish\s*\(` misses
|
|
413
|
+
|
|
414
|
+
const publish = ctx.publish if (publish === undefined) return await publish(descriptor, {}, payload)
|
|
415
|
+
|
|
416
|
+
— which is not a corner case but the shape a handler writes when it guards the optional publisher. A reporter spent a quarter hour hunting for a missing publish they had just written, because the warning said their working event was dead. A false negative here is a missed warning; a false POSITIVE is a warning that lies about working code, and that is the expensive direction.
|
|
417
|
+
|
|
418
|
+
A bare `publish(` now counts, but only in a file that mentions `ctx.publish` or `ctx.events` — `publish` is too common a name to accept unqualified, and the qualifier also covers the `async ({ publish })` destructuring the dotted form misses for the same reason. Both directions tested.
|
|
419
|
+
|
|
420
|
+
codemod: none
|
|
421
|
+
- **@voltro/plugin-broadcast** — `broadcastPlugin` refuses a request it cannot honour instead of downgrading it silently.
|
|
422
|
+
|
|
423
|
+
Probed the way the events, records and presence surfaces were: five plausible mistakes, **five accepted**, and every one produced the same outcome — the in-process memory bus with a successful boot.
|
|
424
|
+
|
|
425
|
+
| written | got | said | | --- | --- | --- | | `provider: 'redes'` (typo) | memory | nothing | | `url: 'http://x'` | memory | nothing | | `url: ''` | memory | nothing | | `provider: 'redis'`, no url anywhere | memory | nothing |
|
|
426
|
+
|
|
427
|
+
On one replica each of these is indistinguishable from working. They appear on the second, as "some screens miss some events" — which is the report that led here, and it cost a consumer a deployment.
|
|
428
|
+
|
|
429
|
+
The asymmetry that decides it: **an app that configures nothing has taken a default, and memory is the honest answer. An app that writes `provider: 'redis'` has stated a requirement**, and answering a requirement with a downgrade is the shape removed everywhere else in this codebase.
|
|
430
|
+
|
|
431
|
+
So configuring nothing still takes memory, an explicit `provider: 'memory'` is still honoured — saying it out loud must not be worse than saying nothing — and a bare redis url still resolves without naming the provider. What throws is only the case where the request cannot be met: an unknown name (listing the valid ones, so the fix does not need the docs), a url whose scheme names no provider, and a named provider with no url anywhere (naming the variables that would satisfy it).
|
|
432
|
+
|
|
433
|
+
Red-verified: with the refusal removed, the two tests that assert it go red.
|
|
434
|
+
|
|
435
|
+
codemod: none
|
|
436
|
+
- **@voltro/cli** — Cross-replica delivery is now tested across a broker OUTAGE, not only a healthy or a degraded link.
|
|
437
|
+
|
|
438
|
+
The suites here proved delivery on a working link, and one proved it on a throttled one. None broke the link. That is the failure an operator actually meets — a redis restart, a failover, a partition that heals — and it was the last untested shape in the realtime stack.
|
|
439
|
+
|
|
440
|
+
**The property asserted is recovery, not delivery.** A broker that is down cannot carry messages, and claiming otherwise would be exactly the sort of guarantee this repo keeps removing. What must hold is that the link heals BY ITSELF: after the outage, delivery resumes with no process restart, no app-side retry and no resubscribe. A subscriber that silently stays dead after a blip is the worst realtime failure there is, because the screen keeps rendering and nothing reports it.
|
|
441
|
+
|
|
442
|
+
The test proves the link worked BEFORE it breaks it, so a zero at the end cannot be blamed on a link that never worked. Red-verified: leaving the proxy disabled gives 0 recovered deliveries instead of 10.
|
|
443
|
+
|
|
444
|
+
Also probed, and correct as found: a throwing listener does not kill the publish, does not stop its healthy siblings receiving, and does not leave the bus unusable afterwards. The 5 MB payload the bus accepts is fine — the size gate sits at the public seam (`ctx.events.publish`) and measures the ENCODED wire form, which is the representation that can actually be rejected downstream.
|
|
445
|
+
|
|
446
|
+
codemod: none
|
|
447
|
+
- **@voltro/protocol, @voltro/runtime, @voltro/cli** — **The credential bound covered one auth shape and the sentence did not say so.**
|
|
448
|
+
|
|
449
|
+
We wrote that "an event subscription can no longer outlive the credential that authorized it" and, a release later, that "the bound now covers EVERY realtime primitive". Both were true only for the `voltro:session` cookie: `sessionExpiryFromHeaders` read that cookie and nothing else, so for an app authenticating with Bearer JWTs the bound was always `undefined` — a no-op that reads as a guarantee.
|
|
450
|
+
|
|
451
|
+
A reporter found it by expecting black screens an hour after a deploy and getting none. Their framing is the one to keep: **the guarantee was not false, it was scoped to an auth shape the sentence did not name** — and we had corrected a different sentence in the same release for exactly that reason.
|
|
452
|
+
|
|
453
|
+
There was also no seam to close it with. `StrategyResolution` was `{ matched, subject }`, so the strategy — the only place in the system that verified the token and holds its `exp` — could not report it.
|
|
454
|
+
|
|
455
|
+
It can now: `{ kind: 'matched', subject, credentialExpiresAt? }`, optional, with absent still meaning no bound. The shared JWT strategy reports its verified `exp`, which covers all six catalog providers (auth0, clerk, kinde, oidc, supabase, workos) in one place rather than six near-identical lines that drift.
|
|
456
|
+
|
|
457
|
+
The expiry rides WITH the subject through the chain and is recorded on the per-connection channel that already carries subject overrides, so `ConnectionInfo` reads it instead of re-deriving from headers. Two sites deriving one fact is what let the cookie path and the bearer path disagree. Both boot paths do it, in the same change.
|
|
458
|
+
|
|
459
|
+
Tested for both shapes — including that `resolveScopes`, which rebuilds the subject, does not drop it. That would have reopened the hole for every app using the seam we point people at for this kind of augmentation.
|
|
460
|
+
|
|
461
|
+
codemod: none
|
|
462
|
+
- **@voltro/cli** — **Records and presence are now PROVEN cross-replica, not asserted.**
|
|
463
|
+
|
|
464
|
+
Asking one question across the whole surface — *which primitive is proven cross-replica against a real broker?* — gave an answer no amount of bug-fixing had:
|
|
465
|
+
|
|
466
|
+
| primitive | before | | --- | --- | | events | seven suites: partition, broker outage, degraded network | | records | **none** | | presence | **none** — zero broker use in all three of its suites |
|
|
467
|
+
|
|
468
|
+
"Multi-replica works" was proven for events and asserted for the other two, and they run through different code: events go bus → bridge → subscriber, records go `store.onChange` → broadcast → the peer's `injectExternalChange` → dispatcher → subscription. Only one had been driven end to end.
|
|
469
|
+
|
|
470
|
+
**Presence** now proves what a consumer had to measure by hand with `redis-cli PUBSUB NUMSUB` because the framework was telling them the opposite: a member tracked on A appears in B's roster, opaque `meta` survives the hop, and a leave on A removes it from B. A roster that only ever GROWS across instances is the failure that looks like success.
|
|
471
|
+
|
|
472
|
+
**Records** cost three wrong attempts, and the reason is worth more than the test. Two independent in-memory stores cannot model this: `injectExternalChange` NOTIFIES without persisting — deliberately, because replicas share a DATABASE and the peer re-reads storage they have in common. With separate stores the notification arrives (measured: called exactly once) and the re-read finds nothing, so no delta is emitted. Correct behaviour against an incorrect topology — and reported as a defect it would have sent someone hunting the bus for a bug that is not there. The suite runs one postgres, two stores, two dispatchers.
|
|
473
|
+
|
|
474
|
+
Two harness errors along the way are recorded in the files rather than quietly fixed: `tracker.track()` alone is a LOCAL write (the route calls `announce(track(...))`), and a predicate literal is `{ column, op, value }` — using `kind` instead of `op` matched nothing, so the missing delta was correct. Both would have been reported as framework defects.
|
|
475
|
+
|
|
476
|
+
codemod: none
|
|
477
|
+
- **@voltro/cli** — The `no-consumer` half of the event audit sees sibling apps.
|
|
478
|
+
|
|
479
|
+
It read the API app's own tree, and in a monorepo the `useEvent` calls are not there — they are in the web apps beside it. A reporter had ten declared events, all ten consumed, all ten calls in ONE file in a sibling app, and got ten `no-consumer` warnings. A check that is wrong ten times out of ten carries no signal, and they ranked the two halves themselves: the producer half found them a dead trigger node that had not fired since a migration; the consumer half found nothing and spent the attention the producer half needed.
|
|
480
|
+
|
|
481
|
+
The siblings are not guessed from directory layout. `pnpm-workspace.yaml` declares them, so this reads what the workspace already says — a project outside a workspace costs nothing, which is the common single-app case.
|
|
482
|
+
|
|
483
|
+
Bounded at 4000 files, and LOUDLY: hitting the bound logs that a `no-consumer` line below may mean "we stopped looking" rather than "nothing consumes it". A silently truncated scan is the same false confidence one layer down, which is the defect this whole audit exists to remove.
|
|
484
|
+
|
|
485
|
+
codemod: none
|
|
486
|
+
- **@voltro/protocol** — `defineEvent` refuses four authoring mistakes it used to accept.
|
|
487
|
+
|
|
488
|
+
Found by probing what it lets through rather than by reading it: nine plausible mistakes were tried, nine were accepted. The surface had exactly two refusals, one of which (`latest` + `webhook`) is a model for the rest.
|
|
489
|
+
|
|
490
|
+
**Whitespace in a name is the severe one — a production-only silence.** The name becomes a broker SUBJECT segment, and NATS refuses a subject containing whitespace and delivers nothing, with no error on the publishing side. An app that works on Redis stops working when the transport changes: silently, on one broker only. Refused at declaration, where the author can still see the string, and the message names the dot form to use instead.
|
|
491
|
+
|
|
492
|
+
**`guards: []`** is refused because the enforcement in `bindEvent` runs only for a non-empty list — so it reads at the call site as if the event were protected and secures nothing. That is the declared-and-inert shape this codebase keeps finding; an omitted field is the honest spelling for unguarded.
|
|
493
|
+
|
|
494
|
+
**`webhook.rateLimit.perMinute: 0`** defers every delivery forever, and there is no "unlimited" spelling for the field, so 0 is almost always someone reaching for one. **`webhook.version: 0`** would make a subscriber pinned to 1 read the event as *behind* — the opposite of what a version bump means.
|
|
495
|
+
|
|
496
|
+
Each message says what is wrong, why, and what to write instead; a test asserts that every refusal is more than one line, because a message that only names the rule leaves the reader guessing at the reason, and the reason is usually what they needed.
|
|
497
|
+
|
|
498
|
+
codemod: none
|
|
499
|
+
- **@voltro/database, @voltro/runtime, @voltro/cli, @voltro/plugin-presence** — **`pluginRef` declarations survive `table()` and are readable as `table.appliedPluginRefs`.** They did not, and the consequence reached further than the reporter could see.
|
|
500
|
+
|
|
501
|
+
`pluginRefSpecOf` reads a column BUILDER; `table()` materialises builders into plain field descriptors. So the declaration vanished the instant the table existed, and the column read as an ordinary `text()`.
|
|
502
|
+
|
|
503
|
+
A consumer's CRUD generator and their contract test both derive "which column carries the tenant" from the schema, both asked `type === 'reference'`, and a `pluginRef` column answered no — so generated junction handlers dropped the tenant sub-query and a favourite could point at another tenant's row. Their test missed it for the same reason the generator did: **a checker sharing the assumption of the thing it checks.** They caught it only because they happened to teach discovery about `pluginRef` before the generator; the other order ships the regression.
|
|
504
|
+
|
|
505
|
+
**On our side it was worse and they could not have known.** The framework's own orphan-rule collector walked the column bag asking `pluginRefSpecOf`, got `undefined` every time, and produced ZERO rules on every real schema — so `orphanPolicy: 'delete'` did nothing, for the second release running. Its wiring test stayed green because it asserted the collector was CALLED, never that it returned anything.
|
|
506
|
+
|
|
507
|
+
A test against a real `table()` then found a THIRD defect immediately: the collector read `spec.target().name`, and a table's property is `tableName`, so every target resolved to undefined and the boot refusal fired for every `pluginRef`. The fixtures returned `{ name }` — confirming the wrong assumption rather than testing it.
|
|
508
|
+
|
|
509
|
+
That confusion had spread. The stale-`source:` resolver in BOTH boot paths built its table set the same way, producing an empty set — and `unresolvedSources` returns nothing for an empty set by design, so the warning silently stopped firing. **The fix for one false positive had turned the other into silence.** A narrow source guard now catches the shape; its own first run flagged a correct workflow read, which is recorded in the file, because a guard that opens with a false positive gets muted.
|
|
510
|
+
|
|
511
|
+
**The presence broker warning fires after the bus attaches.** It ran at plugin activation, and the broadcast bus attaches later — the reporter measured 634 ms, then confirmed with `PUBSUB NUMSUB` that presence was cross-instance while the log said otherwise. The check is deferred and re-reads the transport at fire time. This exact warning had just found them a real misconfiguration and then kept reporting the fault after the repair, which is how a warning spends the credibility it earned.
|
|
512
|
+
|
|
513
|
+
codemod: none
|
|
514
|
+
- **@voltro/plugin-presence** — `presencePlugin` refuses a `timeoutMs` that expires members between heartbeats.
|
|
515
|
+
|
|
516
|
+
The presence surface, probed the way events, records and broadcast were: five plausible mistakes tried, five accepted. Zero and a negative are the obvious two; the one worth the rule is a value SMALLER than the client's heartbeat, because that is the mistake with a plausible motive ("expire people quickly") and a silent failure.
|
|
517
|
+
|
|
518
|
+
`timeoutMs` is one half of a contract whose other half lives in the client. A member is online for `timeoutMs` after its last heartbeat, and `usePresence` beats every 15s by default. Below that, every member expires between beats — the roster flaps empty and nothing reports it, because an empty roster is also what "nobody is here" looks like.
|
|
519
|
+
|
|
520
|
+
A consumer wrote that pairing down themselves ("our 10s heartbeat is the other half of the contract"), which is evidence the rule is real AND that it was left to the reader to work out. It is stated in both languages now, and the message names the CLIENT side, since a message naming only the server value sends the reader looking for the number in another package.
|
|
521
|
+
|
|
522
|
+
A long window is still fine — a signage terminal beating once a minute is a real deployment. The rule is a floor, not a range.
|
|
523
|
+
|
|
524
|
+
codemod: none
|
|
525
|
+
- **@voltro/protocol** — `defineQuery` refuses three contradictions it used to accept — the same probe that found four on `defineEvent`, run against the records surface.
|
|
526
|
+
|
|
527
|
+
That symmetry is the point rather than a coincidence. `guards: []` was refused on events an hour after it was accepted on queries, and a rule that holds for one primitive and not another is worse than no rule: the framework's answer then depends on which file the author happened to open.
|
|
528
|
+
|
|
529
|
+
- **`guards: []`** reads at the call site as if the procedure were protected and enforces nothing — the check runs only for a non-empty list. - **An empty `source`** (`''`, `[]`, or a blank entry) declares reactivity and subscribes to nothing: one snapshot, never an update, indistinguishable from "nothing changed". It is worse than a STALE source, which the boot warning can at least name — this one names no table at all, so nothing can report it. - **`internal: true` + `overridesPlugin`** removes the plugin's route and puts something not wire-reachable in its place, so callers get a 404 for something that used to work with no diff that says so. It extends the existing `assertWireSurfaceConsistent` contract rather than adding a second rule beside it.
|
|
530
|
+
|
|
531
|
+
Also settled, by reading the runtime rather than declining again: **`rewind` needs no rule.** It replays the pruned ring on attach — with `each` that is "catch up on what you missed", with `latest` it is "here is the current value". Both are meaningful, so the combination that looked suspicious is fine, and a test now pins that decision so the next reader does not re-open it.
|
|
532
|
+
|
|
533
|
+
codemod: none
|
|
534
|
+
- **@voltro/cli** — The one multi-replica scenario with no test: a replica that goes away, misses traffic, and comes back. The existing suites prove two replicas REACH each other, not what happens when one stops being able to.
|
|
535
|
+
|
|
536
|
+
It covers both directions of the claim that `missed` is COMPUTED and never estimated. **Under-reporting** is the silence this primitive exists to remove. **Over-reporting** is the freshly-started replica announcing a loss for messages it was never owed — measured once at 5000, and the reason every delivery carries `prior`.
|
|
537
|
+
|
|
538
|
+
The accounting identity is the assertion: every envelope owed after the resume point is either replayed or reported, and the two must sum to what was owed.
|
|
539
|
+
|
|
540
|
+
**The first version of that test was vacuous, and the reason is worth recording because it is the fourth instance this session.** It published six envelopes into the default ring of 64, so the ring held everything, `missed` was always 0, and the identity was true by arithmetic for any implementation at all — sabotaging the computation to under-report by one left it green. The ring is now deliberately SMALLER than the traffic (`ringSize: 3`, ten publishes), the non-vacuity assertions come FIRST, and the same sabotage now fails it 8-to-9.
|
|
541
|
+
|
|
542
|
+
`describeIfReachable`, verified both ways: with `REDIS_PORT=1` it reports two named skips rather than returning green having tested nothing.
|
|
543
|
+
|
|
544
|
+
codemod: none
|
|
545
|
+
- **@voltro/runtime** — A resume replay could be **overtaken** by live traffic, delivering serials out of order.
|
|
546
|
+
|
|
547
|
+
Found by testing the case a busy app produces and the reconnect tests do not: a backlog being replayed at the same moment new envelopes are accepted, because a real reconnect does not pause the publisher. Measured — resuming from `n=3` over a ring of 8, with an ordinary re-entrant publish from the listener at `n=6`, delivered `[4, 5, 6, 9, 7, 8]`.
|
|
548
|
+
|
|
549
|
+
The cause is an ordering that is right for a different reason. The listener is registered BEFORE the replay on purpose: it closes the window between reading the ring and going live, so nothing published in between is lost. What it does not do on its own is keep the two streams in sequence — a live delivery reaches the listener immediately and jumps ahead of the entries still queued behind it.
|
|
550
|
+
|
|
551
|
+
Out-of-order is worse than loss for anything that folds state: a display applying an older frame after a newer one shows the past and stays there. And `n` arriving non-monotonically undermines the serial every gap number is computed from.
|
|
552
|
+
|
|
553
|
+
Live deliveries are now buffered for the duration of the replay and flushed after it, in arrival order, synchronously before `subscribe` returns — an async flush would reopen the window the early registration exists to close. Both properties hold: nothing is missed, and nothing overtakes.
|
|
554
|
+
|
|
555
|
+
The new tests also pin two things the quiet reconnect case cannot see: no serial is delivered twice when an envelope is in the ring at the moment of attach, and traffic arriving during a replay is not reported as a gap.
|
|
556
|
+
|
|
557
|
+
codemod: none
|
|
558
|
+
- **@voltro/cli** — The socket.io comparison published one run per side. Both its numbers were noise, and it is corrected here with five runs each.
|
|
559
|
+
|
|
560
|
+
| | p50 median | p50 range | p99 median | p99 range | | --- | --- | --- | --- | --- | | Voltro cross-replica | **0.68 ms** | 0.58–0.86 | 7.01 ms | 4.50–13.36 | | socket.io + redis-adapter | 1.31 ms | 1.16–1.73 | **4.52 ms** | 4.33–7.83 |
|
|
561
|
+
|
|
562
|
+
The earlier table claimed "32% faster at the median, 44% worse at the tail". The median advantage is nearer **2x** — the p50 ranges do not overlap at all — and the tail gap sits INSIDE the overlap, so it is weaker evidence than a single pair of numbers made it look.
|
|
563
|
+
|
|
564
|
+
**A single measurement presented as a fact is the defect this framework spends its time removing, and it was committed in its own benchmark.** The correction is the finding.
|
|
565
|
+
|
|
566
|
+
**Where the tail comes from, measured rather than guessed.** Splitting the publish path: our own code — building the envelope, the Effect fiber per message, the handoff — costs p50 **0.056 ms** / p99 **0.444 ms**. Waiting for Redis to acknowledge costs p50 1.17 ms / p99 6.43 ms.
|
|
567
|
+
|
|
568
|
+
So roughly 0.4 ms of a 7 ms tail is ours and the rest is the broker round-trip, which socket.io pays too. The `Effect.runPromise` per message was the leading hypothesis and the measurement cleared it. There is no code-level tail defect to fix — on this machine the number is dominated by Docker's network stack.
|
|
569
|
+
|
|
570
|
+
codemod: none
|
|
571
|
+
|
|
572
|
+
---
|
|
573
|
+
|
|
42
574
|
## [0.26.0] — 2026-08-04
|
|
43
575
|
|
|
44
576
|
### ⚠ BREAKING
|
package/dist/index.d.ts
CHANGED
|
@@ -60,6 +60,9 @@ export declare interface AuditEvent {
|
|
|
60
60
|
* the thing this field exists to prevent.
|
|
61
61
|
*/
|
|
62
62
|
readonly actor?: AuditActor | undefined;
|
|
63
|
+
/** The app's own scoping dimension — opaque, stored verbatim, filterable.
|
|
64
|
+
* `.with(tenant())` is one level too coarse for a per-team trail. */
|
|
65
|
+
readonly scope?: unknown;
|
|
63
66
|
/** The app's own note about what happened. Opaque, never interpreted. */
|
|
64
67
|
readonly metadata?: unknown;
|
|
65
68
|
readonly traceId: string;
|
|
@@ -132,6 +135,45 @@ export declare interface AuditPluginOptions {
|
|
|
132
135
|
* of `@effect/sql`.
|
|
133
136
|
*/
|
|
134
137
|
readonly sink?: 'console' | 'memory' | 'datastore' | AuditSink;
|
|
138
|
+
/**
|
|
139
|
+
* Derive the app's own scoping dimension for each recorded call.
|
|
140
|
+
*
|
|
141
|
+
* The framework cannot guess this: it does not know what a team, a project or
|
|
142
|
+
* a workspace is, which is exactly why the column is opaque. The app knows —
|
|
143
|
+
* from the subject, or from the call's INPUT:
|
|
144
|
+
*
|
|
145
|
+
* (ctx) => ({ teamId: ctx.subject.metadata?.teamId }) // subject-shaped app
|
|
146
|
+
* (ctx) => typeof ctx.input?.teamId === 'string' // most apps
|
|
147
|
+
* ? { teamId: ctx.input.teamId } : undefined
|
|
148
|
+
*
|
|
149
|
+
* **`input` is here because the subject-only version covered the wrong half.**
|
|
150
|
+
* A reporter's users belong to MANY teams, so their session carries no
|
|
151
|
+
* "current team" and cannot without inventing a concept their product does not
|
|
152
|
+
* have. A mutation's team comes from its input or from the row it loads. Their
|
|
153
|
+
* API-key subjects DO carry a `teamId` — which made the subject-only resolver
|
|
154
|
+
* worse than useless for them: it would have populated for key-authenticated
|
|
155
|
+
* calls and been null for every human one, so a filtered view would look like
|
|
156
|
+
* it worked.
|
|
157
|
+
*
|
|
158
|
+
* **The input here is RAW — it is not what `redactInput` will store.** That is
|
|
159
|
+
* required (a scope derived from a redacted payload is not derivable at all)
|
|
160
|
+
* and it is a hazard worth stating: whatever you return lands in `scope`,
|
|
161
|
+
* which is NOT redacted. Return the dimension, never the payload.
|
|
162
|
+
*
|
|
163
|
+
* Absent ⇒ `scope` stays null and the column costs nothing. Present ⇒ it is
|
|
164
|
+
* written verbatim and can be filtered on equality, which is the difference
|
|
165
|
+
* between an indexed read path and stuffing `teamId` into `metadata` and
|
|
166
|
+
* scanning — a column documented as a free-form note is not a read path.
|
|
167
|
+
*
|
|
168
|
+
* Throwing here never fails the mutation being recorded: a scope that cannot
|
|
169
|
+
* be derived is null, the same answer as not configuring one.
|
|
170
|
+
*/
|
|
171
|
+
readonly resolveScope?: (ctx: {
|
|
172
|
+
readonly subject: Subject;
|
|
173
|
+
readonly tag: string;
|
|
174
|
+
/** The call's raw input — before `redactInput`. */
|
|
175
|
+
readonly input?: unknown;
|
|
176
|
+
}) => unknown;
|
|
135
177
|
/**
|
|
136
178
|
* Record QUERIES too.
|
|
137
179
|
*
|
|
@@ -199,6 +241,41 @@ export declare interface AuditPluginOptions {
|
|
|
199
241
|
* function once you know your own inputs.
|
|
200
242
|
*/
|
|
201
243
|
readonly redactInput?: 'all' | 'none' | ((event: AuditEvent) => unknown);
|
|
244
|
+
/**
|
|
245
|
+
* What happens to `AuditEvent.subject` before it is handed to the sink.
|
|
246
|
+
*
|
|
247
|
+
* - `'metadata'` (DEFAULT) — `subject.metadata` is replaced by
|
|
248
|
+
* `{ __redacted: 'all' }`. `type`, `id`, `tenantId` and `scopes` survive,
|
|
249
|
+
* which is everything the trail is actually read for.
|
|
250
|
+
* - `'none'` — the subject verbatim. What every sink did before this option
|
|
251
|
+
* existed.
|
|
252
|
+
* - a function — `(subject) => unknown`, for field-level control.
|
|
253
|
+
*
|
|
254
|
+
* **This exists because the durable sink wrote a live credential.** A reporter
|
|
255
|
+
* found a working Jira Personal Access Token in plaintext in 12 of 23 rows of
|
|
256
|
+
* their `_voltro_audit_log`, and neither plugin involved was wrong on its own:
|
|
257
|
+
*
|
|
258
|
+
* - `@voltro/plugin-atlassian`'s `credentialsResolver` takes a `Subject` and
|
|
259
|
+
* NOTHING else, so an app doing per-user Atlassian auth has no place to
|
|
260
|
+
* put the caller's PAT except `subject.metadata`;
|
|
261
|
+
* - this plugin serialised the subject verbatim into a json column.
|
|
262
|
+
*
|
|
263
|
+
* Two correct contracts that disagree about what a Subject IS — an identity,
|
|
264
|
+
* or a credential envelope — with nothing reconciling them.
|
|
265
|
+
*
|
|
266
|
+
* **The reasoning is `redactInput`'s, word for word, applied to the field it
|
|
267
|
+
* did not cover.** `metadata` is not a table column either, so no schema
|
|
268
|
+
* marker protects it; it is app-controlled, so its contents cannot be reasoned
|
|
269
|
+
* about here; and the framework's own per-user-credential mechanism puts a
|
|
270
|
+
* credential in it. The choice is between recording credentials by default and
|
|
271
|
+
* recording an app-controlled bag by default. Losing that bag is visible the
|
|
272
|
+
* first time you read a row; leaking a credential is not visible at all.
|
|
273
|
+
*
|
|
274
|
+
* `resolveScope` still sees the LIVE subject, so a scope derived from
|
|
275
|
+
* `metadata` keeps working — redaction applies to what is STORED, not to what
|
|
276
|
+
* the plugin can compute.
|
|
277
|
+
*/
|
|
278
|
+
readonly redactSubject?: 'metadata' | 'none' | ((subject: Subject) => unknown);
|
|
202
279
|
}
|
|
203
280
|
|
|
204
281
|
/** The narrow read surface the entry points need. */
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ var m = "_voltro_audit_log", h = d(m, {
|
|
|
15
15
|
durationMs: f(),
|
|
16
16
|
subject: s(),
|
|
17
17
|
actor: s().nullable(),
|
|
18
|
+
scope: s().nullable(),
|
|
18
19
|
metadata: s().nullable(),
|
|
19
20
|
input: s(),
|
|
20
21
|
outcome: s(),
|
|
@@ -37,6 +38,7 @@ var m = "_voltro_audit_log", h = d(m, {
|
|
|
37
38
|
durationMs: String(e.outcome.durationMs),
|
|
38
39
|
subject: e.subject,
|
|
39
40
|
actor: e.actor ?? null,
|
|
41
|
+
scope: e.scope ?? null,
|
|
40
42
|
metadata: e.metadata ?? null,
|
|
41
43
|
input: e.input,
|
|
42
44
|
outcome: e.outcome,
|
|
@@ -134,12 +136,36 @@ var m = "_voltro_audit_log", h = d(m, {
|
|
|
134
136
|
...t,
|
|
135
137
|
input: n === "all" ? l : n(t)
|
|
136
138
|
};
|
|
137
|
-
}, f = (
|
|
138
|
-
let
|
|
139
|
-
|
|
139
|
+
}, f = (t) => {
|
|
140
|
+
let n = e.redactSubject ?? "metadata";
|
|
141
|
+
if (n === "none") return t;
|
|
142
|
+
if (typeof n == "function") return {
|
|
143
|
+
...t,
|
|
144
|
+
subject: n(t.subject)
|
|
145
|
+
};
|
|
146
|
+
let r = t.subject;
|
|
147
|
+
if (r.metadata === void 0) return t;
|
|
148
|
+
let { metadata: i, ...a } = r;
|
|
149
|
+
return {
|
|
150
|
+
...t,
|
|
151
|
+
subject: {
|
|
152
|
+
...a,
|
|
153
|
+
metadata: l
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}, p = (e) => s(e) ? a(f(d(e))) : t.void, h = (t) => {
|
|
157
|
+
if (e.resolveScope !== void 0) try {
|
|
158
|
+
return e.resolveScope(t);
|
|
159
|
+
} catch {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}, _ = (e, n) => o(n.tag) ? t.suspend(() => {
|
|
163
|
+
let r = Date.now(), i = h(n);
|
|
164
|
+
return e.pipe(t.tap((e) => p({
|
|
140
165
|
ts: r,
|
|
141
166
|
tag: n.tag,
|
|
142
167
|
subject: n.subject,
|
|
168
|
+
...i === void 0 ? {} : { scope: i },
|
|
143
169
|
traceId: n.traceId,
|
|
144
170
|
input: n.input,
|
|
145
171
|
outcome: {
|
|
@@ -147,10 +173,11 @@ var m = "_voltro_audit_log", h = d(m, {
|
|
|
147
173
|
value: e,
|
|
148
174
|
durationMs: Date.now() - r
|
|
149
175
|
}
|
|
150
|
-
}).pipe(t.catchAllCause(() => t.void))), t.tapErrorCause((e) =>
|
|
176
|
+
}).pipe(t.catchAllCause(() => t.void))), t.tapErrorCause((e) => p({
|
|
151
177
|
ts: r,
|
|
152
178
|
tag: n.tag,
|
|
153
179
|
subject: n.subject,
|
|
180
|
+
...i === void 0 ? {} : { scope: i },
|
|
154
181
|
traceId: n.traceId,
|
|
155
182
|
input: n.input,
|
|
156
183
|
outcome: {
|
|
@@ -159,20 +186,25 @@ var m = "_voltro_audit_log", h = d(m, {
|
|
|
159
186
|
durationMs: Date.now() - r
|
|
160
187
|
}
|
|
161
188
|
}).pipe(t.catchAllCause(() => t.void))));
|
|
162
|
-
}) : e,
|
|
189
|
+
}) : e, v = _, y = _, b = _;
|
|
163
190
|
return n({
|
|
164
191
|
name: "@voltro/plugin-audit",
|
|
165
192
|
description: "Records every mutation invocation; ships an audit() schema mixin for row-level metadata.",
|
|
166
|
-
permissions:
|
|
193
|
+
permissions: [
|
|
194
|
+
"rpc:intercept:mutation",
|
|
195
|
+
"rpc:intercept:action",
|
|
196
|
+
...r ? ["store:write"] : [],
|
|
197
|
+
...e.recordQueries === !0 ? ["rpc:intercept:query"] : []
|
|
198
|
+
],
|
|
167
199
|
...r ? {
|
|
168
200
|
extendSchema: { tables: g },
|
|
169
201
|
bindDataStore: (e) => {
|
|
170
202
|
i = x(e);
|
|
171
203
|
}
|
|
172
204
|
} : {},
|
|
173
|
-
interceptMutation:
|
|
174
|
-
interceptAction:
|
|
175
|
-
interceptQuery:
|
|
205
|
+
interceptMutation: v,
|
|
206
|
+
interceptAction: y,
|
|
207
|
+
...e.recordQueries === !0 ? { interceptQuery: b } : {}
|
|
176
208
|
});
|
|
177
209
|
};
|
|
178
210
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/plugin-audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.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.
|
|
41
|
-
"@voltro/logger": "0.
|
|
42
|
-
"@voltro/protocol": "0.
|
|
40
|
+
"@voltro/database": "0.28.0",
|
|
41
|
+
"@voltro/logger": "0.28.0",
|
|
42
|
+
"@voltro/protocol": "0.28.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"effect": "^3.22.0"
|