@voltro/cli 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +670 -0
  2. package/THIRD-PARTY-NOTICES.md +2 -2
  3. package/dist/{apiBuild-B2m4XK_8.js → apiBuild-BtrTyKnF.js} +15 -15
  4. package/dist/apiBuild-DDJ0It4j.js +2 -0
  5. package/dist/bin.js +3 -3
  6. package/dist/{commands-CaIhTsC2.js → commands-D-9iYF2D.js} +1889 -1591
  7. package/dist/dbCommand-DrzXimKf.js +2 -0
  8. package/dist/{dbCommand-CC61CsAc.js → dbCommand-uuNCrFAb.js} +172 -163
  9. package/dist/{dev-DPkQVUTP.js → dev-BnWq4jeA.js} +1 -1
  10. package/dist/{dev-NCuEhNxs.js → dev-Dbm6SWtn.js} +2369 -1774
  11. package/dist/fileConventions-3bffWssN.js +30 -0
  12. package/dist/{frameworkTableAssembly-sg41s14F.js → frameworkTableAssembly-BwHU9Euq.js} +12 -8
  13. package/dist/frameworkTableAssembly-lrjZtk0G.js +2 -0
  14. package/dist/index.js +1 -1
  15. package/dist/{inspect-_ldwsAwH.js → inspect-CUCCzw2I.js} +20 -4
  16. package/dist/inspect-gt8bq-Tz.js +2 -0
  17. package/dist/{inspectMetrics-dcC7gnxu.js → inspectMetrics-BU90mvJN.js} +15 -15
  18. package/dist/{manifestBuild-Bs1Uw22_.js → manifestBuild-BnzAxp2O.js} +1 -1
  19. package/dist/manifestBuild-ifczArzr.js +2 -0
  20. package/dist/serveCommand-XBXuwJty.js +1294 -0
  21. package/dist/serveEntry.js +2 -2
  22. package/dist/{start-C_4VUDT8.js → start-BGXIf6zT.js} +2 -2
  23. package/dist/startEntry.js +2 -2
  24. package/package.json +17 -17
  25. package/templates/AGENTS.core.md +12 -5
  26. package/templates/AGENTS.md +14 -7
  27. package/templates/agent-docs/_index.md +2 -2
  28. package/templates/agent-docs/_manifest.json +3 -3
  29. package/templates/agent-docs/cli.md +15 -0
  30. package/templates/agent-docs/data.md +339 -2
  31. package/templates/agent-docs/database/migrations.md +40 -0
  32. package/templates/agent-docs/database/schema.md +127 -0
  33. package/templates/agent-docs/deployment.md +28 -0
  34. package/templates/agent-docs/plugins.md +26 -4
  35. package/templates/agent-docs/reference.md +2 -2
  36. package/templates/agent-docs/security.md +18 -0
  37. package/templates/agent-docs/templates/apibackends.md +5 -5
  38. package/templates/agent-docs/templates/overview.md +1 -1
  39. package/templates/agent-docs/whats-new.md +278 -168
  40. package/templates/agent-docs/workflows.md +160 -42
  41. package/templates/apps/api-ai/package.json +7 -7
  42. package/templates/apps/api-auth/package.json +8 -8
  43. package/templates/apps/api-backend/package.json +7 -7
  44. package/templates/apps/api-backend-deactivation/package.json +7 -7
  45. package/templates/apps/api-backend-mail/package.json +8 -8
  46. package/templates/apps/api-backend-mariadb/package.json +9 -9
  47. package/templates/apps/api-backend-storage/package.json +8 -8
  48. package/templates/apps/api-data-advanced/package.json +8 -8
  49. package/templates/apps/api-durable/README.md +2 -1
  50. package/templates/apps/api-durable/events/orders.event.ts +18 -0
  51. package/templates/apps/api-durable/mutations/orders.place.mutation.server.ts +17 -15
  52. package/templates/apps/api-durable/package.json +8 -8
  53. package/templates/apps/api-durable/triggers/order.placed.trigger.tsx +6 -4
  54. package/templates/apps/api-feature-flags/package.json +9 -9
  55. package/templates/apps/api-governance/package.json +8 -8
  56. package/templates/apps/api-kv/package.json +8 -8
  57. package/templates/apps/api-moderation/package.json +8 -8
  58. package/templates/apps/api-observability/package.json +8 -8
  59. package/templates/apps/api-ratelimit/package.json +8 -8
  60. package/templates/apps/api-rbac/package.json +8 -8
  61. package/templates/apps/api-rest/package.json +7 -7
  62. package/templates/apps/api-saas/package.json +11 -11
  63. package/templates/apps/api-search/package.json +8 -8
  64. package/templates/apps/api-versioning/package.json +8 -8
  65. package/templates/apps/api-webhooks/README.md +2 -2
  66. package/templates/apps/api-webhooks/events/orders.event.ts +35 -0
  67. package/templates/apps/api-webhooks/package.json +9 -9
  68. package/templates/apps/api-webhooks/template.json +1 -1
  69. package/templates/apps/changelog/package.json +6 -6
  70. package/templates/apps/edge-functions/package.json +2 -2
  71. package/templates/apps/frontend-admin/package.json +8 -8
  72. package/templates/apps/frontend-app/package.json +8 -8
  73. package/templates/apps/frontend-blank/package.json +7 -7
  74. package/templates/apps/frontend-contact/package.json +7 -7
  75. package/templates/apps/frontend-dashboard/package.json +7 -7
  76. package/templates/apps/frontend-docs/package.json +7 -7
  77. package/templates/apps/frontend-i18n/package.json +6 -6
  78. package/templates/apps/frontend-landing/package.json +7 -7
  79. package/templates/apps/frontend-spa/package.json +7 -7
  80. package/templates/apps/frontend-ssr/package.json +7 -7
  81. package/templates/apps/frontend-ssr-api/package.json +8 -8
  82. package/templates/apps/frontend-static-blog/package.json +6 -6
  83. package/dist/apiBuild-UEM3QBke.js +0 -2
  84. package/dist/dbCommand-Cr__4ATv.js +0 -2
  85. package/dist/fileConventions-CBNPZVOV.js +0 -29
  86. package/dist/frameworkTableAssembly-DRJrAVr1.js +0 -2
  87. package/dist/inspect-BA67TF6v.js +0 -2
  88. package/dist/manifestBuild-i-fRHg_H.js +0 -2
  89. package/dist/serveCommand-C4gzDZzc.js +0 -1175
  90. package/templates/apps/api-webhooks/events/order.completed.webhook.tsx +0 -22
@@ -1,4 +1,4 @@
1
- # What's new in 0.23.0
1
+ # What's new in 0.25.0
2
2
 
3
3
  Read this FIRST when a task touches an area you have not worked in recently.
4
4
  It is the cheapest way to notice that the framework grew the thing you were
@@ -9,312 +9,422 @@ BREAKING entries name a codemod; run `voltro update` to apply it.
9
9
 
10
10
  ### ⚠ BREAKING
11
11
 
12
- - **@voltro/plugin-versioning, @voltro/database, @voltro/cli** — **`versioningPlugin({ tables: string[] })` is gone. Row history is ON by default for every table your app declares, and the two escape hatches take table VALUES.**
12
+ - **@voltro/plugin-broadcast, @voltro/plugin-presence, @voltro/cli** — **Two Voltro apps pointed at one Redis or NATS were publishing into each other's channels. The option documented as the fix for that was never read.**
13
+
14
+ Every framework channel was a flat constant with no per-app component — `voltro:changes`, `voltro:events`, `voltro:members`, `voltro:presence` — and the providers pass channel names to the broker verbatim. So a shared broker made one app's change events wake another app's matchers, one app's presence deltas land in another app's roster (adding members that can never leave: there is no owner for membership to time out), and, since events were unified, one app's events arrive at another app's clients.
15
+
16
+ `BroadcastPluginOptions.channel` existed for this. Its own doc comment named it as the answer for several deployments sharing one broker. It was declared, it was documented, and **nothing ever forwarded it out of the options object** — proven by test before it was replaced. Setting it did nothing, silently, while looking like a solution.
17
+
18
+ It is now **one namespace for all four channels**:
13
19
 
14
20
  ```ts
15
- versioningPlugin({}) // every app table
16
- versioningPlugin({ exclude: [domainEvents] }) // opt one out — by value
17
- versioningPlugin({ include: [aiFlowsTable] }) // add a PLUGIN's table
21
+ broadcast({ provider: 'redis', namespace: 'shop-prod' })
18
22
  ```
19
23
 
20
- The old shape had two failure modes and both were silent:
24
+ A per-channel override would have been the wrong shape even working: escaping cross-talk means changing four names, three of which had no option at all, and fixing one of four is a half-fix that reads as a whole one.
21
25
 
22
- - you listed six tables, forgot the seventh, and nothing ever told you its history was missing; - nothing cross-checked the strings, so `'invoces'` recorded nothing forever while the plugin reported itself active at boot.
26
+ **The default derives from your app's name**, so two different apps separate without anyone configuring anything. That ordering is deliberate a namespace you must remember to set is one two apps forget to set, and the failure is silent in the worst direction.
23
27
 
24
- Opt-out fixes the first (forgetting is now the safe direction) and values fix the second (`tsc` catches a misspelling at the call site, exactly as it does for `reference(() => table)`).
28
+ **The one case derivation cannot see**, stated plainly rather than papered over: staging and production of the SAME app share a name, the code and every fingerprint. Nothing derivable tells them apart. If one broker serves several deployments of one app, `namespace` or `VOLTRO_BROADCAST_NAMESPACE` is not optional — it is the only thing that can work.
25
29
 
26
- **Framework- and plugin-owned tables are OUT of the default**, and that is not tidiness. There are 34 of them, and the busiest — `_voltro_cdc_log`, `_voltro_events`, `_voltro_undo_log`, `_voltro_workflow_events`, `_voltro_webhook_rate_windows` are append-only logs. A full row snapshot per write there is the history of a history, at the highest write rate in the system. `include` is the supported way to version one anyway, and it works whether or not your app declares the table — which answers "can I version a plugin's table I do not own": yes.
30
+ Resolution: `broadcast({ namespace })` `VOLTRO_BROADCAST_NAMESPACE` app name. Values are lowercased and reduced to `a-z0-9_-`. The reason, measured against nats:2 rather than assumed the first version of this note had it wrong:
27
31
 
28
- **The set resolves LAZILY, on first use.** `versioningPlugin()` is called in `app.config.ts`, before a single table has registered; resolving at construction would produce an empty set and record nothing, silently, which is the defect this change removes. Both boot paths register the app's tables during discovery and activate plugins afterwards.
32
+ | In a name | What NATS does | | --- | --- | | a `.` beside a `>` (`shop.>`) | matches `shop.other` wildcards are token-level, tokens are dot-separated | | a name that IS `>` or `*` | matches EVERY subject on the server | | whitespace | rejects the subject outright — the app receives nothing at all |
29
33
 
30
- A table named in BOTH `include` and `exclude` throws at construction rather than picking one only the author knows which was the mistake.
34
+ A wildcard inside a token is inert (`shop>:changes` does not match `other:changes`), so the dangerous inputs are narrower — and different in kind: the whitespace case is not a leak but a silent hard failure. A name reducing to nothing falls through to the next candidate rather than becoming an empty prefix. Redis is indifferent to all three; the sanitiser is the strict intersection.
31
35
 
32
- The boot log prints the **resolved** count (`versioning active · tables: N`), not the configured one: with an opt-out default, "how many did I configure" is not a number anybody has, and "how many am I recording" is the one worth seeing.
36
+ The codemod rewrites `channel` `namespace` and strips a trailing `:changes` (the framework appends the channel kind itself, so carrying the old value verbatim would produce `myapp:prod:changes:changes` a channel nobody publishes to, and silent). A non-literal value is carried verbatim and flagged for review rather than guessed at. It also tells you the old option never took effect, which is the part a rename would otherwise hide.
33
37
 
34
- **Check your storage budget once after upgrading.** If you previously versioned three tables out of forty, you now version forty. The retention sweep (`VOLTRO_ROW_HISTORY_TTL_HOURS`) still bounds age.
38
+ Namespaces are resolved ONCE per boot and threaded to all four wirings; `dev`, `serve` and the plugin bind context call the same helper, because four independent derivations of one value is four chances to produce a replica that publishes where nobody listens.
39
+ - **@voltro/runtime, @voltro/cli** — **Each declared event now travels on its own cross-instance channel (`voltro:events:<name>`), and a replica subscribes only while it has a local subscriber for that event.**
35
40
 
36
- `isFrameworkOwnedLiveTable` is now exported from `@voltro/database` one copy of that rule, since a second copy of it is how a per-dialect difference in what `voltro dev` does got shipped once already.
41
+ No user-authored code is affectedhence `codemod: none`. The channel name is internal to the transport; `defineEvent`, `ctx.events.publish` and `useEvent` are unchanged.
37
42
 
38
- ### Added
43
+ Previously every event shared one channel, so every replica received, JSON-decoded and materialised a route for every event of every peer — including the ones it served no clients for. With five replicas and one high-rate event whose subscribers all sat on one of them, four replicas did that work and threw the result away.
39
44
 
40
- - **@voltro/cli, @voltro/plugin-ai-flows, @voltro/plugin-notifications, @voltro/plugin-presence, @voltro/plugin-versioning, @voltro/plugin-webhooks** — **The direction into a plugin's table first half.** There were two doors OUT of a plugin's schema (`tables: false` on rbac, `alias` on ai-flows) and none in, so an app with grown data either ran a second source of truth beside the framework or did not use the plugin. A consumer named the cost: five plugins unused, not one of them because the plugin was worse than what they had.
45
+ **The operational consequence to plan for:** during a rolling deploy, replicas on different framework versions use different channel names, so cross-replica delivery is degraded for the length of the rollout. Local delivery on each replica is unaffected throughout, and the two sets converge when the rollout completes.
41
46
 
42
- `planAdopt` decides whether a move is safe and in what order it must runthe half that costs hours when you get it wrong, and the half that needs no database. It refuses three things rather than guessing:
47
+ Interest is tracked per EVENT (not per route) and the transport re-reads the desired state when its async `subscribe` resolves a subscriber that arrives and leaves inside that window would otherwise leave a live subscription behind, a leak that grows with reconnect churn and never reports itself. Registering the interest listener replays what is already subscribed, so a client that attached between the bus being built and the transport being wired is not left unwired.
48
+ - **@voltro/runtime, @voltro/cli** — **`ctx.events.emit('name', data)` is gone. `ctx.events.publish(descriptor, key, payload)` is the only emitter, and it drives BOTH audiences.**
43
49
 
44
- - **a NOT NULL target column nobody maps to.** The alternative is a silent zero that reads as real data forever after. - **a target table that already holds rows.** Adopt MOVES rows into a table; it does not merge into one somebody else already wrote. - **a typo on either side of the map.**
50
+ The string emitter and the declared event were two ways to say the same thing, and only one of them can be checked. `emit` matched a workflow trigger BY NAME: rename the event on one side and the trigger silently stops matching, the workflow never runs again, and nothing errors. That is the exact defect a consumer reported having with their own string channels — two spellings of one event, both subscribed, one dead since the day it was written — so shipping the typed event while keeping the untyped emitter would have shipped the fix and the defect together.
45
51
 
46
- And it states, before anything runs, the thing that is expensive to discover late: differing typeid prefixes (`afl_` → `aifl_`) mean every row gets a new id, so every reference elsewhere must be rewritten from a translation table — **including ids embedded in JSON columns**, which is where the reporter's own hand-written migration had its hardest step.
52
+ ```ts
53
+ // before
54
+ await ctx.events.emit('orders.paid', { orderId, total })
55
+ triggerWorkflow({ event: 'orders.paid', workflow: 'fulfil' })
56
+
57
+ // after
58
+ yield* ctx.events.publish(orderPaid, { orderId }, { total })
59
+ triggerWorkflow({ on: orderPaid, workflow: 'fulfil' })
60
+ ```
47
61
 
48
- A source column nobody carries across is reported but not fatal: it is deliberate often enough, and "I forgot this column" and "I decided" look identical in a map file.
62
+ **Nothing was lost with it.** `publish` still writes `_voltro_workflow_events`, still starts every matching trigger, and still records a delivery row per trigger — it does that from ONE call, on the SAME commit boundary as the client fan-out. Two emitters could disagree about whether the thing happened; one cannot. A trigger failure still cannot fail the mutation that published, for the same reason a broker outage cannot.
49
63
 
50
- The field mapping itself stays the app's units, merged fields, a status vocabulary that does not line up are domain knowledge, and a tool inventing them silently corrupts data.
64
+ The codemod is `manual`, and the reason is the actual guidance: the rewrite needs a routing `key` and nothing can derive one. The key decides WHO receives the event, so a guessed `{}` compiles and fans every event out to every listener, while a guessed field fans it out to none. Both fail silently, which is what this change is about. The printed steps say how to choose one.
51
65
 
52
- **The move itself ships with it**, behind `voltro db adopt --from --into --map [--apply]`. **Dry run by default** `--apply` is the only way anything is written, because the interesting failure is irreversible and the interesting output is the refusal. A refused plan prints no steps at all, rather than a preview of something that will not happen.
66
+ **Also: a subscriber can now PUBLISH a declared event** (`ctx.publish` in `*.subscribe.ts`, present only when the app declares any). A row changing and a thing happening are different statements, and usually only the second is what a client cares about — nobody watches `attendance` rows, they watch "attendance changed". Without the bridge, a table-derived event has to be published from every mutation that touches the table, and from the next one somebody adds: fail-open by omission, which is the shape a declaration exists to remove. Best-effort by nature it fires after the commit, so there is no transaction left to couple to. When the event must not be lost, publish it from the mutation.
67
+ - **@voltro/plugin-webhooks, @voltro/cli** — **`defineOutgoingEvent` is gone. An outbound webhook event is an AUDIENCE of a declared event.**
53
68
 
54
- The ordering is the product, not the SQL, and every step is there because skipping it loses data you find out about later:
69
+ ```ts
70
+ // before — events/order.completed.webhook.tsx
71
+ export default defineOutgoingEvent({ id: 'order.completed', payload: P, version: 1 })
72
+
73
+ // after — events/orders.event.ts
74
+ export const orderCompleted = defineEvent({
75
+ name: 'order.completed',
76
+ key: Schema.Struct({}),
77
+ payload: P,
78
+ webhook: { version: 1 },
79
+ })
80
+ ```
55
81
 
56
- 1. **snapshot** the source into `<table>__adopt_snapshot` a real table in the same database, so the restore path is a statement rather than an operational procedure at 2am. It keeps the columns the adopt deliberately left behind. 2. **copy**, with the mapping's raw expressions. 3. **verify by count** this catches the one failure that is otherwise invisible: a `WHERE` inside a raw expression silently dropping rows. 4. **drop the source, last**, and only if the counts match.
82
+ This completes the unification. One declaration, and `ctx.events.publish` reaches connected clients, workflow triggers AND subscribed HTTP targets from the same call, on the same commit boundary. Two declarations of one thing driftthe defect the event primitive exists to remove and keeping both forms would have shipped the fix beside it.
57
83
 
58
- Two things it refuses to do, both because the alternative is a silent partial state: it never drops the source on a count mismatch (both tables stay, and it says so), and it never removes the snapshot after a failed verify the snapshot exists for exactly the run that goes wrong. `--keep-source` copies and verifies without dropping at all.
84
+ **The codemod is a `transform`, and the contrast with its sibling is the useful part.** The string-emitter codemod had to be `manual` because the rewrite needs a routing key and nothing can derive one: only the author knows who should receive an event. This one needs no key. A webhook event is delivered to subscribed TARGETS, not to a key, so `key: Schema.Struct({})` is the correct answer rather than a guess and everything else maps 1:1.
59
85
 
60
- Verified against live postgres (`sql-postgres/__tests__/adoptExecute.integration.test.ts`): the rows move, a unit conversion and a two-field merge come out right, the snapshot holds the originals including the dropped column, a failed adopt leaves the source standing, and a refused plan runs nothing.
86
+ `defaultRetry` and `defaultSigning` are deliberately NOT carried across. The plugin's shapes are richer than a browser-safe descriptor can hold; dropping them silently would remove a policy the author wrote, and inventing the missing fields would install one they did not. The transform leaves them as a compile error and says so — configure them at subscribe time, where the full shape is typed. `globalRateLimit` becomes `rateLimit`: "global" only ever meant "not per-target", and beside three audiences that word would read as "across all of them".
61
87
 
62
- **Reference rewriting after an id re-mint is deliberately NOT automatic.** The ids live in the app's own columns and inside its JSON, and only the app knows where. The translation table is what we owe it; the rewrite is what it owes itself. Doing that automatically is the one place in this command where being wrong would be silent.
88
+ **Nothing downstream changed shape.** `OutgoingEventDescriptor` survives as the internal form the delivery workflow, the JSON-Schema export and the dashboard's event list all read; a declared event is PROJECTED onto it. Giving declared events a parallel path would mean each of those consumers handles two shapes, which is how two shapes drift apart.
63
89
 
64
- Also in this drop, from the same report: every table-carrying plugin exports its table handles, so `reference(() => pluginTable, { onDelete: 'cascade' })` works across the boundary with database-enforced integrity verified by a planner test against the real `_voltro_ai_flows`, including that the plugin table is created before the app table that points at it.
65
- - **@voltro/cli** — **`voltro doctor` reports where a plugin's surface meets one the app already has.** An app that did not start on a green field already has a table for half the plugins it installs, and whether it uses them is decided at that seam — which the framework knew both sides of at boot and said nothing about.
90
+ Webhook DISCOVERY now merges declared events into the same `outgoing` bucket it always produced, in both boot pathsso the six consumers of that bucket are untouched.
66
91
 
67
- A consumer measured it across eleven table-carrying plugins: eight model a concept they already had a table for, and every overlap was found when it hurt — `rbac` at the role model, `notifications` on switch-on, `ai-flows` at a blocked boot. Half an hour to several hours of diagnosis, three times.
92
+ ### Added
68
93
 
69
- Three findings, all exact:
94
+ - **@voltro/plugin-broadcast, @voltro/runtime, @voltro/cli** — **A dropped broadcast message used to leave a client stale forever. It is now detected and repaired.**
70
95
 
71
- - **a plugin table whose `.renamedFrom()` names a table you declare** — saying explicitly that the plugin's empty table is the INTENDED outcome and not a failed migration, which is the sentence that was missing; - **an exact rpc tag collision** — already fatal at codegen, named here because the codegen error does not mention that `alias` is the way out; - **a shared rpc namespace** advisory. It is what makes a plugin unusable without anyone noticing: your `notifications.list` and its `notifications.inbox` coexist while one namespace means two things.
96
+ This was the one correctness gap the change bus had that the event bus did not, and the asymmetry is what gave it away: an event computes exactly what a subscriber missed and tells it, while a ChangeEvent was fire-and-forget with no serial and no accounting.
72
97
 
73
- Deliberately exact, with no name-similarity guessing: a fuzzy matcher over 27 plugin tables produces the noise that gets a check switched off, which is how the authz scan became ignorable on that same repo. The advice names `tables: false` / `alias` only for plugins that actually accept them.
98
+ The failure is quiet and permanent. Replica B's broker connection blips and misses a change replica A published. B's clients keep their sockets — so the client-side reconnect never fires and their live queries never re-run. They show stale rows until something else happens to touch the same table, which on a quiet table can be never. Nothing errors, nothing logs, and the only symptom is a user saying the page "didn't update".
74
99
 
75
- Also confirmed while answering the same report, and pinned by test: `versioningPlugin({ tables: [...] })` already works on a plugin-owned table the app never declaresit watches by NAME and contributes only its own history table. Nothing validates those names, so a typo silently records nothing; that is the cost of the decoupling and it is now stated.
76
- - **@voltro/data-transfer** — **A bundle can be imported into a schema that has moved on.** `classifyImportDrift` compares what a bundle carries against what the target declares and classifies each difference the way `db plan` classifies schema operations, instead of the one all-or-nothing fingerprint comparison that came before.
100
+ **Detection.** Every change now carries a per-origin serial. A receiving replica tracks the highest it has seen per peer, and a jump is an EXACT count of what vanished not an estimate. A first message from an origin reports nothing however high its serial: a replica that just started missed nothing, and reading that as a gap would make every new pod refresh everything on its first remote change.
77
101
 
78
- | difference | verdict | |---|---| | a column the SCHEMA dropped | values discarded said out loud, and the loader skips it | | a NULLABLE / DEFAULTED column the schema added | filled, not refused | | a column whose TYPE changed | **refused** | | a NOT NULL column with no default the bundle cannot fill | **refused** | | a table the target does not have | **refused** — nowhere to put the rows | | a table only the target has | not drift (a `--tables` scope, or added since) |
102
+ **Recovery.** There is nothing to replay pub/sub keeps no log and that does not matter, because **a live query is idempotent**. `Dispatcher.refreshAll()` re-runs every live subscription through its own descriptor, so every guard, row filter and tenant predicate applies unchanged. A refresh is a re-query, not a push: if the snapshot has not moved the subscriber sees nothing, so one dropped message does not repaint the fleet.
79
103
 
80
- Before this, a bundle exported before a column was added could not be imported at all, even though the difference was additive and harmless. The only escape was `--force`, which this package's own doc comment describes as failing "mid-load with raw DB errors after rows may have landed" — an escape hatch that trades a clean refusal for a dirty one.
104
+ Deliberately blunt it refreshes everything rather than reasoning about which tables the lost changes touched. We do not know, and guessing narrower would reintroduce exactly the silent staleness this repairs.
81
105
 
82
- The line it draws is the one a transport primitive has to draw: a row that lands INCOMPLETE is recoverable and is reported; a row that lands WRONG is not, so a changed column type refuses. That is the same distinction the reporter praised in the planner additive is safe, the destructive one is blocked with the remedy in the message.
106
+ Detection and recovery are separate: a bus used without a dispatcher still DETECTS and logs the loss. Both boot paths wire the recovery, in two steps the bus must subscribe before anything can be missed, and the dispatcher does not exist yet.
83
107
 
84
- **It does not replay authored data migrations, and should not.** A bundle carries no migration ledger, so ordered data steps stay on the physical path (`data restore` → `db apply`), where the restored database brings its own `_voltro_migration_plans` and the diff moves forward from there which is exactly what the report concluded and demonstrated row by row.
85
- - **@voltro/web, @voltro/cli** — **`LoaderContext.search`** — the raw query string (leading `?` included, `''` when absent), filled identically on client navigation, `voltro dev` SSR and `voltro start` SSR.
108
+ Nothing to configure. It follows from having a broker.
109
+ - **@voltro/protocol, @voltro/runtime, @voltro/client, @voltro/cli, @voltro/testing, @voltro/voltro** — **`defineEvent` — the axis the framework did not have.** Voltro modelled "what IS" (a table, watched by a reactive query) extremely well and had exactly ONE server→client fan-out path: a query re-runs because a table changed. Anything that is not row state — a game starting, a door opening, a payment terminal confirming — had to invent a table, and two independent consumers built the same three bugs on top of a reactive list: a `seen` set, an `initialized` flag so page load does not replay the history into a live system, and a `limit` that silently truncates. Our own `plugin-presence` does it too.
86
110
 
87
- `pathname` is query-free by contract, and for DATA that is right — a loader keyed on `?tab=2` caches badly. It is wrong for CONTROL FLOW, which is what a loader does since 0.22.0 made it throw `RedirectError` correctly: a redirect target routinely depends on a query parameter, so **the only place a redirect belongs was the only place with no access to one**.
111
+ ```ts
112
+ // events/gameLifecycle.event.ts — browser-safe, may hold several
113
+ export const gameStarted = defineEvent({
114
+ name: 'games.started',
115
+ key: Schema.Struct({ arenaId: Schema.String }),
116
+ payload: Schema.Struct({ gameId: Schema.String, startedAt: Schema.Number }),
117
+ guards: [{ scope: 'display:read' }],
118
+ })
119
+
120
+ // any handler with a ctx — action, mutation, workflow, cron, subscriber
121
+ yield* ctx.events.publish(gameStarted, { arenaId }, { gameId, startedAt })
122
+
123
+ // the client
124
+ const { missed } = useEvent(gameStarted, arenaId ? { arenaId } : null, (payload) => {
125
+ scene.switchTo('running', payload.gameId) // payload is typed from the descriptor
126
+ }, { onMissed: ({ count }) => resync(count) })
127
+ ```
128
+
129
+ **`missed` is computed, never estimated.** Every delivery carries `(origin, n)` and the server keeps the highest serial per origin, so a loss is arithmetic — what you were owed, minus what could be replayed. A dropping buffer discards silently BY DEFINITION, and silence is the one outcome nothing can be built on: a display cannot tell "no game started" from "I missed the start signal".
130
+
131
+ **A first attach and a reconnect are different events.** "Never replay history" and "never lose a message" read as one contradiction and are two questions: a fresh subscription starts empty (opt in with `rewind`), a reconnect resumes from the last serial that subscription saw. `useEvent` does the second for you, including after a deploy or a proxy timeout.
132
+
133
+ **Publishing is server-only.** A client-originated event is an action that publishes, which deletes the entire "who may write to this channel" authorization surface. **Inside a mutation, publish fires on COMMIT and not at all on rollback** — riding the buffer the transactional view already uses for ChangeEvents, so it needs no SQL trigger. Serials are assigned AT commit, so a rollback burns no number and leaves no permanent hole.
134
+
135
+ **Cross-instance delivery is wired**, not just seamed: events ride `@voltro/plugin-broadcast` (Redis / NATS / memory) on their OWN channel — `voltro:events`, not `voltro:changes`, because sharing one would make every replica decode every message of the other kind to discover it does not want it. Additive like the change bus: local fan-out happens first and a broker outage degrades cross-replica delivery without touching the publishing pod's subscribers. A malformed message on that shared channel is dropped with a log line rather than injected — a bad serial would corrupt a route's watermark and make every later `missed` on it wrong, permanently.
136
+
137
+ **`triggerWorkflow({ on: descriptor })`** ships with it, additively. A workflow trigger reads the event's NAME off the descriptor, so a rename moves the trigger with it — where the string form (`event: 'games.started'`, still accepted) leaves the trigger matching nothing and the workflow simply never runs again, with nothing to notice. Shipping it now means a third string namespace never comes into existence even briefly; removing the string form is a separate breaking change with its own codemod, and nothing here has to be undone for it.
138
+
139
+ `apiSurface: compatible`, and the distinction is worth stating because the API report reads it as a REMOVAL: `triggerWorkflow` / `defineEventTrigger` show as changed lines rather than added ones in `@voltro/voltro`'s goldens, since their parameter went from `T` to `T | (descriptor form)`. That is a WIDENING — the direction the gate's rule is not about. Every call that compiled against the old signature still compiles, and a function accepting the wider union is still assignable wherever the narrower one was expected. The umbrella package is listed here for exactly that reason: it re-exports both symbols, so its goldens churn even though nothing it re-exports narrowed.
140
+
141
+ Also: `key` is the routing address and the tenant is derived from the subject (never caller-supplied); payloads are capped at 7,500 bytes on **every** dialect so switching broadcast transport is never a behaviour change; a duplicate event name fails the boot because an event name IS an rpc tag; `voltro doctor` reports declared events with no producer or no consumer — the class a consumer found by hand in their own inventory (four dead channels in twenty); and `testEventBus()` ships in `@voltro/testing` WITH the primitive, driving the real bus so a suite cannot pass on payloads production rejects.
142
+
143
+ Four defects were found and fixed while building it, each pinned by a test: `Queue.unsafeOffer` does not slide on a sliding queue (it keeps the oldest and rejects the new — the wrong end for an event); a delivery dropped before a client's first read was invisible until the gap detector seeded from the attach watermark; a clean stream close was not a reconnect reason, leaving a display at `status: 'live'` receiving nothing after a deploy; and `Rpc.make` with `stream: true` puts the declared error inside the stream schema, not on `errorSchema`, so the guarded-QUERY half of the `ScopeError` union rule had never been asserted.
144
+ - **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/devtools-ui** — **`defineEvent({ delivery: 'latest' })` — for streams where only the current value matters.**
145
+
146
+ The default (`'each'`) is unchanged: every delivery counts, a subscriber that falls behind keeps the newest and is told exactly how many it lost. That is the right reading for a lifecycle event, and it is what you get by not deciding.
147
+
148
+ `'latest'` says the opposite, and it is a **semantic** rather than a performance knob: a newer delivery SUPERSEDES a pending one, the server retains one value instead of a ring, a reconnect hands over the current value, and no gap is reported — because nothing was lost. For a 60Hz stream of positions, frame 1 stopped being interesting the moment frame 2 existed, and reporting it as "missed" trains a consumer to read normal operation as degradation.
88
149
 
89
150
  ```ts
90
- const mode = new URLSearchParams(ctx.search).get('mode')
91
- throw new RedirectError(`/?error=${code}${mode ? `&mode=${mode}` : ''}`)
151
+ export default defineEvent({
152
+ name: 'player.moved',
153
+ key: Schema.Struct({ arenaId: Schema.String }),
154
+ payload: Schema.Struct({ playerId: Schema.String, x: Schema.Number, y: Schema.Number }),
155
+ access: 'authenticated',
156
+ delivery: 'latest',
157
+ })
92
158
  ```
93
159
 
94
- The reported case: a player page redirects an unknown wristband code back to the entry page and must preserve `?mode=kiosk`, or a kiosk terminal drops to normal mode after every failed scan. Both workarounds are bad — moving the redirect into a component gives up the 303 (back to what 0.22.0 just fixed), and `window.location.search` exists only on the client-navigation path, so a fresh SSR request loses it.
160
+ The test for which one you want: **would a consumer be wrong to miss one?**
95
161
 
96
- **The testability half is why it is a FIELD and not advice.** Because `pathname` is a free-form string in the spec, their loader test passed `'/evo5/abc?mode=kiosk'` a shape the runtime never produces and was green for as long as production dropped the parameter on every request. In their words: *wo der Harness etwas liefern kann, das die Laufzeit nicht hat, wird ein kaputter Pfad grün.* A separate field makes that mistake impossible rather than unlikely.
162
+ **`delivery: 'latest'` combined with `webhook` is REFUSED at declaration.** The two contradict each other `latest` says a superseded delivery did not matter, while a webhook delivery is a durable side effect at a third party that cannot be superseded once sent. The combination also multiplies badly: a 60Hz event with an HTTP audience is 60 deliveries per second per subscribed target, and the webhook rate limit DEFERS the excess as pending rows rather than dropping it, so the symptom is a growing table rather than an error anyone would look at. Split them: the high-rate event for clients, a coarser one for the outside world.
97
163
 
98
- Both SSR paths derive it through one shared `splitPathAndSearch`, with a test that fails if either grows its own copy back a two-line `url.split('?')` is exactly what two independent boot paths write for themselves and then disagree about. A prerendered (SSG) page has no request, so its `search` is `''`.
99
- - **@voltro/plugin-notifications, @voltro/plugin-presence, @voltro/plugin-flags, @voltro/plugin-versioning, @voltro/plugin-webhooks, @voltro/cli** — **Every table-carrying plugin now exports its table handles, so an app can point a column at a plugin row.**
164
+ The declared semantic is read from ONE map, by the bus (for retention) and by the bridge (for queue depth), so the two cannot come to disagree about whether a drop counts as a loss. The devtools events panel badges a `latest-wins` event, because two events with identical numbers otherwise mean opposite things about a missing message.
165
+ - **@voltro/protocol, @voltro/plugin-webhooks, @voltro/cli** — **A declared event can now reach subscribed HTTP targets too one declaration, three audiences.**
100
166
 
101
167
  ```ts
102
- import { aiFlowsTable } from '@voltro/plugin-ai-flows'
103
-
104
- export const flowFavourites = table('flow_favourites', {
105
- id: id({ prefix: 'fav' }),
106
- flowId: reference(() => aiFlowsTable, { onDelete: 'cascade' }),
168
+ export const orderPaid = defineEvent({
169
+ name: 'orders.paid',
170
+ key: Schema.Struct({ orderId: Schema.String }),
171
+ payload: Schema.Struct({ total: Schema.Number }),
172
+ webhook: { description: 'An order was paid', version: 2 },
107
173
  })
174
+
175
+ yield* ctx.events.publish(orderPaid, { orderId }, { total })
176
+ // → connected clients (useEvent) + workflow triggers + subscribed HTTP targets
108
177
  ```
109
178
 
110
- A consumer measured **711** app→app references against **2** app→plugin ones and diagnosed it exactly: *"Das liegt nicht daran, dass man selten auf Plugin-Zeilen zeigen will. Es liegt daran, dass es dafür kein Muster gibt und man deshalb aufhört, es zu wollen."*
179
+ Without it, an app that both fans an event out to its screens and posts it to a partner declares the thing twice, in two shapes and the two drift. That is the defect a declaration exists to remove, one level up from the string channel it already removed.
111
180
 
112
- **The pattern existed and was unreachable.** `plugin-storage`'s `assetRef()` is, by default, a real foreign key to `_voltro_storage_refs` with `onDelete: 'setNull'` — database-enforced integrity across the plugin boundary, shipping since it was written. It was simply impossible for every plugin that kept its `table(...)` handles module-local: `notifications` declared six as private `const`, `presence` one, and `flags` / `versioning` / `webhooks` exported theirs from a module but not from the package entrypoint.
181
+ Three implementation decisions worth knowing:
113
182
 
114
- So this needed no new primitive and no new machinery it needed the `export` keyword in seven places. `pluginTableExports.test.ts` fails on the eighth: a plugin whose tables nobody can name is a plugin nobody can point at, and that is invisible, because everything still compiles while the app quietly writes a plain `text()` column plus a hand-rolled cleanup subscriber.
183
+ - **`webhook:` is namespaced**, not spread across the descriptor. These settings are meaningless to the other audiences, and a top-level `retry` would read as if it applied to client delivery which is at-most-once by design and has no retry at all. - **The block is structurally typed in `@voltro/protocol`** (plain numbers and strings), and the plugin maps it onto its own shapes. Protocol is browser-safe and must not reach a plugin; that dependency direction decides where the adapter lives, not preference. - **A declared event is PROJECTED onto the descriptor the plugin already reads** rather than given a parallel path. The delivery workflow, the JSON-Schema export and the dashboard's event list all keep reading one shape — a second path would mean each of them handles two, which is how two shapes drift.
115
184
 
116
- **Two corrections that came out of building it**, because designing on the stated model would have produced the wrong thing:
185
+ `retry` is deliberately not forwarded blind: the plugin's `RetryPolicy` is richer than the two numbers protocol carries, and inventing the missing fields would put a policy in place nobody wrote. Configure it at subscribe time, where the full shape is typed.
117
186
 
118
- - **`orphanPolicy` has no runtime semantics.** Its own doc comment says so — it is planner metadata deciding how existing orphans are cleaned up *before* the FK constraint is added. Runtime referential integrity comes from the FOREIGN KEY (`onDelete`), executed by the database. A proposal to have "the framework execute the orphan policy over the post-commit channel" described machinery that does not exist and did not need to. - **A foreign key across the plugin boundary survives the plugin renaming its table.** Referencing the table as a VALUE is what makes that true; the 0.22.0 `_voltro_` namespace move was catalog-only and the constraint travelled with it. A `text()` column holding ids would have told you nothing.
187
+ `defineOutgoingEvent` still works and is unchanged. Removing it is cleanup with its own `transform` codemod, not part of this.
188
+ - **@voltro/runtime, @voltro/cli** — **Instance membership — which replicas are alive, and when one stops being.**
119
189
 
120
- `fk: false`-style decoupling remains available declare a plain `text()` column but it should be a deliberate choice, not the default that an unreachable handle forces.
121
- - **@voltro/runtime, @voltro/cli** — **`serveApi` / `startRpcServer` take a `host`.** Absent → the wildcard, which is what a container needs and stays the default. It exists because of what a wildcard bind does to a server that its OWN process then connects to.
190
+ Cross-instance FAN-OUT was already solved: a publish goes onto a channel and whoever listens receives it, and nobody needs to know who the other instances are. That is what makes pub/sub cheap.
122
191
 
123
- **The bug it closes had been read as "flaky tests" for eight occurrences.** A test boots a server with `{ port: 0 }`, fetches it, and the fetch never returns — the test dies at its timeout on an operation that takes 20ms. It moved between files and packages every time, which is what made it look like machine contention.
192
+ **Membership is the question a channel cannot answer**, because a channel says nothing about who is on it. An instance that dies simply goes quiet, and quiet is indistinguishable from "nothing happened" a crashing process does not get to send a goodbye.
124
193
 
125
- It is not. A wildcard bind lands on `:::<port>` — IPv6. The client fetches `127.0.0.1:<port>` IPv4. Those are two independent binds of the same number, so a lingering IPv4 socket on that port takes the connection instead: the kernel completes the handshake into ITS backlog, `lsof` reports `ESTABLISHED`, and the server under test never receives a `connection` event. The request then waits against a peer that will never answer.
194
+ That gap is invisible until state is OWNED per instance. Presence is the motivating case: replica 2 holds the WebSockets of the clients connected to it, so when replica 2 dies its members must disappear from replicas 1 and 3, and nothing on the event channel will ever say so.
126
195
 
127
- **Every symptom follows from that**, including the ones that made "the machine is busy" look right: it needs earlier files in the same process (they leave the IPv4 sockets), it is intermittent (an ephemeral-port collision), and a diagnostic report taken mid-hang shows an idle event loop with an empty JavaScript stack because there is genuinely nothing to run. It reproduces at rest, roughly one run in nine, with no docker stack and a load average of 3, and it has failed on a dedicated CI runner.
196
+ `InstanceMembership` announces this process on its own broadcast channel (`voltro:members` separate from events for the same reason events are separate from changes) and reports `joined` / `left` / `restarted` to any consumer. Wired into BOTH boot paths through one helper; visible at `GET /_voltro/inspect/members`.
128
197
 
129
- Found by instrumenting `net.Server.prototype.listen` and catching a hung run: `listener#3 bound :::53011 closed after 0 connection(s)` while its client sat in `fetch`. That instrument ships behind `VOLTRO_TEST_DIAG=1` (`packages/cli/src/integrationDiagnostics.ts`) together with the harness-level fix a port-0 bind with no host goes to the loopback, so server and client share an address family and a collision becomes an ordinary `EADDRINUSE` at bind time instead of a silent hang.
198
+ **Liveness is measured on the RECEIVER's clock.** `lastHeardAt` is when *we* received a heartbeat, never a timestamp the sender put in it trusting the sender reintroduces exactly the problem `.version()` exists to avoid: an instance whose clock runs slow would look permanently overdue, one whose clock runs fast would look alive forever, and neither would report anything wrong.
130
199
 
131
- Measured after: **0 failures in 25 consecutive runs** of the suite that previously failed about one run in nine.
200
+ Three decisions that are easy to get backwards, each pinned by a test:
132
201
 
133
- Production is untouched: the wildcard is still the default, and nothing here runs outside a test process.
202
+ - **Three missed beats, not one.** A single missed beat is a GC pause or a broker hiccup, and evicting on it makes a healthy cluster flap — every flap dropping and re-adding that instance's owned state, which a presence roster shows as everyone briefly leaving and coming back. - **A returning instance with a NEW `startedAt` is a RESTART, not a heartbeat.** Whatever state a consumer held for the old process is gone with it; resuming would show a roster of clients connected to nothing. - **A stale self-echo is ignored.** Brokers replay, and a replayed message from a previous incarnation carries an older `startedAt` — without the id guard that reads as "this instance restarted", and every consumer drops the state it is holding for *itself*.
134
203
 
135
- ### Fixed
204
+ **It is a presumption, not a fact**, and the docs say so: a network-partitioned instance is alive and still serving its own clients; it just cannot be heard. Each side of a partition marks the other down and drops its state. That is the correct degradation — you show what you can actually reach — and it is why `/_voltro/inspect/members` reports what THIS replica observes rather than a merged "cluster view". Presenting one would invent a consensus nobody has; the disagreement is the diagnostic.
136
205
 
137
- - **@voltro/database, @voltro/sql-postgres** **`cdcChannel` was an option that did nothing.** Setting it produced zero change events and zero errors.
206
+ Single-instance deployments get a registry whose only member is themselves, which is the true answer and means no consumer needs a "do we have a cluster" branch — that branch is how a feature comes to work in dev and not in production.
207
+ - **@voltro/cli** — Restore drill — `voltro data restore <dir> --drill [--drill-url <url>]`. "A backup you have never restored is a hypothesis"; the drill turns it into a fact by restoring the artifact into a THROWAWAY database (from `--drill-url` / `DRILL_DB_URL`) and verifying it, WITHOUT ever touching the live DB. It refuses a drill target that resolves to the live connection (a drill that `--clean`s production is the disaster it exists to rehearse against). After the restore it introspects the throwaway DB and compares its schema fingerprint to the backup's stamp: zero tables → FAIL (empty / unreadable dump), fingerprint disagrees with the stamp → FAIL (the restore didn't reproduce what was backed up), tables + matching fingerprint → PASS. Exits non-zero on any FAIL, so a scheduled CI job turns a silently-broken backup into a red build. The verify is schema-level (introspect + fingerprint); a full app boot against the restored DB is a heavier follow-up. Pure decision logic (`resolveDrillTarget` / `assessDrillResult` / `connKey`) covered by 14 unit tests; the native round-trip is integration-tested where a matching `pg_dump` is available. `codemod: none` — a new opt-in flag; no user-authored code is affected.
208
+ - **@voltro/database, @voltro/cli** — Opt-in rolling-deploy refuse gate — `VOLTRO_ROLLING_DEPLOY=1`. The rolling-deploy safety classifier shipped as a `voltro db plan` advisory (a `⚠`, never a block), because the framework can't know the deploy strategy and a maintenance-window / scale-to-zero deploy has no overlap window. Operators who ALWAYS rolling-deploy can now opt into a hard gate: with `VOLTRO_ROLLING_DEPLOY=1` set, `voltro db apply` (both the auto-diff and the reviewed `--plan` path) REFUSES (exit 2) a plan containing a rolling-unsafe operation — a dropped/renamed column, a narrowed type, an added constraint — instead of warning, so an un-split breaking change fails the deploy rather than breaking pods at runtime. Override a specific apply with `--force`. Unset (the default) leaves the advisory behaviour untouched. The decision is a pure `assessRollingDeployGate` in `@voltro/database` (testable without a CLI, reusable by the cloud migration wall). `codemod: none` — a new opt-in env var; no user-authored code is affected.
209
+ - **@voltro/runtime** — Schedule (cron) observability metrics. The framework scheduler now emits three registry series on every firing — `voltro_schedule_runs_total{schedule,status}` (firings by name + `succeeded`/`failed`), `voltro_schedule_duration_seconds{schedule}` (histogram), and `voltro_schedule_last_success_timestamp_seconds{schedule}` (a gauge holding the UNIX time of the last SUCCESS). Emitted from the single scheduler seam, so EVERY app's crons get them with no per-handler wiring, scrapeable via `@voltro/plugin-prometheus` (`GET /metrics`), `GET /_voltro/inspect/metrics`, or the OTLP export — the same registry as the RPC/HTTP/subscription metrics. A cron fires unattended, so its failure mode is silent; the last-success gauge is the series to alert on (`time() - voltro_schedule_last_success_timestamp_seconds > interval × N`), because a failure counter alone can't catch a job that stopped firing at all. A failure moves the counter but deliberately NOT the gauge. `codemod: none` — additive metric emission; no user-authored code is affected.
210
+ - **@voltro/database, @voltro/runtime** — **`.version()` — optimistic locking, and the answer to "which write is newest".**
138
211
 
139
- The store read it and issued `LISTEN <channel>`. The DDL never received it: `emitSchemaSql` hardcoded `pg_notify('framework_changes', …)` inside the trigger function. So a store configured with its own channel listened somewhere nobody ever sent, and because a NOTIFY with no listener is not an error nothing said so. Measured before the fix:
212
+ Two clients read the same row and both write it. Until now the second silently won and the first user's change was gone with no tracethe shape of every "my edit disappeared" report. Mark the column and the store owns it:
140
213
 
214
+ ```ts
215
+ table('documents', { id: id(), title: text(), version: integer().version() })
216
+
217
+ yield* ctx.store.update('documents', id, { title, version }) // the version the client READ
218
+ // → VersionConflict { expected: 3, actual: 7 }
141
219
  ```
142
- cdcChannel=(default) → events received: 1
143
- cdcChannel=my_own_channel events received: 0
220
+
221
+ `VersionConflict` is a typed error carrying **both** numbers, because "someone else changed it" is not actionable while "you had 3, it is now 7" is. It reaches the client typed, so a UI can offer reload-and-re-apply rather than showing a crash.
222
+
223
+ **A timestamp cannot do this job**, which is why `.version()` rejects one at declaration: two writes in the same millisecond are indistinguishable and replica clocks disagree, so a comparison that looks right in a test loses rows under load. This repo has already lost rows to exactly that — an analytics sink dropped 7 of 40 events written in the same millisecond as the query bounding them. An integer the database owns is totally ordered and needs no clock.
224
+
225
+ Three decisions worth knowing: the caller's version is an **expectation, never a write** (it is stripped from the patch, so a client cannot pin its own and win every race); an update with no expectation stays last-write-wins but the version **still advances** (one that moved only for careful writers would sit still while a careless write changed the row — worse than none); and a row deleted underneath you conflicts with `actual: null`, which is how you tell "deleted" from "changed".
226
+
227
+ Enforced in the store wrapper every dialect passes through, NOT in the four hand-written `DataStore` implementations. Twice now a correct fix landed in one of those and the other three kept the bug — a per-dialect copy of a subtle decision will drift, so the decision stopped being per-dialect.
228
+
229
+ **`expires()` — a row with an end date.**
230
+
231
+ ```ts
232
+ table('inviteLinks', { id: id(), email: text() }).with(expires())
144
233
  ```
145
234
 
146
- It could not have worked even with the channel threaded through, because the trigger function had ONE database-global name. `CREATE OR REPLACE FUNCTION framework_notify_change()` is a single `pg_proc` row, so two schemas applied with different channels overwrote each other and the last one won everything applied earlier then emitted on somebody else's channel, silently. The function and the per-table trigger are both named after the channel now, so channels coexist. The default keeps its old names, so nothing existing is renamed.
235
+ After `expiresAt` passes the row is not returned by reads. Null means never, so adding the mixin to an existing table does not make its rows vanish; `.includeExpired()` opts out for a deliberate admin read.
147
236
 
148
- `applySchema` / `emitSchemaSql` / `emitFrameworkBootstrapSql` take the channel as an optional third argument defaulting to `DEFAULT_CDC_CHANNEL` (now exported, so the store and the DDL cannot drift apart again). Pass the SAME value to the store and to `applySchema`: they are two halves of one contract, and giving only one still yields silence.
237
+ **Read the split before relying on it.** Visibility and storage are two guarantees and only one holds everywhere: reads filter on **every dialect, immediately**, while the physical delete is a **postgres-only** retention sweep. An expired row is therefore invisible everywhere and still present in the database on four of five dialects. That is deliberate making visibility depend on the sweep would mean a row that vanished on postgres and kept serving on MariaDB, which is the per-dialect divergence class this repo has three scars from — but it means an expired row is not unreachable. If the value must actually be gone, delete it.
238
+ - **@voltro/runtime, @voltro/workflow, @voltro/cli** — Workflow (durable-execution) observability metrics. The workflow run-recording seam now emits three registry series on every terminal outcome — `voltro_workflow_runs_total{workflow,status}` (`succeeded`/`failed`), `voltro_workflow_duration_seconds{workflow}` (histogram), and `voltro_workflow_last_success_timestamp_seconds{workflow}` (last-success gauge). Because the framework applies no retry of its own, a `failed` run is TERMINAL — it is the dead-letter state — so the failed counter IS the dead-letter rate, and the last-success gauge going stale is the "this workflow stopped completing" alert (`time() - voltro_workflow_last_success_timestamp_seconds > N`), mirroring the schedule metrics. A failure moves the counter but not the gauge. Same registry as the RPC/HTTP/subscription/schedule metrics → scrapeable via `@voltro/plugin-prometheus`, `/_voltro/inspect/metrics`, or OTLP. `@voltro/workflow` stays free of a `@voltro/runtime` dependency: the recorder is injected as an optional `recordRun` hook on the recording options (mirroring `emit`/`wakeups`), supplied by the CLI in BOTH boot paths. `codemod: none` — additive metric emission + a new optional hook; no user-authored code is affected.
149
239
 
150
- **Why it surfaced now.** `cdcAttribution.integration.test.ts` failed twice in CI with `delivered 0×` and never once locally. Forty test files write to that one postgres in a gate run, and on the shared default channel every NOTIFY they emit lands in this suite's consumer — its assertions depended on traffic it does not control. It now uses a per-run channel and is hermetic by construction rather than by luck. Verified: 4/4 in the suite, 101/101 in `sql-postgres`, 1335/1335 in `database`.
240
+ ### Changed
151
241
 
152
- Stated plainly because the earlier attempt at this failure was not: raising that suite's delivery wait from 10s to 20s was tried first and changed nothing, which is what a patience bound does when the problem is not patience.
153
- - **@voltro/cli, @voltro/data-transfer** — **`voltro db apply` never ran file-based migrations, and the deployment topology we recommend has no other path that does.** A data step authored in `migrations/` would never execute in staging or production — silently, because the planner still converged the schema, so the Job went green and the deploy succeeded.
242
+ - **@voltro/plugin-presence, @voltro/protocol, @voltro/cli** — **Presence no longer touches the database.** A heartbeat used to rewrite one row per client every 15 seconds, swept by a coordinated background job a lot of write amplification for a datum that is meaningless 30 seconds later, and it made the most ephemeral thing in the framework the one backed by the most durable store.
154
243
 
155
- A consumer mapped it exactly while working out how a months-old dump lands on today's schema:
244
+ It is now an owner-partitioned map in memory, announced between replicas over the broadcast channel. **`usePresence` is unchanged** — same signature, same live roster, no client code moves.
156
245
 
157
- | command | ran `migrations/*.ts`? | |---|---| | `voltro dev` (boot) | yes, before the diff | | `voltro db files` | yes | | `voltro db apply` / `--plan` | **no** | | `voltro serve` | no (fingerprint check only) |
246
+ **Why this needs no CRDT.** Phoenix's tracker uses ORSWOT because it lets any node track any key, so two nodes can genuinely write one key concurrently. We have an invariant they do not: every entry is owned by exactly one instance — the one holding that client's WebSocket so concurrent writes to one key from different owners are impossible by construction. The merge collapses to partition by owner, union across owners, and a departing owner takes its whole partition.
158
247
 
159
- Their pipeline is the documented onea pre-upgrade Job running `db plan --json` `db apply --plan`, pods on `voltro serve`. Nothing in it ran a file migration. And file migrations are the escape hatch for precisely what a state diff cannot infer (table splits, cross-table data moves, USING-expression type changes), which makes them exactly the steps whose absence a schema diff cannot detect: the shape is right either way.
248
+ The part Phoenix gets free from BEAM monitors knowing an owner is gone is what `InstanceMembership` had to supply, and it is the wire nothing else can provide: a crashing process does not send goodbyes for the thousand clients it was holding, and on the channel it is simply quiet.
160
249
 
161
- **Two different answers, because the two paths are not the same problem.**
250
+ **The staleness filter is gone, and its absence is the change.** The table version had to compare every row against a timeout because a row outlived the client that wrote it. An entry now leaves when its client does and a whole partition goes when membership says its instance did, so an entry that exists is one an instance is currently vouching for. A timeout could only add a way to be wrong. The coordinated sweep is gone with the rows it swept.
162
251
 
163
- - **`db apply`** diffs live, so it now runs pending file migrations FIRST and then diffs the same order boot uses, with nothing to invalidate. If one fails, the diff does not run: a half-migrated database with the schema already reshaped underneath it is harder to reason about than one that stopped where it broke. - **`db apply --plan`** applies a plan computed and REVIEWED against an earlier state, so it **refuses** when any are pending, before touching anything. Running them first would reshape the schema and trip the fingerprint guard immediately after a half-applied deploy plus a drift message the operator did not cause. Running them after would apply a plan reviewed against a state that no longer exists. The refusal names the three commands that recover it, because a message that stops a deploy without restarting it is half a message.
252
+ **`_voltro_presence` remains DECLARED and is never written.** The name is the reactivity key: `presence.list` declares `source: '_voltro_presence'` and the framework routes change events by table name, so the plugin injects a synthetic change whenever the tracker moves and every subscribed client is pushed a fresh roster through the path it already used. Removing the declaration would make the `source` resolve to nothingwhich the boot audit reports correctly, and which would silently stop every roster from updating. One empty table is the accepted cost of not introducing a second push mechanism.
164
253
 
165
- ### Also from the same report
254
+ `PluginBindContext` gains `instanceId`, `membership` and `broadcast`, so any plugin holding per-replica state can say who owns an entry and learn when that owner is gone. Both boot paths supply all three, asserted by the parity guard — two out of three is silently wrong rather than broken.
166
255
 
167
- **`data backup` says what it did not do.** It runs the native dump and nothing else, while this module's own header claimed it reused "the shared content-addressed asset pipeline for blobs" — true of the logical `data export --assets`, never of a native backup. The consumer had retired the system this data came from, which made that artifact their entire rollback story, and they found out by listing the output directory. The command now prints `assets: 'NOT included use \`voltro data export --assets\`'`, and the header and CLI summary no longer claim otherwise.
256
+ A defect found while building: the tracker's route key (`tenant + '::' + channel`) is ambiguous once a channel contains the separator, and a round-trip masks it because both readings rebuild the same key. It surfaces only where something reads the PARTS a client applying a deltaso the parts are stored beside the members and the encoding is now write-only.
257
+ - **@voltro/voltro** — **The umbrella package re-exports the event surface, and two trigger signatures widened.**
168
258
 
169
- **`data backup` prefers `mariadb-dump` on MariaDB.** The `mysql | mariadb` branch spawned a fixed `mysqldump` and took whichever was on PATH. Oracle's MySQL 8 client queries `information_schema.COLUMN_STATISTICS`, which MariaDB does not have, so the dump died after the first table — leaving a partial `db.sql` that looks like a file. MariaDB has shipped `mariadb-dump` / `mariadb` since 10.5 for exactly this split, and on a MariaDB install `mysqldump` is a symlink to it anyway, so preferring the real name costs nothing and removes the guess. NOT fixed with `--column-statistics=0`: that flag does not exist on `mariadb-dump`, so it would break the correct client to accommodate the wrong one.
259
+ `@voltro/voltro` is a one-install re-export of runtime / database / protocol / workflow, so everything this release added to those reaches consumers through it too. Almost all of that is a pure addition `defineEvent`, `EventBus`, `bindEvent`, the delivery semantics, the presence sweep.
170
260
 
171
- **`NativeToolError` shows the child's stderr.** It was being CAPTURED and then never rendered `Data.TaggedError` with no `message` prints the Effect default, so the failure above surfaced as `NativeToolError: An error has occurred` and diagnosing it meant reconstructing the argv by hand out of our source.
261
+ Two lines are not additions, and they are the reason this entry exists: `defineEventTrigger` and `triggerWorkflow` now accept **either** the original spec **or** the descriptor form (`{ on: gameStarted }`). Their parameter type is a union where it used to be a single shape.
172
262
 
173
- **`db plan` / `db apply` name the rows a default will fill.** "47,000 existing rows in `todos` will get the default for `slug`" is a sentence a reviewer acts on; a plan line that reads the same whether the table is empty or not is one they scroll past. The PLANNER cannot say this it is pure by design and does no row counts, which is the property that lets a plan be computed in CI, reviewed and saved — so the count is taken at the command layer, which holds both the classification and the connection. Asked for as the one thing a state diff structurally cannot catch: it gets the shape right and is silently wrong about values.
174
- - **@voltro/cli** — **`voltro doctor`'s authz scan could not see an app's guards, and said so without anyone being able to act on it.** An app exporting 17 guards was told `guard vocabulary: framework names only — no exported require*/assert* found in this app`, and the scan reported **447** findings of which **5** were real.
263
+ `apiSurface: compatible` because widening a PARAMETER cannot break a caller: every call that compiled against the old shape still matches one arm of the union. The check flags it as non-additive because the golden line changed rather than appeared, which is the right default a narrowed parameter looks identical in a diff and would break every call site.
175
264
 
176
- The inference was handed the DISCOVERY file set — dev.ts's `walk()`, which returns only convention-named files (`*.query.ts`, `*.mutation.ts`, `schema.ts`, …). Guards do not live in those. They live in `lib/access.ts`, which that walk never yields, so the vocabulary read every file EXCEPT the ones that could have taught it anything. It reads the whole source tree now.
265
+ No codemod: nothing a user wrote stops compiling.
177
266
 
178
- The report came with a measurement rather than an argument, which is why the cause was findable in one hop: they moved two throwaway exports into a file the discovery set does cover, re-ran, and took it back.
267
+ ### Fixed
179
268
 
180
- | | before | after two names | |---|---|---| | no access check | 447 | 246 | | vocabulary | 91 | 294 |
269
+ - **@voltro/runtime** **A change watched by N identical live subscribers cost N reads and N diffs. It now costs one of each.** Fifty screens open on the same list re-ran the same query fifty times per change and recomputed the same delta fifty times — in memory that is wasted CPU; against SQL it is fifty round trips.
181
270
 
182
- Two names out of seventeen removed 201 false findings. And the 91 originally recognised were **coincidence**: one of their guards is called `requireScope`, which collides with a framework name, so it was in the set without the inference ever having run. "Partially working" was zero inference plus one collision.
271
+ `diffRows` costs the WALK, not the delta: ~31µs at 50 rows, ~289µs at 500, ~3.1ms at 5000, and one changed row costs what zero does. So on a large list the diff share is worth as much as the read share.
183
272
 
184
- **Why no test caught it.** Every unit test of `inferGuardVocabulary` passed throughout, because the function was never wrong the caller handed it the wrong files. The vocabulary is computed by an exported `root`-taking function now, tested against real trees, because the defect lives in *which files reach the function* and no test that hands it strings can see that.
273
+ Both are keyed by the resolved read descriptor; the diff share additionally keys on the previous rows' OBJECT IDENTITY. That second half is a safety property rather than an optimisation: a patch computed against another subscriber's base silently corrupts its rows, and it is the one failure on this path that neither a test nor a log would catch. Reference identity cannot be wrong about it two subscribers share only when they hold the literally same array, which is exactly when the read share already served them together. A late joiner holds a different object and gets its own diff.
185
274
 
186
- They declined to write the allowlist ratchet, and were right to: *"442 false lines in a file that says DEBT lead the next reader further astray than no file at all."* The ratchet is worth using now that the vocabulary is.
187
- - **@voltro/database** — **`voltro dev` sent plpgsql to MariaDB and could not boot.** With auto-migrate on, an app whose plugins declare a reactive `_voltro_*` table (plugin-versioning, among others) failed at startup with `Unknown data type: 'trigger'` the framework bootstrap emitting `CREATE OR REPLACE FUNCTION … RETURNS trigger AS $$` to a driver that has no such thing. Reported against 0.22.1 and measured on the SHIPPED build rather than inferred from source, on all five dialects.
275
+ Recorded because the route was not straight, and the wrong turns are the instructive part. The read memo was removed mid-release as dead code on a measurement that was broken: `handleChange` is dispatched with `void`, so the counter was sampled before the reads had landed, and the "1.00 reads" that condemned it was an artefact of the sampling. The diff share was separately shipped once with a test that could not prove it — counting deliveries, which happen either way, stays green with the share disabled — and was removed for that reason before being rebuilt. The proof needs no mocks: a shared diff is the SAME OBJECT in every delivery, so counting distinct patch identities is exact, and disabling reuse turns one shared patch into fifty.
276
+ - **@voltro/cli** — **The domain-event audit tables are bounded now.** `ctx.events.emit(...)` writes one row to `_voltro_workflow_events` plus **one per matching trigger** to `_voltro_workflow_event_deliveries`, and nothing in the framework ever deleted from either. Both are registered with the boot retention GC on a 30-day default, env-tunable via `VOLTRO_WORKFLOW_EVENTS_TTL_HOURS` and `VOLTRO_WORKFLOW_EVENT_DELIVERIES_TTL_HOURS`.
188
277
 
189
- Two emitters write schema DDL `emitSchemaSql` for user tables and `emitFrameworkBootstrapSql` for `_voltro_*` and each carried a hand-written copy of the reactive-trigger block. Only one had the postgres gate. The trigger function is plpgsql and `pg_notify` has no equivalent elsewhere (the other dialects get cross-instance capture from a binlog/CDC reader), so the gate is a gate and not a missing implementation.
278
+ Same family as `_voltro_schedule_claims`, whose sweep landed one release ago after a consumer measured 35,128 rows in 14 days. The comment there already named the pattern *"the one table of this family with no sweep"* and two more members of the family were sitting next to it. The delivery log is the faster half: three triggers on one event write four rows per emit.
190
279
 
191
- It is one function now, and the tests assert the OUTCOME rather than the presence of a gate: the two emitters must agree, per dialect, about whether a reactive table produces plpgsql.
280
+ Found while validating a consumer's request for a client-facing event primitive. Their report's core complaint is that they had modelled events as durable rows and the table grew without bound; the primitive we would have pointed them at does exactly that, in framework-owned tables, with no bound at all.
192
281
 
193
- **Why the existing dialect tests did not catch it.** They already passed a reactive table through the emitter `table()` sets `isReactive: true`, so every case in that file did. Their "every dialect gets the same shape of DDL" test compared `CREATE TABLE` / `ADD COLUMN` / `CREATE INDEX` and simply did not list the trigger block, so the one statement kind that legitimately differs per dialect was the one kind nothing looked at. It is asserted explicitly now, per dialect, including the exception.
282
+ **Read the delivery TTL as the deduplication window, not as housekeeping.** The idempotency check looks for an existing delivery row with the same `idempotencyKey`, so once a row is swept its key is no longer deduplicated. With the default key (`<eventId>:<triggerId>`, and `eventId` is fresh per emit) a duplicate cannot occur and the sweep costs nothing; it matters only for an app supplying its own key that can re-emit the same stable value more than 30 days apart. That app raises the env var, which is what it is for.
194
283
 
195
- The block is byte-identical in 0.21.0, so this is not a regressionit was reachable only with auto-migrate enabled, which is why it surfaced now.
196
- - **@voltro/cli, @voltro/protocol** — **`internal: true` took the rpc server down instead of taking a procedure off the wire.** The flag shipped in 0.22.0. Marking five procedures with it produced
284
+ Deliberately **not** status-filtered, unlike `_voltro_outbox`: there a `dead` row is an incident an operator can requeue, while a 30-day-old `starting` delivery has no requeue path and no reader filtering would preserve evidence nobody can act on and leave the table unbounded for exactly the rows a crash produces.
285
+ - **@voltro/cli** — **A declared event never reached the generated rpcGroup, so `useEvent` could not work in a real app.**
197
286
 
198
- TypeError: Cannot read properties of undefined (reading 'key')
287
+ `codegen.ts` keeps its own `walk` with its own list of file patterns, and `*.event.ts` was not on it. The machinery below it was complete — `loadExports` has an event branch whose comment says an event descriptor MUST reach the client group, and the emitter has an `eventToRpc` case — but nothing ever handed either of them an event file. A project with two declared events generated a rpcGroup containing neither, the browser's `RpcClient` had no procedure to subscribe with, and the entire client half of the primitive was unreachable.
199
288
 
200
- and no server on `voltro dev` and, identically, on `voltro serve`. The consumer isolated it by toggling one at a time (an action alone, mutations alone), confirmed the codegen half was correct (603 598 procedures, zero dangling references), and left the flag commented out.
289
+ **Nothing reported it, and that is the part worth knowing.** The server builds its own event rpcs in `makeEventWiring` and merges them at runtime, so `voltro dev` logs `events registered count:2` and looks completely healthy from the side anyone would check. It was found by booting a fixture and grepping the generated file, not by any test.
201
290
 
202
- Each boot path builds TWO things from the discovered procedure lists the rpc GROUP and the HANDLER MAP, several hundred lines apart. Only the group consulted the filter. `RpcGroup.toHandlersContext` then looks a bound handler's tag up in the group, gets `undefined`, and reads `.key` off it.
291
+ This is the third copy of one defect. `fileConventions.ts`, `dev.ts`'s walk and `codegen.ts`'s walk each keep a separate pattern list, and a convention added to one is silently absent from the others the same shape as the earlier gap where events were discovered by neither boot path. `walkConventionCoverage.test.ts` now asserts the two walks agree on what a client-facing descriptor is, against a real directory tree.
203
292
 
204
- `serveApi.ts` already carried a comment describing that exact crash in the opposite direction a handler bound with no group entry, for the undo and connection built-ins and it did not generalise to the new filter. Both paths now filter ONCE and read the filtered bindings, so the group and the handler map cannot be built from different sets.
293
+ Also fixed alongside it: **one descriptor exported under two names generated two of everything.** `export { fireArena }` plus `export default fireArena` is the same object under two keys, and `loadExports` pushed an entry per export name — producing `export const arenaFireRpc =` twice, a redeclaration. The failure was split in the worst way: `voltro dev` booted fine (the generated file is transpiled, not typechecked, and the runtime map overwrote the duplicate key) while the app's own `typecheck` and `voltro build` failed on generated code the user never wrote. Deduped by descriptor IDENTITY, not by name two DIFFERENT descriptors sharing a name is a real conflict and must still be reported rather than silently collapsed into one endpoint.
294
+ - **@voltro/runtime** — **A freshly-started replica no longer tells every client it missed thousands of messages.**
205
295
 
206
- Three further holes came out with it:
296
+ Measured: a pod joining a route where a peer was at serial 5000 reported `missed: 5000` on its first delivery. Nobody had missed anything — that replica simply had not been listening, and a client attaching to it was never owed a peer's history.
207
297
 
208
- - **A `internal: true` STREAM was still served in production.** `serveApi`'s group filtered queries, mutations and actions and not streams, so dev crashed at boot while serve quietly kept the stream on the wire — two paths, two wrong behaviours, and the silent one in production. - **The dev inspect invoker** routed internal procedures. It is filtered too: an internal procedure is the one MOST likely to have no guard ("only server code calls this" is the reason people write them), so an admin-token surface is a narrower door, not a closed one. - **`internal: true` combined with `publicApi` or `exposeAsTool` now THROWS at declaration.** Those projections add a REST route / an agent tool and never consulted the flag, so a procedure carrying both was off the WebSocket and still served over HTTP — the same hole, one surface across. Neither silent resolution is acceptable (dropping the route breaks a live endpoint invisibly; keeping it defeats the flag), so the author decides while both fields are still in front of them.
298
+ The cause was one number the subscriber could not see. From a subscriber's seat, two opposite situations look identical: an origin absent from the attach watermark plus a first delivery carrying a high serial. It can mean the serials in between reached this instance and were lost on the way out (a real loss, which the gap detector exists to report), or that this instance never had them at all.
209
299
 
210
- **Why the parity guard was green.** It reads each assembly site's source and asserts it mentions `isWireReachable`. Every site did; the handler map is not an assembly site by that definition and never calls an `xToRpc` lifter, so the offender scan was structurally blind to it. There is a shape-based check for the binding loops now, and because the defect satisfied every source-level rule stated a test that BOOTS a server with an internal procedure present. That is the one that fails.
211
- - **@voltro/database** — **A plugin table whose `.renamedFrom()` names a table the APP owns made every boot after the first one impossible.** Reported against `@voltro/plugin-ai-flows@0.22.1`; the mechanic applies to any plugin table carrying `.renamedFrom(<a name the app declares>)`.
300
+ A delivery now carries `prior` the route's watermark for that origin immediately before the envelope was accepted. `prior > 0` proves the earlier serials reached the bus, so a jump is a genuine local drop and is still reported exactly as before; `prior === 0` proves they did not, so there is nothing to report and the first delivery establishes the baseline. A real drop occurring right afterwards is still caught.
212
301
 
213
- Boot one decided correctly and said so:
302
+ This also makes it safe for a replica to hold a cross-instance subscription only while it needs one see the channel-partitioning entry, where "this instance was not listening" stops being a rare startup case and becomes the normal one.
303
+ - **@voltro/runtime, @voltro/cli** — **An event's `guards:` were never checked. Any client that could open the socket could subscribe to any declared event.**
214
304
 
215
- ```txt
216
- ✓ CREATE TABLE _voltro_ai_flows (27 cols)
217
- # .renamedFrom('ai_flows') NOT applied — 'ai_flows' is still declared by this
218
- # schema … that is the intended outcome when an app owns a table of the same name.
219
- ```
305
+ `defineEvent` accepted them. Its own doc comment called them *"WHO MAY LISTEN — the same vocabulary as a query's guards"*, with a worked example. `eventToRpc` declared `ScopeError` in the wire contract whenever they were present. `manifestBuild` serialised them, `doctorCommand` and `advisoryGuardAudit` reported on them, `voltro check` counted their scopes, and the devtools events panel showed a guard COUNT per event.
220
306
 
221
- Boot two, over exactly that state, refused:
307
+ Nothing enforced them. `bindEvent` read the resolved subject for the TENANT and for nothing else, so a declaration that read as an access-control rule was decoration.
222
308
 
223
- ```txt
224
- auto-migrate: REFUSED — 2 blocked operation(s)
225
- - rename-table : both 'ai_flows' and '_voltro_ai_flows' exist in the database
226
- ```
309
+ That is the worst shape this class of hole can take: everything *around* the enforcement existed, so it looked enforced from every angle an author or an operator would inspect it from — the manifest, the dashboard, the doctor, and the type of the error the rpc could return. The one thing missing was the check.
227
310
 
228
- **The guards were evaluated in the wrong order.** Guard 1 ("the old name must not still be declared") answers the question completely: if the app declares the old name, the marker is INAPPLICABLE and there is no rename to have a conflict about. Guard 2 ("the target must not already exist live") asks a follow-up — *which of these two holds the real rows?* that only makes sense once a rename is actually on the table. Guard 2 ran first.
311
+ Guards now run BEFORE the subscribe, from the same descriptor every one of those readers uses, with the routing key as the guard input so a resource-scoped guard (`{ scope: 'arena:read', from: 'arenaId' }`) can see which arena was asked for. `bindEvent`'s error channel is `ScopeError` rather than `never`, which is what `eventToRpc` had been promising all along.
229
312
 
230
- So the planner blocked the boot over the exact state it had itself produced one pass earlier and documented as intended, and the state was not stabilisable: dropping the empty `_voltro_*` table just let boot one recreate it. Neither remedy in the message worked either the rows belong to the app's own schema, and letting the rename run would take them.
313
+ The ORDER is pinned too, not just the check: failing after `bus.subscribe` would leave a refused client holding a live subscription, and the first version of that test read the subscriber count after the stream had already ended where the scope's finaliser has unsubscribed and the count is 0 either way. It measures while the subscription would be live now, and asserts the admitted case is 1, or the denial assertion proves nothing.
231
314
 
232
- The reporter's case makes it worse than a name collision: the plugin is a port of *their* engine, so it carries the names of the tables it grew out of. They had to unregister the plugin losing its inspect endpoints — to boot at all. The changelog's "**Nothing is required of you**" was false for precisely the case guard 1 exists to protect.
315
+ Tenant isolation was never affected: it comes from the subject on both sides and is not something a caller can ask for.
316
+ - **@voltro/runtime** — **A `latest` event re-sent its current value to a client that already had it.**
233
317
 
234
- Guard 1 runs first now. The test that pins it models TWO passes, because one pass is what the original test did and one pass is green either way.
235
- - **@voltro/cli** — **`voltro update` bumped `@voltro/*` and left what `@voltro/*` requires behind.** The `@effect/*` packages are peer dependencies, so a user app declares them directly. When a release moved its peer range, `update` rewrote every `@voltro/*` spec, installed, and left the app pinned to the old peers:
318
+ Found by building the cross product of delivery semantics against attach kinds each was individually covered and the combination was not.
236
319
 
237
- 0.22.1 requires @effect/rpc ^0.76.0 @effect/platform ^0.97.0 the app declared @effect/rpc ^0.75.1 @effect/platform ^0.96.2
320
+ `each` answers "you are already up to date" with silence. `latest` re-sent the retained value on every resume, on the reasoning that a last-value-wins delivery is idempotent. It is idempotent in a store and not on a screen: a reconnect handing back the value already displayed is a re-render, and on a flaky connection that is a visible flicker with nothing behind it. There is no reason for the two semantics to differ on that question.
238
321
 
239
- pnpm warns about that and installs anyway. The app compiles and boots on a dependency graph the framework was never tested against, which is the worst shape a version mismatch takes: nothing fails, so nothing points at the cause. Found by a consumer while diagnosing something unrelated.
322
+ The comparison is per `(origin, n)`, not by serial aloneunder `latest` the retained entry can come from ANY replica, so a bare number would read another pod's serial as our own and skip a value the client has genuinely never seen. Pinned by a test that publishes locally, injects remotely, and resumes current with respect to the local origin only.
323
+ - **@voltro/runtime** — **`await ctx.events.publish(...)` in an async handler published NOTHING, silently.**
240
324
 
241
- `update` now reads the peer requirements off the freshly installed `@voltro/*` packages (disk, after the install no second per-package-manager registry query to get wrong, and no exposure to the yarn-classic hazard where `yarn npm info …` parses as `yarn run npm`), aligns the app's declared ranges, and re-installs if anything moved.
325
+ `ctx.events.publish` returns an `Effect`. An Effect is not thenable, so `await` hands the object back unrun: no delivery to clients, no cross-replica publish, no webhook, no workflow trigger and the handler returns success. Nothing errors, nothing logs, and `tsc` is satisfied because awaiting a non-Promise is legal.
242
326
 
243
- Three rules keep it from doing damage:
327
+ Found the hard way: a two-replica end-to-end fixture published from an async handler, the action returned `{ ok: true }`, and a full broker trace showed no event traffic at all. The first two hypotheses (a subscribe/publish race, then a stale build) were both wrong, and the diagnosis only landed after instrumenting the broker on both sides.
244
328
 
245
- - **Only peers the app already declares.** One resolved transitively is not ours to add — that would change the app's dependency surface on its behalf. - **Only when the declared floor is genuinely BELOW the requirement.** An app pinned ahead, or pinned exactly at the floor with different syntax (`0.76.0` vs `^0.76.0`), is left alone. Those are choices, not drift. - **Only ranges it can judge** (`^`, `~`, `>=`, exact). A union, an upper bound, `workspace:` / `catalog:` left alone. Under-reporting an exotic range is safe; rewriting one we did not understand is not.
329
+ It matters because **both handler styles are supported and shipped**: the docs show the `Effect.gen` + `yield*` form, which works, while the mutation TEMPLATE ships an `async (input, ctx) => { }` handler. An author following the template and reaching for `ctx.events.publish` gets the one spelling that cannot work.
246
330
 
247
- If two framework packages disagree about one peer, that is REPORTED with both names and skippedit is our bug, and resolving it inside a user's upgrade would hide it.
248
- - **@voltro/cli** — **`voltro update` now also reports a peer that NOBODY declares.** The alignment added alongside this rewrites ranges an app already declares; a second consumer hit the other half of the same problem.
331
+ It is also inconsistent with the rest of `ctx.*`. `ctx.store.insert`, `ctx.cache` and `ctx.kv` are Promise-based precisely so async handlers can use them `makeAsyncKv` / `makeAsyncCache` exist for that reason. `ctx.events` is the one member that is not, and the difference is invisible at the call site.
249
332
 
250
- Their `apps/voltro-api/package.json` declared the three `@effect/*` packages. The workspace ROOT did not and the root's `@voltro/client` / `web` / `database` / `protocol` / `ai`, the ones all three frontends use, all require `effect ^3.22`. It resolved `3.21.4` transitively. The peer was unsatisfied workspace-wide, the install succeeded, and nothing said a word.
333
+ `publishEvent`'s Effect is now also awaitable: the returned value carries a `then` that runs it, so `await ctx.events.publish(…)` performs the publish and resolves with the same result `yield*` produces. Both spellings work, neither is silent, and the Effect-first form remains the documented one.
334
+ - **@voltro/runtime** — **Publishing an event is 2.1× faster, and the reason is worth knowing: observability was setting the throughput ceiling.**
251
335
 
252
- That matters more than a version skew usually does because **Effect types are nominal**: two copies produce red `tsc` on `rpcGroup.generated.ts` while the server runs green — the exact symptom `voltro doctor`'s duplicate-install check describes. Doctor already caught it after the fact, with cause and recipe, and the reporter says so; the point of this is to stop the state being created.
336
+ Measured on the publish path, single core:
253
337
 
254
- It is REPORTED, not repaired: the fix is to declare a dependency the app never declared, which changes its dependency surface. That is the user's call.
338
+ | | before | after | | --- | --- | --- | | `bus.publish` (1 subscriber) | 5.07µs | **1.45µs** | | `bus.publish` (100 subscribers) | 5.13µs | **1.48µs** | | `ctx.events.publish` (encode + size gate + bus) | 8.60µs | **4.12µs** |
255
339
 
256
- ### Internal (no consumer-facing effect)
340
+ The cause was one line. `Effect.tagMetrics('event', name)` is the natural spelling for labelling a metric and it modifies a FiberRef to build a label context on EVERY call: **4.2µs**, against 0.7µs for a metric instance tagged once via `Metric.tagged`. Before the change the metric was roughly **90% of the cost of publishing an event** — the route encoding, the replay ring and the size gate together came to 0.35µs.
257
341
 
258
- - **The four 0.22.0 codemods gain the gate tests the convention asks for.**
342
+ The tag cache is bounded by construction: its keys are DECLARED event names plus a two-value drop reason, so it cannot grow with traffic. A label carrying user data would make it a leak, and the test pins the boundedness rather than a size.
259
343
 
260
- `codemodRegistry.test.ts` asserts that every `*.codemod.ts` on disk is registered and that ids are unique — registration, not behaviour. The way a `manual` codemod actually fails is an `appliesTo` that is too broad, so the note prints for projects with nothing to do. That is not cosmetic: a note everyone sees is a note nobody reads, and the next one in the series announces a boot refusal or an irreversible deletion.
344
+ Two things the measurement corrected, both recorded because the guesses were plausible:
261
345
 
262
- Fifteen cases, both directions for each codemod. The silent direction is the one that needed pinning `rename-index` must not fire on an app that merely READS a plan (additive there), the plugin-table move must not fire on an app that installs none of the three, and `cache.scope` must not fire on a logger scope or an OAuth scope, both of which are ordinary English in any codebase.
346
+ - **The replay ring was NOT the bottleneck.** `ring = ring.slice(drop)` reallocates a 64-element array on every publish once full, which looked like the obvious cost. Fixing it to an in-place `splice` moved 5.76µs to 5.07µs real, and nowhere near the metric. It is kept because the allocation is what a garbage collector notices, but it was not the answer. - **Fan-out is nearly free.** 1 subscriber and 100 subscribers cost the same; 1000 costs 3.07µs. The per-publish work dominates, not the delivery loop.
263
347
 
264
- Verified by breaking a gate rather than by watching green: widening `TOUCHES_SCOPE` to match everything fails exactly the two silent-direction cases and nothing else. A test that has never been seen to fail is not evidence that it checks anything.
348
+ Guarded behaviourally rather than by timing a timing assertion goes flaky on a loaded CI machine and then gets deleted, after which the regression it guarded is invisible again. Reverting to `tagMetrics` leaves the cache empty and the test goes red.
349
+ - **@voltro/cli** — **A source-tree guard failed the whole test FILE when a fixture directory vanished mid-walk.**
265
350
 
266
- **One finding, pinned rather than quietly fixed.** `04_inspect-write-credential` gates on `envTokenAuthResolver|InspectAuthResolver|authResolver`, and the third alternative is not inspect-scoped any project with its own unrelated `authResolver` gets the note. It is the loosest gate in the set. There is a test asserting the current behaviour, so tightening it is a deliberate act with a failing test to update, rather than a silent change to who hears about a credential split.
267
- - **The test harness sends ANY hostless bind to the loopback, not just `port: 0`.**
351
+ `netHarnessPackages.test.ts` walked with `readdirSync(dir)` then `statSync(p)` two syscalls with a gap. The codegen suites create their fixture modules inside `src/` (`mkdtemp(join(here, '.codegen-…'))`) and remove them in `afterEach`, and they have to live there: the codegen imports them through vite's module graph, which is rooted at the package. A directory removed inside that gap makes `statSync` throw `ENOENT`, which fails the file at COLLECTION time — no assertion, a path nobody recognises, and green the moment you re-run it alone.
268
352
 
269
- The first version rewrote only ephemeral binds, and that was worse than not doing it at all: it made the two halves of a single test disagree about address family.
353
+ This is the FOURTH file to grow that shape, and the rule was already written up in `packages/cli/CLAUDE.md` for `ledgerReadPortability.test.ts`. It surfaced now because two new codegen suites landed in the same directory, which is the point: the latent version was indistinguishable from machine load.
270
354
 
271
- `devHealthServer`'s conflict case caught it in the next gate run. That test binds ephemerally, then asks for the SAME port again and expects `EADDRINUSE` to degrade the handle to `port: null`. With only port-0 rewritten, the first server took `127.0.0.1:P` while the second an explicit port, so untouched took `:::P`. Those do not collide. The expected conflict silently stopped happening and the assertion read `expected 51500 to be null`.
355
+ Fixed on the reader, per that rule: `readdirSync(dir, { withFileTypes: true })` gives the name and the kind from ONE syscall, so there is no gap; and dot-directories are skipped, which is right regardless a scratch directory is never source.
356
+ - **@voltro/plugin-presence** — **A client that vanished stayed in the presence roster forever, and `presencePlugin({ timeoutMs })` did nothing.**
272
357
 
273
- The failure is worth keeping in view because it is the same mechanism the harness exists to remove, produced by a half-applied fix: two binds of one port number in different families are two independent binds. A test that names its own interface still keeps it, and production is untouched the wildcard remains the default there, because a container must be reachable from outside.
358
+ One cause, two symptoms. A member left the roster only when its client explicitly CALLED `leave`. A closed laptop, a dropped network or a crashed tab call nothing and the owning replica is still alive, so `dropOwner` never fires either. Those entries stayed, and every screen kept showing people who had gone home.
274
359
 
275
- Re-verified after: `devHealthServer` 5 passed, `mcp` 13, `protocol` 301, `runtime` 1038, and an instrumented run still reports `bound 127.0.0.1:<port>`.
276
- - **The net harness is shared across every package that binds a listener, and a derived guard keeps it that way.**
360
+ The tracker's own comment asserted the opposite ("an entry is removed when the client leaves") and argued from it that a staleness filter "would only add a way to be wrong". The premise was false, so the conclusion protected the bug. A stale comment describing a cluster-coordinated sweep that had been deleted in an earlier rewrite made it read as already-solved from a second angle.
277
361
 
278
- The bind fix itself ships with the `host` option in this same release. What did not ship with it was reach: the mitigation lived in `packages/cli/vitest.config.ts`, written where the symptom appeared, so the other eleven packages whose tests bind a real listener never had it. `@voltro/mcp` then failed a release gate with the identical signature bound, zero connections, its client stuck in `fetch` and that read as a NEW problem rather than as the containment being too narrow. It is the second time this repo fixed a real-listener flake inside one package's config.
362
+ `timeoutMs` was the second half of the same defect: accepted, shown in the plugin's own usage example, and logged at boot — read by nothing. The same shape as `defineEvent({ guards })` and `broadcast({ channel })`.
279
363
 
280
- `test/harness/setup.ts` is now loaded by all twelve. It is deliberately NOT in `@voltro/testing`: that package is published, and a `net.Server` monkey-patch does not belong in a shipped API surface.
364
+ `sweep()` now removes members whose client stopped heartbeating, and `timeoutMs` drives it. It touches **only this instance's own partition** another owner's entries carry timestamps from THEIR clock, and judging them against ours is exactly the mistake instance membership exists to avoid: a peer that is gone is dropped whole, on a signal, never on a guess about clock skew.
281
365
 
282
- `packages/cli/src/netHarnessPackages.test.ts` DERIVES the required set any test file calling `.listen(` / `createServer(` / `serveApi(` / `startRpcServer(` — instead of curating a list that would rot exactly the way the original mitigation did. Remove a package's config and it fails naming that package; verified by deleting `@voltro/mcp`'s and watching it go red. It also asserts the derivation matches more than five packages, because a guard that silently matches nothing reads exactly like a clean repo.
366
+ It needs no cluster coordination, and that is a consequence of the design rather than a shortcut: the table version had shared rows, so one replica had to evict them or they would fight. Owner-partitioned presence has no shared state, so every replica sweeps its own and there is nothing to coordinate.
283
367
 
284
- The harness covers `@voltro/cli`'s `unit` project too, not only `integration`: the light mock-server suites live there, and `devHealthServer` one of them is among the files this failure mode has hung.
285
- - **Thirty-two tests reported `passed` when their service was absent. They skip now, and a derived check keeps it that way.**
368
+ Removals are ANNOUNCED a local removal nobody broadcasts is a member every other replica keeps showing. The sweep runs at a third of the timeout, so a vanished member is gone within roughly 1.3× the window rather than up to 2×.
369
+ - **@voltro/cli** — **`voltro build` could delete output it had just written.** The post-build orphan prune compared each file's mtime against `Date.now()` taken at build start — two different clocks. Linux stamps inode times from a COARSE clock (`ktime_get_coarse_real_ts64`) that advances once per timer tick, so a file written microseconds AFTER the cutoff can carry an mtime a tick BEFORE it, and the strict comparison then removed it.
286
370
 
287
- The rule is not new `voltro/CLAUDE.md` states that a suite needing a live service must SKIP rather than pass, that all 36 suites with the hand-rolled shape were converted, and that a new one must never be added. It was enforced by prose, so it rotted: seven files had grown it back.
371
+ The consequence is the exact failure the prune was designed to avoid: a bundle that is missing pieces mid-run. The wipe-before-build version had the same effect for a different reason, and this reintroduced it in a narrower window.
288
372
 
289
- Measured, not inferred:
373
+ The comparison now carries a one-second tolerance. The two directions are not symmetric — too small deletes a fresh artefact, too large lets an orphan survive until the next prune — so the margin sits on the side of keeping. Real orphans are minutes or builds old.
290
374
 
375
+ Found by the release gate on Linux, where the suite's own concurrency case failed while asserting a precondition that held: the file it checked was fine, a different one was pruned. It had never failed on macOS, whose timestamp granularity differs. The suite now pins the tolerance directly — a file stamped just before the cutoff must survive, and one past the tolerance must still go, so the margin cannot quietly widen into a no-op.
376
+ - **@voltro/cli** — **A `source:` that names no table is now reported at boot.** It was silent, and the silence is the defect: `source` is matched BY NAME against change events, so one naming a table that does not exist matches nothing — the query returns its first result and never updates again. Not a broken subscription, a permanently silent one, which from the outside is indistinguishable from "nothing has changed".
377
+
378
+ ```text
379
+ 1 query declares a `source` that names no table:
380
+ agent.messages: source 'agent_messages' is not a declared table — did you mean '_voltro_agent_messages'?
291
381
  ```
292
- $ PG_PORT=1 vitest run plugin-ratelimit/src/postgresStore.test.ts
293
- Tests 5 passed (5)
294
- ```
295
382
 
296
- Five tests that connected to nothing. The shape is a `beforeAll` probe plus `if (!ok) return` in each body: an absent dependency becomes a PASS, the only trace is a shorter duration, and vitest swallows the `console.warn` meant to say otherwise. Two of the seven — `concurrency.pg` and `jsonArrayWrite.pg` exist specifically to prove atomicity under real concurrency, so a green there was evidence for a claim nobody had checked.
383
+ Found by a consumer applying the `agent_messages` `_voltro_agent_messages` rename we shipped. Their two agent queries went quiet, their live typewriter stopped updating (the reply arrived on page reload), and every layer agreed everything was fine: boot clean, zero warnings, `tsc` green — `source` is a string. Their own invariant test missed it too, because it compared DECLARED sources against READ tables and both sides named the old table, so they went stale together and agreed.
384
+
385
+ Our codemod's reassurance — *"a missed one fails loudly with 'relation does not exist'"* — is true of a SQL reference and NOT of a `source:` declaration. That sentence is what sent them past the `grep` hits it had printed.
386
+
387
+ The check is set membership against data the boot already holds, so it closes a class: a rename is one way in, a typo is another, a plugin table whose plugin is not installed is a third. The suggestion is what makes it actionable, and it is why edit distance alone is not enough — `agent_messages` → `_voltro_agent_messages` is eight edits, so a prefix match wins outright. Wired into `voltro dev` AND `voltro serve`. **Warn, not refuse**, deliberately: an app can be carrying one right now and booting happily, so refusing would turn an upgrade into an outage for a defect the framework never mentioned.
388
+
389
+ **And every mutating `inspect`-backed command now sends the write credential.** `voltro schedule run`, `voltro workflows start|resume|signal` and `voltro inspect invoke` all sent the bearer and none sent `x-voltro-inspect-write`, so they were refused by their own server while the identical `curl` with both headers worked — using tokens `voltro dev` had minted into the project's own `.env.local`. Five call sites, one omission, in the shared helper none of them owned; it is attached there now for every mutating method. From the ENVIRONMENT only: the read token is published in the runtime registry so a read works from any directory, and publishing the write token beside it would leave no second factor.
390
+ - **@voltro/cli** — **`voltro serve` built the instance-membership registry TWICE per process.**
391
+
392
+ `serveCommand` builds one before `bindDataStore` — it has to, because a plugin handed a registry that appears later would silently never learn that a peer died — and `serveApi` built a second. So one serve process ran **two heartbeat timers announcing the same `instanceId`**, held two subscriptions to the members channel, and handed the presence plugin and the event layer different objects for one fact. `serveCommand`'s was also never detached at shutdown, so its timer ran until process exit.
393
+
394
+ `voltro dev` builds exactly one, which makes this the dev/serve divergence class again — both paths typecheck alone, and nothing errors either side. It was found by booting `voltro serve` for the first time in this area and reading its log: `membership: announcing` appeared twice with the same id.
297
395
 
298
- All seven now use `describeIfReachable` (`plugin-ratelimit` ×2, `plugin-broadcast`, `plugin-flags`, `plugin-versioning`, `integration-harness` ×2), and four packages gained the `@voltro/testing` devDependency they were missing — the import would have typechecked clean and died at runtime with `Cannot find package`, which this repo has been bitten by before.
396
+ `serveCommand` now hands its registry to `serveApi`, which builds one only when nothing was passed (a direct `serveApi` call in a test or an embedder).
299
397
 
300
- Verified in BOTH directions, because only one is obvious: with no service, `2 passed | 3 skipped` where it used to be `5 passed`; with the stack up, 5/10/4/2 tests actually run and pass.
398
+ Pinned by a two-replica integration test that boots real `voltro serve` processes against a real Redis and asserts exactly one `announcing` line per process plus the boot REFUSAL when `VOLTRO_SESSION_SECRET` is unset, which is the first thing a deployment hits and must name both the variable and the command that fixes it.
399
+ - **@voltro/protocol, @voltro/cli** — **Two guards for the two defect shapes this area kept producing.**
301
400
 
302
- `packages/cli/src/noHandRolledReachability.test.ts` makes the rule mechanical. It DERIVES the offenders from source rather than curating a list, and it strips comments first — the first version flagged two files whose only offence was a comment *explaining* the anti-pattern, and a check that punishes documenting a hazard teaches people to stop documenting it. It also excludes itself, since it must state the pattern in order to forbid it, and asserts the supported helper is used by more than twenty files, so an empty repo could not make it vacuous. Red-checked by reintroducing the guard into `plugin-flags`: it fails and names the file.
303
- - **@voltro/runtime, @voltro/cli, @voltro/mcp** — Two `@voltro/runtime` tests asserting that a symbol is EXPORTED carried vitest's 5s default timeout around a dynamic `import('./index')`. That silently added a second assertion nobody meant to make — "…and a cold import of this package's whole barrel completes within 5 seconds" — which is a claim about the MACHINE.
401
+ Every defect found while hardening the event primitive was one of two things, so they are checked now rather than rediscovered:
304
402
 
305
- In a full uncached monorepo run the package's import phase alone was 88s and the file went red while every assertion in it would have passed. Given an explicit 60s ceiling: the timeout is now a backstop rather than the assertion, which is the same correction already applied to `coordinatedSchedule.test.ts`.
403
+ **"Declared but never read."** `defineEvent({ guards })` was accepted, documented as "WHO MAY LISTEN", declared as `ScopeError` in the wire contract, serialised into the manifest, reported by doctor and counted in the dashboard and enforced nowhere. `declaredOptionsEnforced.test.ts` requires each option to be READ on the event's own path, and it took three attempts to make it able to fail:
404
+
405
+ - v1 asked whether the symbol appeared anywhere outside a reporter. It does — on the query path — so it stayed green through a revert that removed the event enforcement entirely. It proved that SOMETHING checks guards, which was never in doubt. - v2 scoped it to the file and still passed: replacing the CALL left the import behind, and an unused import satisfied it. - v3 matches a CALL or a property read, with comments AND imports stripped. `guards` carried a doc block naming itself the whole time it was dead, so a rule satisfied by prose would have passed on the case it exists for.
406
+
407
+ **"Derived twice."** The broadcast namespace could have been derived by four wirings; the membership registry WAS built twice per serve process; the file-convention pattern list exists in three copies and `*.event.ts` was missing from one. None of them errors — two namespaces that disagree are simply invisible to each other. `derivedOnceGuard.test.ts` pins one construction per boot path and requires the second consumer to take the value as a parameter.
408
+
409
+ Both are red-verified against the actual reverts, not against a hypothetical.
410
+
411
+ Also fixed here: the two-replica boot test hardcoded the expected event count and went red when the fixture grew two more. It derives the number from the fixture now — a count written down beside the thing it counts rots on the next change.
412
+
413
+ And `nats-test` is in the CI stack. It was added to `docker-compose.yml` without being started, so every NATS integration test skipped — and the gate's no-undeclared-skips step is right to call that a coverage claim nobody honours.
414
+
415
+ ### Internal (no consumer-facing effect)
306
416
 
307
- **Three more files had the same shape**, and they are the ones this repo's maintainer notes already list as "rotating victims" of full-monorepo runs: `cli/src/adminExportServe.test.ts`, `cli/src/connectionServe.test.ts` and `mcp/src/http.test.ts`. All three BOOT a real listener and make real HTTP round-trips — the last one boots two servers against the same 5s default. Each went red in an uncached full run under load ~19 and green alone seconds later, with every assertion in them passing either way.
417
+ - **@voltro/workflow** **The cluster resume test tore the first runner down at a point the clock picked, and asserted a property only the engine can place.** It waited for `step1`'s SIDE EFFECT, slept one second, then killed runner A and asserted that the resumed runner B did not redo `step1`. Alone that held; inside the full gate it produced `expected ['A','B'] to deeply equal ['A']` B re-ran the step, correctly.
308
418
 
309
- That is worth naming precisely, because "it passes in isolation" has been the signature of both machine load AND a defect the suite carried itself, and this repo has been wrong in both directions. Here it is neither: the suites are correct and the timeout was measuring the wrong thing. A test whose claim is "these two endpoints compose" should not also be claiming how many milliseconds that takes on a saturated machine.
419
+ The one second was a guess that `step1`'s journal write had landed. A step's side effect and its durable record cannot be atomic, so a teardown between them re-runs the step on resume the framework is at-least-once at a step boundary and the docs say so, telling users to make side effects idempotent for exactly this reason. The assertion is therefore legitimate only at a teardown point chosen AFTER the write, and nothing in the test chose one.
310
420
 
311
- **And one of the four turned out NOT to be the machine.** With the 60s ceiling in place, `connectionServe.test.ts`'s "callback route is NOT mounted" test consumed the entire budget in a full parallel run 60006ms while its four siblings in the same file took 82ms, 50ms, 38ms and 1ms. A test that is 700× slower than its neighbours is hanging, not slow, and the raised ceiling is what made that readable: at 5s it looked like every other saturation red.
421
+ An `armed` step now sits between `step1` and the nap, and the teardown waits for it. `activityExecute` returns only once a step's result is durably recorded that is what lets replay skip it, and what the idempotency-key scenario in the same suite already depends on so `armed` starting IS the journal write having landed. No duration is left in that path.
312
422
 
313
- The cause is **not** known. It does not reproduce alone (3 runs) or as a whole file (4 runs), which leaves the full-parallel context and nothing more specific. So this does not claim a fix. Every request in that file now carries `AbortSignal.timeout(10_000)`, which turns the next occurrence into a named `TimeoutError` on a specific request instead of an anonymous test timeout that eats a minute of the run and reports nothing — the difference between an observation and a diagnosis.
423
+ Distinct from the ceiling raises around it, which address a resumed run needing longer than the timeout under load. This one is not a timeout: no amount of waiting turns a re-executed step back into a skipped one.
424
+ - **@voltro/cli** — The dev-SSR streaming tests defined "the shell" as *every chunk that arrived before 0.6 × the deferral delay* — an assertion about the machine wearing the shape of an assertion about the renderer. Under a loaded CI runner the shell lands after that deadline, the derived `shell` string comes out EMPTY, and the failure reads `Expected SHELL_LAYOUT_EAGER_OK`, as though the renderer had dropped a field. Green on every developer machine.
314
425
 
315
- Recorded rather than resolved, because "it passes in isolation" has been the signature of both machine load and a real defect in this repo, and this one has not been told apart yet.
316
- - **The 0.23.0 versioning codemod gains its gate test.**
426
+ The shell is now everything BEFORE the chunk carrying the deferred value, and the claim the first-byte deadline was reaching for is stated as what it actually is: the eager field's chunk index is strictly lower than the deferred value's. No duration remains in that path. The one clock that stays is the lower bound on WHEN the deferred value arrived — a slower machine only makes that more true.
317
427
 
318
- Same reason as the four before it: `codemodRegistry.test.ts` covers registration, not behaviour, and what a `manual` codemod gets wrong is an `appliesTo` that fires for projects with nothing to do. This note is long and carries a storage-budget warning, which makes a spurious print worse than usual — a long note on an app that is unaffected is the most reliable way to teach someone to stop reading them.
428
+ It still fails a buffered implementation, which is the point of the suite: one chunk means the deferred index is 0, the shell is empty, and the eager-field assertion fails.
319
429
 
320
- Four cases, both directions. Verified by breaking the gate: widening `TOUCHES_VERSIONING` to match everything fails exactly the two silent-direction cases.
430
+ **Found because a red suite had been reporting green.** CI's test step ends in `| tee`, and GitHub's default `run` shell is `bash -e` — *without* `pipefail` — so the step's exit status was tee's. `@voltro/cli#test` failed, turbo exited 1, and the step reported SUCCESS; the comment above it asserted pipefail was on. It surfaced only because the failing package died before printing its summary, which tripped the undeclared-skip check further down. One line later and the gate would have gone green on a failing test. The workflow now sets `defaults.run.shell: bash` so no future piped step can reintroduce it.