@voltro/plugin-atlassian 0.20.2 → 0.22.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 +398 -0
- package/THIRD-PARTY-NOTICES.md +2 -2
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,404 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## [0.22.0] — 2026-08-01
|
|
43
|
+
|
|
44
|
+
### ⚠ BREAKING
|
|
45
|
+
|
|
46
|
+
- **@voltro/protocol, @voltro/runtime, @voltro/cli** — **`cache: { scope: 'tenant' }` — one entry per org, none shared across orgs.**
|
|
47
|
+
|
|
48
|
+
BREAKING only in the "more precise is still breaking" sense: the union `'subject' | 'global'` gained a member. Every scope you already wrote still compiles; what can stop compiling is code that consumes the union EXHAUSTIVELY (a `switch` with an `assertNever`, a `Record<QueryCacheScope, …>`). Almost always framework-internal rather than app code — the codemod is a `manual` note so the compile error is recognised rather than debugged, because a transform cannot tell a switch that wants a `tenant` branch from one whose author should look at the query and decide.
|
|
49
|
+
|
|
50
|
+
`scope` took `'subject' | 'global'`, and for an org-wide figure neither fits. `'subject'` recomputes it per PERSON; `'global'` shares one entry across every caller. A consumer put it exactly:
|
|
51
|
+
|
|
52
|
+
> `'subject'` rechnet pro Person neu … bei 18 Mitarbeitern also bis zu 18 > identische Berechnungen derselben Zahlen. `'global'` würde über > Mandantengrenzen hinweg teilen. Für Daten, die aus `subject.tenantId` > abgeleitet sind, ist das kein Cache, sondern ein Leck.
|
|
53
|
+
|
|
54
|
+
They took the 18 computations of a nine-table statistic rather than write the leak. That was the right call, and it should not have been a call.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
export const last12Months = defineQuery({
|
|
58
|
+
name: 'globalStatistics.last12Months',
|
|
59
|
+
input: Schema.Struct({}),
|
|
60
|
+
output: Stats,
|
|
61
|
+
source: ['invoices', 'employees'],
|
|
62
|
+
cache: { ttl: '5m', scope: 'tenant' },
|
|
63
|
+
})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Rubric, now three-way: does the resolved predicate depend on the caller? On the PERSON → `subject`; on their ORG only → `tenant`; on neither → `global`.
|
|
67
|
+
|
|
68
|
+
**A caller with no tenant BYPASSES a `'tenant'` cache** rather than falling back. Falling back to `global` is the leak the option exists to avoid; falling back to `subject` silently turns a cache the author sized per org into one sized per person. Note this covers `tenantId: null` as well as absent — an anonymous subject's `tenantId` is `string | null`, so `null` is the shape that actually arrives, and treating only `undefined` as absent would key every tenantless caller under one literal `:t:null` entry.
|
|
69
|
+
|
|
70
|
+
**And `scope: 'global'` over a `tenant()`-scoped table is now reported.** The option alone would have left the leak one word away, in a field whose two legal values differ by one word. `cacheScopeLeaks` runs in the discovery both boot paths share — so dev and serve cannot disagree about what a leak is — and rides the same gate as the `.serverOnly()` audit: `voltro dev` warns, `VOLTRO_SERVER_ONLY=strict` refuses. It names every offending table rather than the first, is silent for `'global'` on reference data (the case the option exists for), and is silent for a query with no declared `source`, where it has nothing to reason about and a guess would be a warning nobody can act on.
|
|
71
|
+
|
|
72
|
+
**Not a replacement for modelling.** The same consumer moved that statistic to a `defineAggregate` with `tenantId` as an indexed column, which puts the tenant boundary in the DATA rather than in a cache key — better for a rollup, and they say so. `scope: 'tenant'` is for the other case they name: a query that must be FRESH and is merely expensive, where an aggregate's refresh interval is the wrong instrument.
|
|
73
|
+
- **@voltro/cli** — **A destructive inspect endpoint now needs its own credential. One token for "list my routes" and "erase this person" was one token too few.**
|
|
74
|
+
|
|
75
|
+
`/_voltro/inspect/*` is not read-only. Plugins mount POSTs on it that DO things: `plugin-governance`'s `/erase` is an irreversible GDPR right-to-be-forgotten deletion and `/export` a full personal-data dump; `plugin-storage` mints and revokes object access. Every one of them sat behind the same bearer as reading a route list, so anything that could read the sitemap could erase a person.
|
|
76
|
+
|
|
77
|
+
An earlier change in this series made plugins DECLARE `inspect:write` for a mutating endpoint, and shipped with the note that this "governs what a plugin may mount, not who may call it". That was a footnote under a GDPR erasure endpoint, not a fix. This is the half that closes it.
|
|
78
|
+
|
|
79
|
+
**A mutating method requires `VOLTRO_INSPECT_WRITE_TOKEN`, sent as the `x-voltro-inspect-write` header ON TOP of the bearer** — an additional factor, not an alternative credential: the read token still has to be correct to get there. GET / HEAD / OPTIONS are unaffected. Unset → refused, with the same posture as the read token: *the absence of a secret is not consent.*
|
|
80
|
+
|
|
81
|
+
**`voltro dev` mints it** per project, exactly like the read token, and the dashboard proxy injects it for loopback targets under the same three conditions the bearer already had (loopback only, never over a caller's own header, only when a value exists). So the dev loop is unchanged and no developer handles a secret. **Nothing mints it for `serve` / `start` / a bare harness** — in production a destructive endpoint should take a deliberate act to enable.
|
|
82
|
+
|
|
83
|
+
**The design decision worth knowing.** `InspectAuthResolver` gained a REQUIRED `method` parameter. The alternatives were both worse: optional-and-skipped is fail-open at exactly the call site most likely to be added carelessly, and optional-and-refused makes the exported resolver hostile to every legitimate read caller — which is how a security default gets replaced with a custom resolver that does less. A required parameter puts the check in the compiler, and it earned that immediately: `tsc` named **eight** more mount points than the four found by hand, including `pluginInspect.ts` (where the destructive plugin POSTs actually live) and `start.ts` (production).
|
|
84
|
+
|
|
85
|
+
BREAKING for a caller of `envTokenAuthResolver` / a custom `InspectAuthResolver` CALL SITE — the second argument is required. A resolver IMPLEMENTATION is unaffected: `(headers) => …` still satisfies the type. The codemod is a `manual` note; a transform cannot know whether a given mount is a read or a write, and guessing on this surface is how the boundary would be lost again.
|
|
86
|
+
- **@voltro/database, @voltro/cli, @voltro/devtools-ui** — **An index whose name changed is now renamed, not rebuilt.** The planner gained a `rename-index` operation; where it applies, `voltro db apply` emits one catalog-only statement instead of `DROP INDEX` + `CREATE INDEX`.
|
|
87
|
+
|
|
88
|
+
The cost this removes is not hypothetical. Auto-named indexes are `<table>_<column>_idx`, and **no dialect renames an index when the column under it is renamed** — verified on postgres 18, MySQL 8.4 and MariaDB 11.8. So every `.renamedFrom()` column rename, itself a metadata-only operation, dragged a full B-tree rebuild of that column's indexes behind it: on a large table, minutes of IO and — without `CONCURRENTLY` — a write lock. The same probe confirms the replacement is free: postgres reports an unchanged `relfilenode` across the rename, which is the definition of "no rebuild happened".
|
|
89
|
+
|
|
90
|
+
It is deliberately narrow, because the cases left out are the ones that cannot be made safe by inspection. **sqlite** has no rename statement, so it keeps drop + create rather than have the plan disagree with what runs. **UNIQUE** indexes are constraint objects whose rename syntax diverges by dialect. **Expression** indexes have no comparable key text (the DB normalises it). And **two same-shaped indexes renamed at once** is ambiguous — nothing says which became which, so both rebuild. A rebuild is slow; renaming the wrong catalog object is worse.
|
|
91
|
+
|
|
92
|
+
**Why this is BREAKING for a purely additive change.** Widening a union that the framework PRODUCES breaks every exhaustive `switch` a consumer wrote over it. That is not a theoretical reading — it broke one inside this repo, which is how the second half of this entry was found.
|
|
93
|
+
|
|
94
|
+
**Also fixed: the dashboard could not render two operation kinds.** `add-unique-composite` and `drop-unique-composite` were missing from `@voltro/devtools-ui`'s hand-written `OperationKind`, from its `formatOp` switch, and from the CLI's inspect payload — for as long as those ops have existed. Nothing crashed; the row just rendered blank, which reads exactly like a plan that has no such step. The CLI's copy of the union is now DERIVED from the planner's, the renderer's switch is exhaustive by construction (no `default:` arm — that would swallow the next one), and a parity test in `@voltro/database` fails on any divergence in the one copy that genuinely cannot be derived.
|
|
95
|
+
- **@voltro/database, @voltro/plugin-notifications, @voltro/plugin-scim, @voltro/plugin-ai-flows, @voltro/cli, @voltro/devtools-ui** — **Ten plugin-owned tables move into the `_voltro_` namespace, and the planner learned to carry a table across instead of dropping it.**
|
|
96
|
+
|
|
97
|
+
`plugin-notifications` (`notification_inbox`, `notification_preferences`, `notification_deliveries`, `notification_topic_subscriptions`, `notification_quiet_hours`, `notification_held`), `plugin-scim` (`scim_users`, `scim_groups`) and `plugin-ai-flows` (`ai_flows`, `ai_flow_runs`) registered framework-OWNED tables into the USER's table namespace while every other plugin used `_voltro_*`. An app with a same-named table collided with the framework.
|
|
98
|
+
|
|
99
|
+
**Nothing is required of you.** The tables carry their rows across on the next `voltro db apply` — or a `voltro dev` / `voltro serve` boot with auto-migrate — as a catalog-only `ALTER TABLE … RENAME TO`. Run `voltro db plan` first if you want to see it; it prints the renames without touching anything.
|
|
100
|
+
|
|
101
|
+
**The blocker was a missing primitive, not the rename.** A rename and a drop+create are structurally identical to a differ — old table gone, new table present — and for a TABLE the difference is all of the data, so the planner had no way to express one and this sat as a known gap. It can now:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
table('_voltro_notes', { id: id({ prefix: 'note' }), body: text() }).renamedFrom('notes')
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`ALTER TABLE … RENAME TO` is catalog-only on every dialect including sqlite, so unlike `rename-index` this has no dialect gate. Three guards each block a way to destroy data rather than move it, and none of them is silent:
|
|
108
|
+
|
|
109
|
+
- **the old name must not still be declared** — an app with its OWN `notes` table keeps it, which is exactly what makes reclaiming a name into `_voltro_` safe for you. A legitimate outcome, so the plan runs and the `create-table` line says why the marker was not applied; - **the target must not already exist live**, and **two tables may not claim one old name** — both REFUSE TO PLAN, because only you can say which table holds the real rows, and the quiet alternative (an empty plan reading "schema up to date" while the old table still holds everything) loses data by inaction.
|
|
110
|
+
|
|
111
|
+
A marker whose old table is simply absent is a quiet no-op, so it survives a staged rollout.
|
|
112
|
+
|
|
113
|
+
**The half that nearly shipped broken, twice.** Index names are DERIVED (`<table>_<col>_idx`, `<table>_pkey`) and no dialect renames an index when its table is renamed. Diffed raw, a table rename planned as DROP the primary-key index plus re-add it as a plain UNIQUE — which postgres refuses outright, so the rename could never converge. Live index names are now projected through the table rename and the real ones emitted as `rename-index` ops.
|
|
114
|
+
|
|
115
|
+
The second half was subtler and postgres-shaped: `<table>_pkey` is NOT a catalog object on four of the five dialects — every introspector fabricates that entry from the table's current name. Emitting a rename for it failed outright (`ERROR 1176: Key 'notes_pkey' doesn't exist`, measured on MySQL 8.4.10), and postgres is the one dialect where the fabricated name happens to be real, so hand-verifying the statement there proved nothing about the rest. It is projected for the diff and emits no DDL at all — after the rename the next introspection fabricates the new name on its own.
|
|
116
|
+
|
|
117
|
+
Covered end-to-end against a live database now, not by hand — and on EVERY dialect, not just the one that happened to work. `runDialectParity` gained a `rename-table` scenario that applies a real plan to a real database and asserts the rows survived AND the re-plan is EMPTY; it runs on postgres, mysql, mariadb, sqlite, mssql and turso.
|
|
118
|
+
|
|
119
|
+
**Why BREAKING when your code does not change.** Two reasons. Any raw SQL you wrote against those table names by hand — a reporting query, a dashboard view, a `db.raw(...)` — now names a table that does not exist; `tsc` cannot see that, so the codemod prints the list. And `MigrationOperation` gained a `rename-table` kind, which breaks an exhaustive `switch` over it (the same reason `rename-index` was breaking).
|
|
120
|
+
|
|
121
|
+
**A `_voltro_` table needs an explicit `id({ prefix })`** — a typeid prefix cannot be derived from a name starting with `_`. All ten already had one; this only matters if you declare your own.
|
|
122
|
+
|
|
123
|
+
### Added
|
|
124
|
+
|
|
125
|
+
- **@voltro/database, @voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/plugin-versioning** — **A change event now says WHICH CALL caused it, and `_voltro_row_history` records it.**
|
|
126
|
+
|
|
127
|
+
A row diff carries no intent. The same `DELETE` on a join table is a member being removed, a team being deleted, a user being deleted, or a membership expiring — and `before`/`after` cannot tell those apart, because the difference is not in the data.
|
|
128
|
+
|
|
129
|
+
A consumer running the audit plugins beside their own hand-written `auditLogs` table put it exactly: `_voltro_row_history` can say *"`userTeams` row X changed"* and show the JSON, and cannot say *"Anna removed Bernd from the Frontend sub-team"*. Their audit UI renders the sentence, so they kept 2900 rows and 300 call sites of their own.
|
|
130
|
+
|
|
131
|
+
`ChangeEvent` and `PluginChangeEvent` gained `procedure` — the rpc tag of the call doing the writing — and `_voltro_row_history` gained a column for it. `traceId` says which CALL; this says which call it WAS.
|
|
132
|
+
|
|
133
|
+
**It cost one field rather than a hook**, which is the part worth stating. The same consumer asked for a per-table `annotate: (op, before, after, ctx) => …` that would let them write the sentence themselves. That hook as specified cannot produce their example: `subTeamMemberRemoved` is not in the diff either, so an annotator would face the identical problem one layer up. The tag was already resolved at the boundary that establishes write attribution (it is the same string the span is named after), and it was simply not travelling.
|
|
134
|
+
|
|
135
|
+
Available for a background write too — a schedule or startup write carries its own procedure with no request identity beside it, and the field is not conditional on its neighbours.
|
|
136
|
+
|
|
137
|
+
**Four copies of one shape.** `WriteAttribution` → `attributionFields()` → `ChangeEvent` → `PluginChangeEvent` → `RecordedWrite` → the versioning row's builder AND its reader. `plugin-versioning`'s own source records that this class has bitten it twice already: `traceId` / `subjectId` were added to the bridge and silently dropped, first by the row builder and then by the row reader, and nothing failed either time because a missing optional field reads as an honest absence. `procedureAttribution.test.ts` pins the chain, including that an unattributed event stays byte-identical to one from before the field existed (the key OMITTED, never `procedure: undefined`).
|
|
138
|
+
|
|
139
|
+
**Nothing to run.** The new column is additive and framework tables ride the declarative differ on every dialect, so a boot picks it up wherever it picks up your own schema changes.
|
|
140
|
+
|
|
141
|
+
Two related asks from the same report are NOT in this change, deliberately:
|
|
142
|
+
|
|
143
|
+
- **An actor SNAPSHOT (`{ id, email, name }`) frozen on the audit row.** It would close the "who was this, eight months ago" question, and it collides with the erasure endpoint we also ship: a frozen email retained for years is exactly what a right-to-be-forgotten request must reach. Denormalising PII into an append-only table is a design decision that has to include how `plugin-governance`'s `/erase` finds it again, and shipping the first half alone would hand every user a compliance trap. - **`changedFields` beside `data`.** Cheap and uncontroversial; it wants the previous version at write time, which the writer already fetches for the version number. Left out only to keep this change to one idea.
|
|
144
|
+
- **@voltro/cli** — **`voltro doctor` gained an authz scan, and the check it replaces was measured wrong in both directions.**
|
|
145
|
+
|
|
146
|
+
A consumer audited a 598-executor app by hand, closed **28** authorization holes, and then classified what `subject-write-no-guard` had said about the same code: it named **10 of the 28**, and **33 of its 68 findings** pointed at handlers that were already correct. A reviewer who spot-checks three findings, sees three correct handlers, and closes the tab has behaved rationally.
|
|
147
|
+
|
|
148
|
+
Three things were wrong with the old rule, and each is answered:
|
|
149
|
+
|
|
150
|
+
**It could not see the app's own guards.** The guard list was the framework's (`requireScope`, `assertCan`, …). That app's guards were `requireTeamAccess`, `requireRoadmapManageAccess` and friends in its own `lib/`, so every call site read as unguarded — 9 of the 33. The vocabulary is now INFERRED from the app's source (an exported `require*` / `assert*`). Once the vocabulary is known the check can be INVERTED, which is the only form that finds anything: not "this file looks wrong" but "this file references no check at all".
|
|
151
|
+
|
|
152
|
+
**It looked only at writes.** One of the 28 was a READ — an executor that took an inquiry id and returned the whole message thread with every participant's name, email, avatar and roles, to any authenticated user in the org. Reported clean, because a read writes nothing. Queries and streams are scanned now.
|
|
153
|
+
|
|
154
|
+
**`subject.id`-in-the-write was a proxy for the wrong thing**, and the reason is worth stating because it inverted the incentive: `storeMiddleware` stamps `createdBy` / `updatedBy` from the request subject for any table carrying `audit()`, so a handler on such a table never writes `subject.id` itself. The better an app used the actor mixin, the fewer of its writes the actor check would even look at. 17 of the 28 misses were that shape.
|
|
155
|
+
|
|
156
|
+
**An inline ownership check is now reported as informational, not as a hole.** `row.userId !== subject.id → new AccessDeniedError({})` is correct code; it is worth SEEING (only that one file knows the rule) and it is not a finding. That was 24 of the 33.
|
|
157
|
+
|
|
158
|
+
**The ratchet is what makes it usable on an existing app.** A first run reporting 221 unreviewed handlers is not actionable, and a check nobody can act on gets switched off. `voltro doctor --write-authz-allowlist` records today's unchecked executors into `voltro-authz-allowlist.txt`; every later run fails only on ADDITIONS. The file says DEBT and not approval, in those words, because an allowlist that reads as sign-off is worse than none. It is keyed by rpc TAG, not path, so moving a file can neither re-open a hole nor hide one — and it is consulted LAST, so an executor that gains a real guard is reported as guarded and its line simply stops mattering.
|
|
159
|
+
|
|
160
|
+
Findings are ordered by blast radius (op weight, plus the target table being `tenant()`-scoped or referenced by other tables). With dozens of findings, ordering is what decides whether the first three anyone reads are the ones that matter.
|
|
161
|
+
|
|
162
|
+
**And the scan now points at the declarative form.** `guards: [{ action, resourceType, resource }]` answers "may this subject act on THIS row", and an app whose relationships live in its own tables registers its own tuple source instead of copying data into a framework table — which covers the data-dependent membership checks apps hand-roll. The reporting app used it on **0 of 359** mutations, and it is the second app observed to build 100+ imperative checks beside an unused policy engine. That is a discoverability defect, so the message that flags an imperative check is where it gets said.
|
|
163
|
+
- **@voltro/protocol, @voltro/cli** — **`internal: true` keeps a procedure off the wire. There was no way to say that.**
|
|
164
|
+
|
|
165
|
+
`publicApi` and `exposeAsTool` opt IN to wider surfaces. Nothing opted OUT of the default one: every discovered `*.query.ts` / `*.mutation.ts` / `*.action.ts` / `*.stream.ts` was value-imported into `rpcGroup.generated.ts` and callable over the WebSocket by any authenticated browser session.
|
|
166
|
+
|
|
167
|
+
A consumer found what that costs. Their app had grown 18 procedures named `*Internal` — the convention a Convex port carried over for "only other server code calls this". All 18 were in the client group. One of them:
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
auditLogInternal.createFromAction
|
|
171
|
+
input: { actorId, actorType, actorEmail, actorName, resourceType,
|
|
172
|
+
resourceId, eventType, before, after, teamId, … }
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
No guard, every field client-supplied, zero callers. Any authenticated user could write audit rows attributed to anyone. This is the same argument as `.serverOnly()` on a column, one level up: **a naming convention is not a boundary.** If the only thing keeping a procedure off the wire is that nobody wrote a client call for it, it is on the wire.
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
export const createFromAction = defineMutation({
|
|
179
|
+
name: 'auditLog.createFromAction',
|
|
180
|
+
input: Schema.Struct({ /* … */ }),
|
|
181
|
+
output: Schema.Void,
|
|
182
|
+
internal: true, // no client-group entry, no route in dev or serve
|
|
183
|
+
})
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Server code calls it by importing its executor directly, which is what a server-to-server caller already does.
|
|
187
|
+
|
|
188
|
+
**It is honoured on all three paths, and that is the load-bearing part.** The generated client group, `voltro dev`'s rpc group and `voltro serve`'s rpc group are three INDEPENDENT assembly paths. A boundary honoured by two of them is worse than one honoured by none: the docs would say internal, the browser would agree, and production would still route the tag — invisible from outside, in the one place people stop looking once a flag exists. All three now consult one exported predicate (`isWireReachable`), and a source-reading test fails if any assembly site stops consulting it, or if a FOURTH one appears.
|
|
189
|
+
|
|
190
|
+
Available on queries, mutations, actions **and streams**. A stream without it would have been a hole in the same boundary; `tsc` caught that omission.
|
|
191
|
+
|
|
192
|
+
**Not a substitute for a guard.** An internal procedure still runs with whatever authority its caller has. This removes the wire surface, not the need to check who is asking — `voltro doctor`'s authz scan still covers it.
|
|
193
|
+
|
|
194
|
+
`internal` is compared as `!== true`, so a descriptor whose flag is absent, `undefined`, or anything other than exactly `true` stays reachable. An accidental de-routing is an outage, and outages caused by a security flag are how the flag gets reverted.
|
|
195
|
+
|
|
196
|
+
### Fixed
|
|
197
|
+
|
|
198
|
+
- **@voltro/workflow** — **The cross-dialect cluster-engine suite poisoned the database it tests against, and got less reliable the more you ran it.**
|
|
199
|
+
|
|
200
|
+
`clusterTestSuite.ts` (shipped as the test-only `@voltro/workflow/cluster-suite` subpath) left every run's state in the `cluster_*` tables and nothing removed it. Each scenario names its workflows with a per-run suffix, so two runs never collide — they ACCUMULATE. A later runner then finds messages addressed to `ClusterCron/clusterCron_<oldSuffix>` entity types that no process registers a handler for any more, retries them every 10 seconds forever, and holds a connection each time. Eventually the pool cannot be acquired and whichever scenario happens to be running dies with `SqlError: Failed to acquire connection`.
|
|
201
|
+
|
|
202
|
+
Measured on mysql, one file, back to back:
|
|
203
|
+
|
|
204
|
+
| after | `cluster_messages` rows | |---|---| | run 1 | 15 | | run 2, with the purge | 15 | | run 2, purge disabled | 30 |
|
|
205
|
+
|
|
206
|
+
The failure therefore named the victim and never the cause: the losing file passes perfectly in isolation against a fresh database, so it read as machine load, and the standard response — re-run it — made the next run worse. This is the second producer behind the "rotating victims" this repo had a maintainer note about; the first was a half-provisioned mssql.
|
|
207
|
+
|
|
208
|
+
State is purged in `beforeAll`, not `afterAll`, on purpose: a run that crashes cannot clean up after itself, and its leftovers are the likeliest to be there. Same reasoning as the boot path's `reapTestFixtures`. `cluster_migrations` is deliberately left alone — it records the cluster library's installed schema version, and clearing it would make the library re-run migrations it already applied.
|
|
209
|
+
|
|
210
|
+
The general lesson, because it is not specific to this fixture: **"it passes in isolation" is a symptom, not a diagnosis.** It is equally consistent with machine load and with shared state the suite itself poisons, and only the second one can be fixed. Ask what a suite LEAVES BEHIND, and count it, before recording a red as environmental.
|
|
211
|
+
- **@voltro/cli, @voltro/runtime** — `voltro dev` could stop restarting altogether. After a save, the supervisor printed `file changed — restarting` and then nothing: no new server, the old process still holding the port and the browser's websocket, and no reconnect ever. Only killing the tree by hand recovered it. Reported as "after a change to a backend service nothing ever reconnects again".
|
|
212
|
+
|
|
213
|
+
**Two independent missing deadlines, on the same path.**
|
|
214
|
+
|
|
215
|
+
1. The supervisor's SIGKILL escalation could never fire. It was guarded by `!proc.killed`, and node sets `killed` as soon as a signal has been successfully **sent** — so it was already `true` on the line after the SIGTERM. The 1.5s grace window was decorative, and the stop waited on the child forever in an uninterruptible release. 2. The child had nothing to escalate against. Installing a SIGTERM listener removes node's default kill, so the only thing that ends the process is the handler reaching `process.exit()` — and it got there via `Promise.all(fibers.map(Fiber.interrupt))` with no bound. One finalizer that never completes (a pool drain against a database that is gone, a wedged plugin `onDeactivate`, a `quit` on a dead socket) and the server ignores SIGTERM outright.
|
|
216
|
+
|
|
217
|
+
Both are bounded now, and a child that needs SIGKILL says so (`child ignored SIGTERM — escalated to SIGKILL`) instead of costing every restart the full grace in silence.
|
|
218
|
+
|
|
219
|
+
**Shutdown hooks now actually run.** Thirteen teardowns sat on `process.on('beforeExit')` — the CDC detach, the subscriber and reaction detach, the scheduler, the workflow runtime, the retention sweep, trace persistence — and `beforeExit` is not emitted when something calls `process.exit()`, which is how a signalled process ends. A listening server never drains its event loop naturally either, so they had never run at all. Two more were on `process.once('exit')`, which fires but drops async work; both bodies were async. They are all on the signal path now.
|
|
220
|
+
|
|
221
|
+
That includes the one users can observe: the `ctx.onShutdown(cb)` callbacks a `*.startup.ts` registers, whose contract says "on SIGTERM / SIGINT". Under `voltro dev` they had never fired.
|
|
222
|
+
|
|
223
|
+
**`voltro serve`'s production drain was being truncated.** dev and serve each installed their own SIGINT/SIGTERM listeners next to the runtime's, so two owners raced to call `process.exit` — and the runtime's, registered first during `startRpcServer`, won as soon as the launch fiber interrupted (~40ms, measured). Whatever serve's careful sequence had not reached by then did not happen: plugin deactivate, analytics flush, in-flight request drain, connection pool close. There is one owner now; `onProcessShutdown` is how a boot path contributes teardown to it.
|
|
224
|
+
- **@voltro/database, @voltro/cli** — **A framework table that changed SHAPE never reached an existing database, and what `voltro dev` did depended on your dialect.**
|
|
225
|
+
|
|
226
|
+
`_voltro_*` tables were stripped from BOTH sides of the boot diff and evolved by a separate emitter instead — `CREATE TABLE IF NOT EXISTS` + `ADD COLUMN IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS` on **postgres**, and on every other dialect just the CREATEs. Nothing there could change a column's type or nullability on any dialect.
|
|
227
|
+
|
|
228
|
+
So the framework could declare a shape for one of its own tables that a boot would never reach — and reach it on postgres while silently skipping it on MariaDB, in a framework whose every other layer is built on dialect parity. 0.21.0 shipped exactly that: `_voltro_api_keys.hashedKey` gained `.maxLength(64)`, and on MariaDB that column's unbounded UNIQUE is what keeps the table out of binlog capture — so the fix for the CDC exclusion was itself excluded, on the dialect where it mattered.
|
|
229
|
+
|
|
230
|
+
**The filter was symmetric, and that was the bug.** The reason to exclude framework tables is the DROP direction: a live `_voltro_*` / `cluster_*` table no app declares must not plan as a lossy drop. That reason says nothing about a table we DO declare. It is asymmetric now — undeclared framework table, never dropped; declared framework table, diffs like any other — so framework tables ride the same planner, the same classification and the same applier as user tables, on every dialect. The second, weaker path is gone rather than fixed: patching it would have kept two paths.
|
|
231
|
+
|
|
232
|
+
**Nothing to run.** A boot applies framework-table changes wherever it applies your own. No `voltro db apply` step, no dialect-specific instruction.
|
|
233
|
+
|
|
234
|
+
Two things worth knowing, because they are how a half-finished version of this looked correct:
|
|
235
|
+
|
|
236
|
+
- the live filter existed in TWO places — the plan, and the convergence RE-PLAN `applyPlan` runs before it records a fingerprint. Widening only the first made the re-plan see a declared set full of framework tables against a live set with none, so it proposed `create-table` for all four and `applyPlan` correctly refused. The framework's own convergence proof caught it; there is one filter now. - the fingerprint fast-path now covers framework tables too, so a release that changes only one of them invalidates it instead of being skipped. That re-fingerprints once and self-heals on the next apply.
|
|
237
|
+
|
|
238
|
+
**Why the rule said otherwise.** The codemod exemption for DB tables rested on a test whose every assertion exercises `planMigrations` — which does see framework tables — and whose header concluded that a `voltro dev` boot reconciles them. Evidence about the planner, conclusion about the boot path. Its parenthetical gave it away: "(add the column, add the table)" are exactly the two cases the old emitter could do, one of them on postgres only. Both are covered now, and `sql-postgres` / `sql-mysql` carry `frameworkTableEvolution.*.integration.test.ts` — boot a real database on both dialects, assert the column changed, assert the next boot has nothing to do, and assert an undeclared `cluster_*` table is still never dropped.
|
|
239
|
+
|
|
240
|
+
An earlier revision of this change shipped a boot WARNING (`frameworkShapeGap`) naming the work the additive emitter could not do. It is deleted. It was the right answer to the wrong problem — it described the divergence rather than removing it, and told MariaDB users to run a command postgres users did not need.
|
|
241
|
+
- **@voltro/cli, @voltro/plugin-governance, @voltro/plugin-storage, @voltro/plugin-billing, @voltro/plugin-mail, @voltro/plugin-moderation, @voltro/plugin-search** — **A plugin mounting a destructive inspect endpoint declared `inspect:read`, and nothing checked.**
|
|
242
|
+
|
|
243
|
+
`inspectEndpoints` mapped to `inspect:read` in the boot permission audit — one hook, one permission, regardless of what the plugin actually mounted. The permission is named "read" and the endpoints did not have to be. Measured across the shipped plugins after a consumer noticed it from the outside: seven mount a non-GET inspect endpoint, and **six declared `inspect:read` alone**. Among those endpoints are `plugin-governance`'s `/erase` — an irreversible GDPR right-to-be-forgotten deletion — and `/export`, a full personal-data dump, plus `plugin-storage`'s `/share` and `/revoke`.
|
|
244
|
+
|
|
245
|
+
`inspect:write` already existed as a permission, and `plugin-flags` already declared it. So the convention was right and simply unenforced — the "declaration nobody checks" shape.
|
|
246
|
+
|
|
247
|
+
The requirement is DERIVED from what the plugin mounts now: any endpoint whose method is not GET / HEAD / OPTIONS requires `inspect:write`, and the boot audit names the offending `METHOD /path` so the fix is not a guess. Adding a POST to a plugin panel forces the declaration at boot, for every future plugin too. This is the same shape `extendSchema` already had, where one contract field ships two distinct capabilities.
|
|
248
|
+
|
|
249
|
+
The six plugins are corrected. A source-level test asserts the shipped set keeps passing its own rule — the check that would have caught this originally, since it was found by a consumer rather than by us.
|
|
250
|
+
|
|
251
|
+
**Scope, stated plainly:** this governs what a PLUGIN may mount, not who may call it. Those endpoints still sit behind the same single inspect token as reading a route list. Treat that token as an admin credential. The inspect docs said "read-only introspection surface" and now say what is actually there.
|
|
252
|
+
- **@voltro/database, @voltro/voltro** — A `text().maxLength(n)` **narrowing** could not be applied. `db plan` counted it under `blocked`, and every route refused: `db apply`, `db apply --force`, `migrate`, and `VOLTRO_DESTRUCTIVE_OK` (which only relaxes `lossy`). There was no acknowledgement flag anywhere.
|
|
253
|
+
|
|
254
|
+
0.21.0's own change log said the opposite — *"narrowing is deliberately NOT blocked — blocking it would leave the remedy just as unusable as the silence did"* — and the code refused every route. So the feature that was supposed to make the MariaDB hash-long-unique remedy usable made it **visible** without making it **applicable**, which is a smaller step than it reads.
|
|
255
|
+
|
|
256
|
+
**The cause is one argument.** `mkPlanned`'s fourth parameter is the refuse-marker, and I used it to attach the count query as a hint. The field's own doc comment says it is for ops "with no resolution" — a narrowing that ships the query which resolves it is the opposite of that. The query now rides in the `reason`, where the CLI already prints it, and the operation is `needs-backfill` and appliable.
|
|
257
|
+
|
|
258
|
+
Unblocked, the failure mode is the honest one the message already describes: if a value IS longer than the new bound, the database rejects the ALTER and the migration fails loudly. Better than a gate that cannot be opened.
|
|
259
|
+
|
|
260
|
+
Reported by a consumer who had run all four of their check queries first — 2900 rows on one column, 46 on another, **zero** offending values, fixed-width trace ids and SHA-256 digests sitting exactly at their bound — and then found no way to say so. Their data was provably safe and the tool still refused.
|
|
261
|
+
|
|
262
|
+
No test pinned `blocked`, which is why this shipped: the CLASSIFICATION was right the whole time, so reading it alone showed nothing wrong. There is now an assertion on the flag itself, verified red against the 0.21.0 behaviour.
|
|
263
|
+
- **@voltro/database, @voltro/runtime, @voltro/cli** — **Two places where the framework had documented a shortcoming instead of removing it.**
|
|
264
|
+
|
|
265
|
+
### The framework bootstrap is one statement kind again
|
|
266
|
+
|
|
267
|
+
`emitFrameworkBootstrapSql` also emitted `ALTER TABLE … ADD COLUMN IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS`, and the ADD COLUMN half was **postgres-only**, because that is the dialect with the syntax. While framework tables were filtered out of the boot planner, this was their only evolution path — so a release that added a framework column reached a postgres user's database on boot and a MariaDB user's never. One `voltro dev`, two behaviours, decided by the driver.
|
|
268
|
+
|
|
269
|
+
The planner owns framework-table evolution now, so those steps were not merely redundant: they were a second, weaker path. And the index step actively hurt — it ran BEFORE the planner could add a column, so an index over a newly-added column failed the boot (pg 42703) instead of waiting one step. The ADD COLUMN step existed to paper over exactly that ordering, which is a good sign the ordering was wrong.
|
|
270
|
+
|
|
271
|
+
What remains is `CREATE TABLE IF NOT EXISTS` (plus enum types, cyclic FKs and reactive triggers, which the planner does not manage). It exists for one reason: `applyPlan` records into `_voltro_migration_plans`, so that table has to exist first. It is dialect-uniform, because nothing is left in it that only one dialect can express.
|
|
272
|
+
|
|
273
|
+
Its tests asserted the opposite — one was literally named *"non-postgres path delegates to plain emitSchemaSql (no ALTER evolution)"*, recording the divergence as intended behaviour. They now assert the same DDL SHAPE on all five dialects.
|
|
274
|
+
|
|
275
|
+
### A guard and its executor share one query
|
|
276
|
+
|
|
277
|
+
A relationship guard (`guards: [{ action, resourceType, resource }]`) is answered by the app's registered `TupleSource`, and for any real policy that means loading something — the draft whose `teamId` decides access, the membership row. Then the executor loads the same row for the actual work. Two queries for one row, on every guarded call.
|
|
278
|
+
|
|
279
|
+
A consumer proposed a new guard form (`resolve(...)` then `check(row => …)`) so the framework would hand the loaded row down. The framework already had the answer: a request-scoped, batching, caching loader that the executor uses. The tuple source just could not reach it — its signature was fixed at boot, several layers above the request.
|
|
280
|
+
|
|
281
|
+
`TupleSource` now receives `load`, the REQUEST's loader — the same one `ctx.load` gives the executor. Reading through it makes the second read free. Measured rather than asserted: one query with the shared loader, two without, with the "without" case kept as a control so the claim stays falsifiable.
|
|
282
|
+
|
|
283
|
+
`load` is `undefined` outside a request — a boot seed, a schedule tick, a plugin's startup hook. That is a real answer and stays one; a source must fall back to its own query there rather than assume a cache with no lifetime.
|
|
284
|
+
|
|
285
|
+
Wired in `makeAppContextBuilder`, the single builder both boot paths call, so dev and serve cannot drift on it. It is an async-local rather than a parameter for the same reason `writeAttribution` is one: guard evaluation runs through `checkGuardsEffect` in browser-safe `@voltro/protocol`, which must not learn a runtime loader type, and only one of its three call sites has a request context in scope. The isolation property is pinned by a test with two concurrent requests — a process-wide cache here would be a tenant-isolation bug, not a performance detail.
|
|
286
|
+
|
|
287
|
+
Not breaking: a `TupleSource` implementation that destructures the fields it already used keeps compiling.
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## [0.21.0] — 2026-07-31
|
|
292
|
+
|
|
293
|
+
### ⚠ BREAKING
|
|
294
|
+
|
|
295
|
+
- **@voltro/database, @voltro/runtime, @voltro/workflow, @voltro/voltro** — `text().maxLength(n)` on an EXISTING column now actually applies. It planned zero operations and reported "schema is up to date" while the live column stayed `longtext` — the documented remedy for MariaDB's hash long-unique was a silent no-op, which is worse than no remedy because you stop looking.
|
|
296
|
+
|
|
297
|
+
**The differ was not comparing lengths wrongly — it could not see them.** `maxLength` was absent from the schema snapshot entirely: it lived on the column definition, was read only when rendering CREATE DDL, and never reached the comparison. A consumer pinned the mechanism with a contrast: a column bounded AT CREATION was `varchar(64)` (DDL path, fine); one bounded afterwards stayed `longtext` (diff path, blind).
|
|
298
|
+
|
|
299
|
+
It is now carried on both sides — declared from the definition, live from introspection — compared as its own dimension, and rendered by the applier from the declared snapshot (a bare `text` tag would emit DDL that applies successfully and changes nothing, the silent no-op the applier's convergence check exists to catch).
|
|
300
|
+
|
|
301
|
+
**Three guards against the real risk, which is not missing a change but re-emitting one forever:**
|
|
302
|
+
|
|
303
|
+
- Only the VARCHAR family contributes a live length. MariaDB reports `character_maximum_length = 4294967295` for `longtext` and 65535 for `text`; postgres reports NULL. Reading a type's theoretical maximum would make a declared `text()` differ from its own live column on every boot. - Only `text()` columns are compared. `id()` renders as `VARCHAR(64)` on mysql/mariadb while its declaration carries no length — measured on live MariaDB while building this, and it would have emitted an ALTER for every id column, forever. - Never on sqlite (no length-enforced type), and never when the caller omitted the dialect — an unknown dialect behaves exactly as before this field existed.
|
|
304
|
+
|
|
305
|
+
Proven by round-trip suites against live MariaDB and live Postgres: the same schema re-plans to NOTHING, a length change produces exactly one operation on the right column, applying it lands the new width, and re-planning after that is clean.
|
|
306
|
+
|
|
307
|
+
Classification follows the nullability precedent: **widening is `safe`** (no data can be lost), **narrowing is `needs-backfill`** and says so, with the count query to run first. Narrowing is deliberately not blocked — blocking it would leave the remedy just as unusable as the silence did.
|
|
308
|
+
|
|
309
|
+
**Also: `_voltro_api_keys.hashedKey` is now bounded at 64**, since the value is `sha256Hex(token)` and narrowing it can never fail. The other four unbounded unique columns in framework tables are deliberately left alone, each with the reason at the column: `_voltro_kv.key` is the caller's own key, `idempotencyKey` comes from a user-supplied function, and the two workflow `executionId`s have no shape the framework guarantees. A narrowing ALTER that fails on existing data during a framework upgrade is a worse outcome than the index-size concern it would fix — and the MariaDB hash long-unique is harmless on those tables anyway, since `_voltro_*` is filtered out of the binlog reader's include list.
|
|
310
|
+
|
|
311
|
+
**Migration** — `voltro update` prints it (`0.21.0/01_maxlength-now-migrates`, `manual`, and it fires only for projects that declare a bound). Your source does not change; every `.maxLength(n)` already written keeps compiling. What changes is that the next `voltro db apply` — or a `voltro dev` / `voltro serve` boot with auto-migrate — emits ALTERs it used to skip. Run `voltro db plan` first: it prints exactly which columns would be altered without touching anything, and an empty plan means this does not affect you. Widening is `safe` and can simply run; narrowing is `needs-backfill` and the plan carries the count query to run before it.
|
|
312
|
+
|
|
313
|
+
*Why this is `BREAKING` and not `Fixed`: the API is compatible — nothing is removed, renamed or narrowed, and the same call compiles. But an upgrade now performs DDL against YOUR tables that the previous version silently skipped, and on MariaDB `longtext → varchar(n)` is a full table rebuild that locks. The DB-changes-need-no-codemod exemption is written for `_voltro_*` tables riding the differ; this reaches user tables, so the operator deserves the warning at `voltro update` time rather than in a changelog section they may never open.*
|
|
314
|
+
|
|
315
|
+
### Added
|
|
316
|
+
|
|
317
|
+
- **@voltro/cli, @voltro/voltro** — **`voltro schedule run <name>`** fires one scheduled job on demand, against `voltro dev` or `voltro serve`.
|
|
318
|
+
|
|
319
|
+
Asked for by a consumer whose nightly jobs correct business data and whose workaround was: edit the cron expression to a minute out, wait for the reload, put it back. "Run it once now and watch" is a normal thing to want.
|
|
320
|
+
|
|
321
|
+
**Most of it already existed, and that is why it took a measurement to find the gap.** `SchedulerHandle.fireNow` has been there, and so has `POST /_voltro/inspect/schedules/:name/fire`. What was missing was the way in: no CLI verb, and — the part that mattered — **`voltro serve` mounted no inspect surface at all**. Every `/_voltro/inspect/*` route existed only under `voltro dev`, which is the one place a nightly data-correcting job is not running.
|
|
322
|
+
|
|
323
|
+
So production now mounts it. Two things make that safe rather than a new attack surface, and both were checked rather than assumed:
|
|
324
|
+
|
|
325
|
+
- `handleInspectRequest` is **closed by default**: with no `VOLTRO_INSPECT_TOKEN` every request is 401 carrying the remedy, the compare is constant-time, and the token is never minted outside dev. Opening it is an operator's deliberate act. - Only the handlers production can answer TRUTHFULLY are wired. Everything else stays absent and replies "not configured" — an empty array would claim the app has no procedures. `inspectSchedules` is deliberately still absent: dev computes `nextFiringAt` and the EFFECTIVE coordination from its own boot closure, and reporting a guessed coordination mode to a post-deploy gate is worse than reporting nothing.
|
|
326
|
+
|
|
327
|
+
The manifest's `rpc` / `workflows` entries come from a builder both boot paths now share (`inspectEntries.ts`). They are the same facts on both sides, and a second hand-written copy of the descriptor→entry mapping is the shape this repo keeps paying for.
|
|
328
|
+
|
|
329
|
+
**A run id of `null` is reported as its own outcome**, not as success: the run was coordinated away — another replica holds the lock, or `onOverlap: 'skip'` found the previous run still going. Printing "ok" would claim work that never started.
|
|
330
|
+
|
|
331
|
+
**Also fixed, and it affects every command in the inspect family.** `fetchJson` collapsed a non-2xx into the body's `error` field alone, discarding `message`. The surface answers `{ error: <category>, message: <what happened> }`, so firing an unknown schedule printed "fire failed" while the server had said `scheduler.fireNow: unknown schedule "…"`, and a closed surface printed "unauthorized" while the body named the missing env var. It now prefers `message`, then `reason`, then `error` — fixed in the shared fetch rather than per command.
|
|
332
|
+
|
|
333
|
+
Verified end to end against `voltro-starter/apps/v-api-durable`: the fire returns a run id and the job's own output appears in the server log; an unknown name reports the server's reason; `--format json` round-trips.
|
|
334
|
+
|
|
335
|
+
### Fixed
|
|
336
|
+
|
|
337
|
+
- **@voltro/database, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-sqlite, @voltro/sql-mssql, @voltro/voltro** — <!-- apiSurface: compatible — reasoned, not rubber-stamped. `attributionFields()` gained an OPTIONAL parameter and `withCapturedAttribution` is new. Both are additive, and it was CHECKED rather than assumed, because this repo has already paid once for a narrowing that read as additive: a zero-arg call still compiles, the value is still assignable to the old `() => …` type, and it still passes as a callback typed with the old shape. All three probed under `--strict`. Nothing was removed. -->
|
|
338
|
+
|
|
339
|
+
Write attribution is now CARRIED down the write path instead of re-read from the ambient async-local scope, so a connection-pool handoff can no longer strand a write's `traceId` / `subjectId`.
|
|
340
|
+
|
|
341
|
+
`routeEvent` read the identity with `attributionFields()`, and it runs after `await runPromise(...)`. `AsyncLocalStorage` propagates through continuations the current context CREATES; one scheduled by ANOTHER context — exactly what a pool handoff does when an acquisition queues — resumes with that other context's store. So under contention the write landed with the identity absent. Absent is a LEGAL value there meaning "no request behind this write", so the result does not look like a defect: it looks like a schedule. In a compliance trail that asymmetry is the whole problem, and it is why this is closed structurally rather than left as unlikely.
|
|
342
|
+
|
|
343
|
+
The value is captured ONCE at each public store method, before any await (`withCapturedAttribution`), and threaded explicitly through every `execute*` and into `routeEvent` — in all four dialect stores, including the transactional view, which is the path every framework mutation takes. The ambient scope is kept as a FALLBACK: a site that has not been threaded behaves exactly as before rather than worse, which is what made the change verifiable site by site.
|
|
344
|
+
|
|
345
|
+
**On what is and is not proven.** A pool handoff cannot be reproduced deterministically from a test — the resume context is the driver's choice. Two attempts are worth recording because both produced misleading green: a load-based regression test passed in isolation and failed in the full gate twice (a coin flip that also blocked releases), and a "deterministic" replacement that left the scope before the write finished PASSED against the unthreaded store, because the store re-enters its own scope internally. It proved nothing while looking like proof, so it was deleted.
|
|
346
|
+
|
|
347
|
+
What is proven: the pure semantics (`writeAttributionCapture.test.ts` — explicit wins over the ambient scope, explicit wins over ANOTHER request's scope, "no request" stays "no request", keys omitted rather than `undefined`), and the threading itself (`attributionThreadingParity.test.ts` — every store accepts and uses the carried value, no store still calls a bare `attributionFields()`, every transactional view carries it). The guarantee is structural, and it is stated that way rather than dressed up as a reproduction.
|
|
348
|
+
|
|
349
|
+
Raised by a consumer who could NOT reproduce the loss across 2700 writes at 96-way concurrency with every core saturated, and who asked for the fix anyway on the right grounds: *"impossible beats unlikely when the failure is invisible."*
|
|
350
|
+
- **@voltro/database, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/voltro** — <!-- apiSurface: compatible — `beginLocalWrite`, `endLocalWrite` and `resolveEchoAttribution` are new exports on `@voltro/database`; nothing was removed, renamed or narrowed. Checked rather than assumed, because this repo has already paid once for a narrowing that read as additive. -->
|
|
351
|
+
|
|
352
|
+
Under CDC, a plain `store.insert(...)` could deliver its change with `traceId` and `subjectId` ABSENT — not always, and more often the busier the process.
|
|
353
|
+
|
|
354
|
+
**The registration was racing the echo, and only winning on a margin.** The write path registers the request identity so the transport echo (a postgres NOTIFY, a mysql binlog row) can be re-united with it, and `pendingAttribution.ts` stated the ordering as a guarantee: the registration happens "BEFORE the transport can possibly echo it, because a NOTIFY fires at COMMIT". That is true INSIDE a transaction, where `routeEvent` runs before COMMIT. It is false for a plain write, whose statement commits ITSELF — the trigger fires while the write path is still awaiting the driver, and the registration lands afterwards. The echo has to make a round trip through the LISTEN/binlog connection, and that round trip was the only thing keeping the order right.
|
|
355
|
+
|
|
356
|
+
**Why it stayed hidden.** An unattributed event is a LEGAL event meaning "no request behind this write", so a lost identity is indistinguishable from a background job — there is no error, no warning, and nothing that looks wrong in a trace. It surfaced as one failing assertion in the full test suite, which is the only place this machine is loaded enough to flip the order, and it passed on every isolated re-run.
|
|
357
|
+
|
|
358
|
+
**Closed by construction, not by widening the margin.** A store now brackets each non-transactional write (`beginLocalWrite` / `endLocalWrite`) and delivers echoes through `resolveEchoAttribution`. An echo arriving while a write to that table is mid-registration is HELD until that write has had its chance, then answered. Held echoes keep arrival order — a subscriber seeing an update before the insert it updates would be worse off than one missing a `traceId` — and a write that never registers releases the table after a bound rather than parking its stream.
|
|
359
|
+
|
|
360
|
+
Three narrowings, each deliberate:
|
|
361
|
+
|
|
362
|
+
- **Only under `cdc`.** Nothing is injected in `inline` mode, so there is no echo to order against. - **Only non-transactional writes.** `transactional()` registers before COMMIT already; bracketing it would hold OTHER replicas' echoes for the length of the transaction to fix a race that path does not have. Asserted, so the narrowing is on the record rather than something a later reader "fixes". - **A remote write is still never attributed.** The barrier may delay an answer; it must never invent one. Nothing is registered locally for another replica's write, and that stays true while echoes are held.
|
|
363
|
+
|
|
364
|
+
**Both CDC stores had a hand-written copy of the claim-and-emit tail**, which is the shape of the previous three attribution defects in this package. The decision now lives in one function both call, and `echoBarrierParity.test.ts` fails if either store claims for itself, leaves a public write outside the bracket, or brackets the transactional path. The ordering itself is proven against the primitive (`pendingAttribution.test.ts`) — a live suite cannot force it, because the resume point is the driver's choice, and the four barrier tests were verified to go red against the pre-fix behaviour before being trusted.
|
|
365
|
+
|
|
366
|
+
Also fixed: `mysql`'s `updateMany` / `deleteMany` route their events after their transaction commits, so they carried the same race despite not going through the per-row write path. They are bracketed too.
|
|
367
|
+
- **@voltro/cli, @voltro/voltro** — `voltro build` could fail to build the **start bundle** with `Could not resolve "@voltro/cli/startEntry"`, degrading `voltro start` to the slower per-module boot. The serve bundle carried the identical latent failure.
|
|
368
|
+
|
|
369
|
+
Both bundles generate an entry importing a narrow CLI export and build it with `absWorkingDir: <app root>` — so esbuild resolved that bare specifier from the APP's `node_modules`. Under strict pnpm the app has `@voltro/cli` there only if it DECLARES it, and an app normally depends on `voltro` / `@voltro/web` and gets the CLI transitively. The CLI now resolves its own entry from `import.meta.url` and hands esbuild an absolute alias.
|
|
370
|
+
|
|
371
|
+
Same shape as the tsx bug (`tsxLoader.ts`): a package that is OUR dependency, resolved from the user's directory, invisible under strict pnpm. Same answer — the CLI knows where it lives, so it stops asking the app.
|
|
372
|
+
|
|
373
|
+
**The published export was not the problem.** It is present in the tarball — checked against the real 0.20.1 and 0.20.2 packages, `./startEntry` → `./dist/startEntry.js`, file included. Only resolution failed, which is why re-adding the export would have changed nothing.
|
|
374
|
+
|
|
375
|
+
This monorepo hoists everything, so the bare specifier resolves here and the build passes with or without the alias. The test therefore asserts the alias directly rather than inferring it from a green build — the hoisted layout is exactly what hid the strict-pnpm failure in the first place.
|
|
376
|
+
|
|
377
|
+
**Also pinned, after a wrong turn worth recording.** A consumer reported that the framework provides no Suspense boundary, so any suspend during SSR throws. The obvious repair — a root `<Suspense>` — was implemented, measured, and REVERTED:
|
|
378
|
+
|
|
379
|
+
- With a root boundary, a page that THROWS answered **200** with `<template data-msg="Switched to client rendering">`. React downgrades an errored boundary to client rendering, which silently undid the hard failure shipped moments earlier. - Without one, a suspending page renders fine anyway: `renderToPipeableStream` treats the root as an implicit boundary, so a suspend delays the shell flush rather than failing.
|
|
380
|
+
|
|
381
|
+
So the reported problem does not exist on the streaming path, and the obvious fix for it breaks something that does. Both halves are now fixtures with assertions side by side (`ssr-suspends` must render, `ssr-throws` must 500) — adding a root boundary flips the second, and that pair is what makes it visible instead of shipping it. Docs corrected in both languages, including the two places a suspend genuinely is unsupported (`renderToString` behind static prerender, and the client render), neither of which is the framework's choice.
|
|
382
|
+
- **@voltro/cli, @voltro/voltro** — <!-- apiSurface: compatible — `CliRuntime` keeps its exported signature and its behaviour (NodeContext + logger); only `runCli`'s internal composition changed. `loggerConfig` is module-private. -->
|
|
383
|
+
|
|
384
|
+
Every one-shot `voltro` command printed each log line TWICE. Measured on `voltro agents-md`: 76 lines for 38 events.
|
|
385
|
+
|
|
386
|
+
`runCli` installed two loggers. It provided `CliRuntime` (which contains a `LoggerLayer`) to the program, then provided a second `LoggerLayer` around the `matchCauseEffect` wrapping it — so the program ran inside both. Two `LoggerLayer`s in one fiber do not compose the way the name suggests: `LoggerLayer` is `Logger.replace(Logger.defaultLogger, …)`, which removes the DEFAULT logger and adds its own. The second one finds no default left to remove, the removal is a no-op, and the add still happens. Replace composes as replace only against the default — never against another replace.
|
|
387
|
+
|
|
388
|
+
The duplicates were distinguishable only because the outer layer was built without the command's `defaultScope`, so half the output carried `scope` and half did not. Had both been configured identically the output would have been byte-identical pairs, which is a good deal harder to notice than a stray field.
|
|
389
|
+
|
|
390
|
+
**The failure branch is the half that survives a partial fix**, and it did. Moving the second layer from around `matchCauseEffect` onto the error handler repairs the success path and leaves the failure path doubling, because that branch runs while the program's scope is still open and INHERITS its logger. Verified by measuring three shapes rather than reasoning about scopes: handler-provides → 2 lines, handler-inherits → 1, provide-once-outermost → 1.
|
|
391
|
+
|
|
392
|
+
The last is what shipped. The logger is a FiberRef, not a service the program requires, so it is provided ONCE at the outermost boundary and the program gets only `NodeContext`. That covers both branches by construction, rather than by the handler happening to still be inside a scope that has not closed yet. `CliRuntime` is unchanged and still used by `runCliMain`, which provides it once and never had the problem.
|
|
393
|
+
|
|
394
|
+
Guarded by `cliRuntime.test.ts`, which asserts the COUNT (any second provision doubles it regardless of what it logs) and that every line carries the command scope. It was checked against the reverted fix in both of its shapes before being trusted. Its capture spies on stdout AND stderr, deliberately: diagnostics route to stderr by level, and a stdout-only capture reported zero lines for the failure path — reading as "nothing was logged" when the truth was "logged on the other stream", which had the test accusing the fix it was written to protect.
|
|
395
|
+
- **@voltro/cli, @voltro/voltro** — Three corrections to the `db drift` baseline shipped in 0.20.2, all reported by the consumer who verified the fix — and all of them defects in that fix rather than in older code.
|
|
396
|
+
|
|
397
|
+
**1. The first `db drift` after upgrading CRASHED.** `liveFingerprint` is a new column and does not exist until a `db apply` adds it, so naming it in the ledger read died on `SqlError: Failed to execute statement` instead of reaching the "no baseline yet" branch written for exactly that moment. The documented sequence was `0 → apply → clean`; the real one was `crash → apply → clean`, with the crash landing in the first CI run after an upgrade. Proven by dropping and re-adding the column: absent → exit 1 and a driver error, present → exit 0 and the honest message. The read is now `SELECT *`, which cannot go stale against an older ledger.
|
|
398
|
+
|
|
399
|
+
**2. A no-op `db apply` established no baseline.** The baseline is written per applied plan row, so an already-current schema produced none — and "cannot compare" then persisted indefinitely rather than for one run, for any app whose schema was current when it upgraded. A no-op apply now backfills the latest row's `liveFingerprint` instead of inserting a history entry for a migration that did not happen.
|
|
400
|
+
|
|
401
|
+
**3. "No baseline" gets its own exit code: 3.** Previously it exited 0, so a CI gate could not distinguish "compared and matched" from "did not compare" — and on a stable schema the second could persist forever. The consumer named that as their reason for NOT adding a drift gate: it would pass vacuously, which is the failure this whole thread is about. Now `0` = matched, `3` = no baseline, `4` = diverged.
|
|
402
|
+
|
|
403
|
+
**And the SQL-error reporter added in the same release did not work on this path.** It walked `.cause`, and an `Effect` `FiberFailure` has none — its cause hides behind `Symbol(effect/Runtime/FiberFailure/Cause)`, with only `stack`, `message` and `name` as own keys. So the helper returned `undefined` and not even its "no statement attached" fallback fired. The consumer reproduced that against an empty database and checked field by field; all absent.
|
|
404
|
+
|
|
405
|
+
The reason the tests missed it is worth recording: every fixture was a hand-built object WITH a `.cause` — the shape assumed, not the shape the runtime produces. The suite now builds a real `FiberFailure` through `Effect.runPromise`, and the walk unwraps the symbol and flattens the `Cause` tree (`Fail`/`Die`/`Sequential`).
|
|
406
|
+
|
|
407
|
+
Their note on the irony is fair and is the reason this is one entry rather than two: defect 1 above IS the "next DB-shaped error in your CI" that the reporter existed to make readable, and it arrived as a bare wrapper plus a driver stack. With the statement printed it would have named the missing column immediately.
|
|
408
|
+
|
|
409
|
+
**A FOURTH copy of both defects was found in `voltro dev`'s migrations inspect endpoint, and it was the worst one.** It carried the same postgres-only `::text` casts, wrapped in `orElseSucceed(() => [])` — so on every non-postgres dialect the syntax error became an EMPTY history rather than a failure: the devtools migrations panel showed nothing, and with no history row the drift verdict came out `false`. A silent, permanent "no drift" on every mysql/mariadb/mssql/sqlite app. It also compared the declared hash against a live one, exactly like the CLI did.
|
|
410
|
+
|
|
411
|
+
Both are fixed there too, and `ledgerReadPortability.test.ts` now fails if any query touching `_voltro_migration_plans` grows a `::type` cast again. Three copies were fixed in one change and the fourth was missed in the same change, which is the argument for the test rather than another paragraph in a maintainer note.
|
|
412
|
+
- **@voltro/cli, @voltro/voltro** — `voltro dev` served a client-only shell — with a **200** — whenever a `renderMode: 'ssr'` page failed to render on the server. It now answers 500 with the cause, exactly as `voltro start` does.
|
|
413
|
+
|
|
414
|
+
**Reported as "voltro dev does not SSR". It does**, and has since before 0.20.0 — the middleware, its intent stated in a comment ("mirroring what `voltro start` does in production"), is an ancestor of every 0.20.x tag. What the reporter saw was the masking: their pages suspended during the server render (a lazily-loaded i18n catalog above any Suspense boundary), all 225 degraded to Vite's SPA shell, and an empty `<div id="root">` is indistinguishable from a framework that never server-renders. Their conclusion was the only one the evidence supported.
|
|
415
|
+
|
|
416
|
+
**The 200 is the part that mattered.** The same render is a hard 500 under `voltro start`, so those pages were down in production while dev reported success — the inverse of the usual "works in dev, breaks in prod", and worse, because nothing prompts you to look. Measured before the fix: `HTTP 200`, no `x-voltro-rendered-by` header at all, and the thrown error present in the dev log but nowhere in the response.
|
|
417
|
+
|
|
418
|
+
Three sites did this (both streaming `onShellError` handlers and the outer catch); all three now fail through one helper. The pattern was already in the file — the `isDeferralNotSupported` branch refuses rather than degrades and says why in the same words ("falling through would leave the developer with an unstyled page and a log line, which is exactly the silent degradation the hard error exists to prevent"). One of the removed fallbacks sat directly under a comment stating that falling through would mask the bug.
|
|
419
|
+
|
|
420
|
+
Dev puts the cause and stack in the response body; production keeps its bare `server error`, so a stack never reaches a public response. That is a difference in what the failure says, never in whether it fails.
|
|
421
|
+
|
|
422
|
+
**Also fixed, found while reproducing it: a page added while `voltro dev` runs was never server-rendered.** The middleware matched against a route table built once at boot, so a new page missed matching entirely and returned before its module was ever loaded — Vite's SPA shell, 200, and *no log line at all*, because the middleware never ran. The page tree the middleware reads is now refreshed by the same regeneration that rewrites the route table (`dirs` too, or a layout added after boot would be invisible to the spa-shell decision).
|
|
423
|
+
|
|
424
|
+
Both are covered by `webDevSsrLayoutLoader.test.ts` against a real dev server: `/ssr-throws` must answer 500 + `x-voltro-rendered-by: ssr-dev-failed` + the cause and must never contain the empty shell, and a page written while the server runs must be server-rendered without a restart. Each assertion was verified red against its own reverted fix — separately, because the first failure aborts the test and would have left the second unproven.
|
|
425
|
+
|
|
426
|
+
One thing this does NOT change: the framework still provides a Suspense boundary only for its own deferral (`<Await>`), not a blanket one at the root. Code that suspends outside it needs a boundary you mount yourself. That is now documented next to the failure behaviour, since a hard 500 is how you will meet it.
|
|
427
|
+
|
|
428
|
+
### Internal (no consumer-facing effect)
|
|
429
|
+
|
|
430
|
+
- **@voltro/cli** — Maintainer notes only — no shipped behaviour changes.
|
|
431
|
+
|
|
432
|
+
`mssqlClusterPatch.ts`'s header said the `@effect/cluster` patch covers "two mssql-only bugs" (it is four: the `deliver_at` INT-overflow, the MERGE…OUTPUT with correlated sub-SELECTs, `FOR UPDATE`, and `USING (SELECT * FROM (VALUES …))`) and implied that a version bump needs nothing but a re-key, because 0.59.0 → 0.60.0 happened to apply unchanged. On 0.60.2 the same patch fails on 3 of its 6 files — upstream refactored `SqlMessageStorage` and moved the context the hunks match on. A bump can require REGENERATING the patch.
|
|
433
|
+
|
|
434
|
+
The regeneration recipe now lives in `packages/sql-mssql/CLAUDE.md`, together with the two measurements that produced confidently wrong answers while working this out: reading an already-patched `node_modules/.pnpm/*patch_hash=*` copy and concluding upstream had fixed it, and un-patching one of the several installed copies and concluding the patch was not load-bearing. Both look like evidence.
|
|
435
|
+
|
|
436
|
+
Also recorded there: verify by BREAKING it. `git apply --check` proves the patch lands, not that it still fixes anything. Un-patched, the mssql cluster suite fails with `Incorrect syntax near ')'`; patched, 5/5 against the live fixture.
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
42
440
|
## [0.20.2] — 2026-07-30
|
|
43
441
|
|
|
44
442
|
### Fixed
|
package/THIRD-PARTY-NOTICES.md
CHANGED
|
@@ -9,7 +9,7 @@ Generated from the resolved runtime dependency closure (6 packages).
|
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
-
## @effect/sql@0.
|
|
12
|
+
## @effect/sql@0.52.0
|
|
13
13
|
|
|
14
14
|
License: MIT
|
|
15
15
|
|
|
@@ -37,7 +37,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
|
37
37
|
SOFTWARE.
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
## jose@6.2.
|
|
40
|
+
## jose@6.2.4
|
|
41
41
|
|
|
42
42
|
License: MIT
|
|
43
43
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/plugin-atlassian",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Jira + Confluence plugin — JiraService + ConfluenceService over the Atlassian REST/Greenhopper/Agile APIs, with a pluggable per-subject credentials resolver (PAT), transient retry + Retry-After, timeouts, an SSRF-guarded PAT-free avatar proxy, and optional response caching via @voltro/cache.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|
|
@@ -42,11 +42,11 @@
|
|
|
42
42
|
"node": ">=24.0.0"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@voltro/integration-http": "0.
|
|
46
|
-
"@voltro/protocol": "0.
|
|
45
|
+
"@voltro/integration-http": "0.22.0",
|
|
46
|
+
"@voltro/protocol": "0.22.0"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"effect": "^3.
|
|
49
|
+
"effect": "^3.22.0"
|
|
50
50
|
},
|
|
51
51
|
"publishConfig": {
|
|
52
52
|
"access": "public"
|