workflow 5.0.0-beta.9 → 5.0.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 (263) hide show
  1. package/README.md +68 -23
  2. package/dist/api-workflow.d.ts +3 -1
  3. package/dist/api-workflow.d.ts.map +1 -1
  4. package/dist/api-workflow.js +2 -1
  5. package/dist/api.d.ts +5 -4
  6. package/dist/api.d.ts.map +1 -1
  7. package/dist/api.js +6 -7
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +6 -1
  11. package/dist/internal/builtins.d.ts +4 -4
  12. package/dist/internal/builtins.js +6 -6
  13. package/dist/internal/errors.d.ts +1 -1
  14. package/dist/internal/errors.d.ts.map +1 -1
  15. package/dist/internal/errors.js +2 -2
  16. package/dist/nest-builder.d.ts +2 -0
  17. package/dist/nest-builder.d.ts.map +1 -0
  18. package/dist/nest-builder.js +2 -0
  19. package/dist/nest-vercel-builder.d.ts +2 -0
  20. package/dist/nest-vercel-builder.d.ts.map +1 -0
  21. package/dist/nest-vercel-builder.js +2 -0
  22. package/dist/runtime.d.ts +2 -1
  23. package/dist/runtime.d.ts.map +1 -1
  24. package/dist/runtime.js +4 -1
  25. package/docs/advanced/dynamic-workflows.mdx +224 -0
  26. package/docs/ai/chat-session-modeling.mdx +176 -422
  27. package/docs/ai/defining-tools.mdx +6 -7
  28. package/docs/ai/human-in-the-loop.mdx +11 -11
  29. package/docs/ai/index.mdx +67 -72
  30. package/docs/ai/message-queueing.mdx +71 -110
  31. package/docs/ai/meta.json +1 -0
  32. package/docs/ai/resumable-streams.mdx +40 -28
  33. package/docs/ai/sleep-and-delays.mdx +10 -10
  34. package/docs/ai/streaming-updates-from-tools.mdx +6 -6
  35. package/docs/api-reference/index.mdx +25 -1
  36. package/docs/api-reference/meta.json +8 -0
  37. package/docs/api-reference/vitest/index.mdx +68 -15
  38. package/docs/api-reference/workflow/create-hook.mdx +166 -10
  39. package/docs/api-reference/workflow/create-webhook.mdx +16 -15
  40. package/docs/api-reference/workflow/define-hook.mdx +37 -33
  41. package/docs/api-reference/workflow/fatal-error.mdx +30 -8
  42. package/docs/api-reference/workflow/fetch.mdx +14 -10
  43. package/docs/api-reference/workflow/get-step-metadata.mdx +2 -2
  44. package/docs/api-reference/workflow/get-workflow-metadata.mdx +3 -3
  45. package/docs/api-reference/workflow/get-writable.mdx +7 -7
  46. package/docs/api-reference/workflow/index.mdx +3 -3
  47. package/docs/api-reference/workflow/retryable-error.mdx +1 -1
  48. package/docs/api-reference/workflow/set-attributes.mdx +63 -0
  49. package/docs/api-reference/workflow/sleep.mdx +4 -4
  50. package/docs/api-reference/workflow-ai/durable-agent.mdx +63 -101
  51. package/docs/api-reference/workflow-ai/index.mdx +5 -5
  52. package/docs/api-reference/workflow-ai/workflow-chat-transport.mdx +67 -24
  53. package/docs/api-reference/workflow-api/get-hook-by-token.mdx +28 -12
  54. package/docs/api-reference/workflow-api/get-run.mdx +43 -8
  55. package/docs/api-reference/workflow-api/index.mdx +8 -9
  56. package/docs/api-reference/workflow-api/register-lifecycle-hooks.mdx +87 -0
  57. package/docs/api-reference/workflow-api/resume-hook.mdx +73 -12
  58. package/docs/api-reference/workflow-api/resume-webhook.mdx +11 -9
  59. package/docs/api-reference/workflow-api/start.mdx +107 -12
  60. package/docs/api-reference/workflow-astro/index.mdx +18 -0
  61. package/docs/api-reference/workflow-astro/meta.json +4 -0
  62. package/docs/api-reference/workflow-astro/workflow.mdx +45 -0
  63. package/docs/api-reference/workflow-errors/entity-conflict-error.mdx +4 -4
  64. package/docs/api-reference/workflow-errors/hook-conflict-error.mdx +60 -0
  65. package/docs/api-reference/workflow-errors/hook-force-claimed-error.mdx +70 -0
  66. package/docs/api-reference/workflow-errors/hook-not-found-error.mdx +8 -8
  67. package/docs/api-reference/workflow-errors/index.mdx +91 -0
  68. package/docs/api-reference/workflow-errors/meta.json +7 -0
  69. package/docs/api-reference/workflow-errors/precondition-failed-error.mdx +68 -0
  70. package/docs/api-reference/workflow-errors/run-expired-error.mdx +2 -2
  71. package/docs/api-reference/workflow-errors/run-not-supported-error.mdx +58 -0
  72. package/docs/api-reference/workflow-errors/step-not-registered-error.mdx +5 -5
  73. package/docs/api-reference/workflow-errors/throttle-error.mdx +2 -2
  74. package/docs/api-reference/workflow-errors/too-early-error.mdx +2 -2
  75. package/docs/api-reference/workflow-errors/workflow-error.mdx +52 -0
  76. package/docs/api-reference/workflow-errors/workflow-not-registered-error.mdx +5 -6
  77. package/docs/api-reference/workflow-errors/workflow-run-cancelled-error.mdx +13 -6
  78. package/docs/api-reference/workflow-errors/workflow-run-failed-error.mdx +12 -5
  79. package/docs/api-reference/workflow-errors/workflow-run-not-completed-error.mdx +58 -0
  80. package/docs/api-reference/workflow-errors/workflow-run-not-found-error.mdx +4 -4
  81. package/docs/api-reference/workflow-errors/workflow-runtime-error.mdx +58 -0
  82. package/docs/api-reference/workflow-errors/workflow-world-error.mdx +8 -8
  83. package/docs/api-reference/workflow-globals.mdx +15 -11
  84. package/docs/api-reference/workflow-nest/configure-workflow-controller.mdx +37 -0
  85. package/docs/api-reference/workflow-nest/index.mdx +31 -0
  86. package/docs/api-reference/workflow-nest/meta.json +9 -0
  87. package/docs/api-reference/workflow-nest/nest-local-builder.mdx +64 -0
  88. package/docs/api-reference/workflow-nest/workflow-controller.mdx +46 -0
  89. package/docs/api-reference/workflow-nest/workflow-module.mdx +134 -0
  90. package/docs/api-reference/workflow-next/with-workflow.mdx +39 -17
  91. package/docs/api-reference/workflow-nitro/index.mdx +60 -0
  92. package/docs/api-reference/workflow-nuxt/index.mdx +48 -0
  93. package/docs/api-reference/workflow-observability/hydrate-data.mdx +35 -0
  94. package/docs/api-reference/workflow-observability/hydrate-resource-io.mdx +62 -0
  95. package/docs/api-reference/workflow-observability/index.mdx +62 -0
  96. package/docs/api-reference/workflow-observability/meta.json +11 -0
  97. package/docs/api-reference/workflow-observability/observability-revivers.mdx +50 -0
  98. package/docs/api-reference/workflow-observability/parse-class-name.mdx +41 -0
  99. package/docs/api-reference/workflow-observability/parse-step-name.mdx +40 -0
  100. package/docs/api-reference/workflow-observability/parse-workflow-name.mdx +55 -0
  101. package/docs/api-reference/workflow-runtime/create-world.mdx +39 -0
  102. package/docs/api-reference/workflow-runtime/get-world-handlers.mdx +44 -0
  103. package/docs/api-reference/{workflow-api → workflow-runtime}/get-world.mdx +11 -14
  104. package/docs/api-reference/workflow-runtime/health-check.mdx +51 -0
  105. package/docs/api-reference/workflow-runtime/index.mdx +41 -0
  106. package/docs/api-reference/workflow-runtime/meta.json +12 -0
  107. package/docs/api-reference/workflow-runtime/set-world.mdx +51 -0
  108. package/docs/api-reference/workflow-runtime/workflow-entrypoint.mdx +43 -0
  109. package/docs/api-reference/workflow-runtime/world/analytics.mdx +315 -0
  110. package/docs/api-reference/workflow-runtime/world/index.mdx +60 -0
  111. package/docs/api-reference/workflow-runtime/world/meta.json +4 -0
  112. package/docs/api-reference/workflow-runtime/world/queue.mdx +88 -0
  113. package/docs/api-reference/{workflow-api → workflow-runtime}/world/storage.mdx +104 -35
  114. package/docs/api-reference/{workflow-api → workflow-runtime}/world/streams.mdx +8 -8
  115. package/docs/api-reference/workflow-serde/index.mdx +1 -2
  116. package/docs/api-reference/workflow-serde/workflow-deserialize.mdx +3 -4
  117. package/docs/api-reference/workflow-serde/workflow-serialize.mdx +8 -8
  118. package/docs/api-reference/workflow-sveltekit/index.mdx +18 -0
  119. package/docs/api-reference/workflow-sveltekit/meta.json +4 -0
  120. package/docs/api-reference/workflow-sveltekit/workflow-plugin.mdx +42 -0
  121. package/docs/api-reference/workflow-vite/index.mdx +18 -0
  122. package/docs/api-reference/workflow-vite/meta.json +4 -0
  123. package/docs/api-reference/workflow-vite/workflow.mdx +48 -0
  124. package/docs/changelog/attributes-mvp.mdx +53 -41
  125. package/docs/changelog/batched-event-writes.mdx +79 -0
  126. package/docs/changelog/eager-processing.mdx +110 -436
  127. package/docs/changelog/index.mdx +4 -2
  128. package/docs/changelog/lazy-event-creation.md +127 -0
  129. package/docs/changelog/lazy-hook-resume.mdx +78 -0
  130. package/docs/changelog/meta.json +11 -1
  131. package/docs/changelog/resilient-resume.mdx +32 -0
  132. package/docs/changelog/resilient-start.mdx +33 -285
  133. package/docs/changelog/step-message-ownership.mdx +360 -0
  134. package/docs/changelog/turbo-mode.md +87 -0
  135. package/docs/comparisons/index.mdx +66 -0
  136. package/docs/comparisons/meta.json +11 -0
  137. package/docs/comparisons/workflow-sdk-vs-aws-agentcore.mdx +55 -0
  138. package/docs/comparisons/workflow-sdk-vs-aws-step-functions.mdx +111 -0
  139. package/docs/comparisons/workflow-sdk-vs-cloudflare-workflows.mdx +71 -0
  140. package/docs/comparisons/workflow-sdk-vs-inngest.mdx +102 -0
  141. package/docs/comparisons/workflow-sdk-vs-temporal.mdx +123 -0
  142. package/docs/comparisons/workflow-sdk-vs-trigger-dev.mdx +104 -0
  143. package/docs/configuration/build-and-diagnostics.mdx +79 -0
  144. package/docs/configuration/cli-and-web-ui.mdx +241 -0
  145. package/docs/configuration/framework-options.mdx +165 -0
  146. package/docs/configuration/index.mdx +32 -0
  147. package/docs/configuration/meta.json +12 -0
  148. package/docs/configuration/runtime-tuning.mdx +399 -0
  149. package/docs/configuration/worlds.mdx +315 -0
  150. package/docs/cookbook/advanced/child-workflows.mdx +33 -25
  151. package/docs/cookbook/advanced/publishing-libraries.mdx +65 -56
  152. package/docs/cookbook/advanced/serializable-steps.mdx +48 -68
  153. package/docs/cookbook/advanced/upgrading-workflows.mdx +35 -31
  154. package/docs/cookbook/agent-patterns/agent-cancellation.mdx +78 -60
  155. package/docs/cookbook/agent-patterns/durable-agent.mdx +23 -135
  156. package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +180 -195
  157. package/docs/cookbook/common-patterns/batching.mdx +20 -14
  158. package/docs/cookbook/common-patterns/idempotency.mdx +41 -53
  159. package/docs/cookbook/common-patterns/rate-limiting.mdx +8 -4
  160. package/docs/cookbook/common-patterns/saga.mdx +23 -19
  161. package/docs/cookbook/common-patterns/scheduling.mdx +30 -22
  162. package/docs/cookbook/common-patterns/sequential-and-parallel.mdx +29 -25
  163. package/docs/cookbook/common-patterns/timeouts.mdx +26 -21
  164. package/docs/cookbook/common-patterns/webhooks.mdx +10 -6
  165. package/docs/cookbook/common-patterns/workflow-composition.mdx +27 -17
  166. package/docs/cookbook/index.mdx +22 -22
  167. package/docs/cookbook/integrations/ai-sdk.mdx +63 -48
  168. package/docs/cookbook/integrations/chat-sdk.mdx +46 -33
  169. package/docs/cookbook/integrations/sandbox.mdx +58 -45
  170. package/docs/deploying.mdx +106 -0
  171. package/docs/errors/abort-signal-timeout-in-workflow.mdx +16 -12
  172. package/docs/errors/corrupted-event-log.mdx +39 -18
  173. package/docs/errors/deployment-mismatch.mdx +71 -0
  174. package/docs/errors/fetch-in-workflow.mdx +15 -14
  175. package/docs/errors/hook-conflict.mdx +38 -11
  176. package/docs/errors/hook-force-claimed.mdx +96 -0
  177. package/docs/errors/index.mdx +24 -37
  178. package/docs/errors/node-js-module-in-workflow.mdx +9 -5
  179. package/docs/errors/replay-divergence.mdx +27 -0
  180. package/docs/errors/run-expired.mdx +85 -0
  181. package/docs/errors/runtime-decryption-failed.mdx +77 -0
  182. package/docs/errors/serialization-failed.mdx +44 -12
  183. package/docs/errors/start-invalid-workflow-function.mdx +9 -5
  184. package/docs/errors/step-executed-multiple-times.mdx +23 -0
  185. package/docs/errors/step-not-registered.mdx +6 -6
  186. package/docs/errors/timeout-in-workflow.mdx +12 -8
  187. package/docs/errors/webhook-invalid-respond-with-value.mdx +18 -18
  188. package/docs/errors/webhook-response-not-sent.mdx +20 -16
  189. package/docs/errors/workflow-not-registered.mdx +5 -5
  190. package/docs/foundations/cancellation.mdx +31 -32
  191. package/docs/foundations/errors-and-retries.mdx +54 -11
  192. package/docs/foundations/hooks.mdx +98 -35
  193. package/docs/foundations/idempotency.mdx +267 -12
  194. package/docs/foundations/index.mdx +1 -26
  195. package/docs/foundations/serialization.mdx +22 -22
  196. package/docs/foundations/starting-workflows.mdx +104 -30
  197. package/docs/foundations/streaming.mdx +108 -60
  198. package/docs/foundations/versioning.mdx +4 -4
  199. package/docs/foundations/workflows-and-steps.mdx +9 -9
  200. package/docs/getting-started/astro.mdx +22 -18
  201. package/docs/getting-started/express.mdx +15 -11
  202. package/docs/getting-started/fastify.mdx +15 -11
  203. package/docs/getting-started/hono.mdx +15 -11
  204. package/docs/getting-started/index.mdx +10 -3
  205. package/docs/getting-started/meta.json +3 -1
  206. package/docs/getting-started/nestjs.mdx +264 -21
  207. package/docs/getting-started/next.mdx +18 -14
  208. package/docs/getting-started/nitro.mdx +22 -18
  209. package/docs/getting-started/nuxt.mdx +15 -11
  210. package/docs/getting-started/python.mdx +190 -41
  211. package/docs/getting-started/react-router/index.mdx +33 -0
  212. package/docs/getting-started/react-router/meta.json +5 -0
  213. package/docs/getting-started/react-router/v7.mdx +237 -0
  214. package/docs/getting-started/react-router/v8.mdx +232 -0
  215. package/docs/getting-started/sveltekit.mdx +20 -16
  216. package/docs/getting-started/tanstack-start.mdx +17 -13
  217. package/docs/getting-started/vite.mdx +15 -11
  218. package/docs/how-it-works/cancellation.mdx +63 -63
  219. package/docs/how-it-works/code-transform.mdx +82 -66
  220. package/docs/how-it-works/encryption.mdx +30 -26
  221. package/docs/how-it-works/event-sourcing.mdx +132 -35
  222. package/docs/how-it-works/framework-integrations.mdx +96 -337
  223. package/docs/how-it-works/understanding-directives.mdx +22 -22
  224. package/docs/internal/index.mdx +6 -4
  225. package/docs/internal/meta.json +6 -1
  226. package/docs/internal/nitro-native-build.mdx +38 -0
  227. package/docs/internal/nitro-web-ui.mdx +24 -0
  228. package/docs/internal/serializable-abort-controller.mdx +7 -7
  229. package/docs/meta.json +4 -2
  230. package/docs/observability/attributes.mdx +91 -21
  231. package/docs/observability/index.mdx +29 -15
  232. package/docs/observability/lifecycle-hooks.mdx +95 -0
  233. package/docs/observability/meta.json +1 -1
  234. package/docs/observability/retention.mdx +95 -0
  235. package/docs/observability/tracing.mdx +124 -0
  236. package/docs/testing/index.mdx +120 -38
  237. package/docs/testing/server-based.mdx +10 -10
  238. package/docs/whats-new.mdx +190 -0
  239. package/docs/worlds/building-a-world.mdx +538 -0
  240. package/docs/worlds/local.mdx +129 -0
  241. package/docs/worlds/meta.json +10 -0
  242. package/docs/worlds/postgres.mdx +424 -0
  243. package/docs/worlds/upgrading-to-v5.mdx +162 -0
  244. package/docs/worlds/vercel.mdx +345 -0
  245. package/package.json +17 -14
  246. package/docs/api-reference/workflow/experimental-set-attributes.mdx +0 -63
  247. package/docs/api-reference/workflow-api/world/index.mdx +0 -58
  248. package/docs/api-reference/workflow-api/world/meta.json +0 -4
  249. package/docs/api-reference/workflow-api/world/observability.mdx +0 -164
  250. package/docs/api-reference/workflow-api/world/queue.mdx +0 -86
  251. package/docs/deploying/building-a-world.mdx +0 -251
  252. package/docs/deploying/index.mdx +0 -95
  253. package/docs/deploying/meta.json +0 -4
  254. package/docs/deploying/world/local-world.mdx +0 -84
  255. package/docs/deploying/world/meta.json +0 -4
  256. package/docs/deploying/world/postgres-world.mdx +0 -224
  257. package/docs/deploying/world/vercel-world.mdx +0 -181
  258. package/docs/migration-guides/index.mdx +0 -34
  259. package/docs/migration-guides/meta.json +0 -9
  260. package/docs/migration-guides/migrating-from-aws-step-functions.mdx +0 -358
  261. package/docs/migration-guides/migrating-from-inngest.mdx +0 -304
  262. package/docs/migration-guides/migrating-from-temporal.mdx +0 -313
  263. package/docs/migration-guides/migrating-from-trigger-dev.mdx +0 -328
@@ -5,14 +5,26 @@ description: A minimal, write-only subset of the planned Workflow Attributes fea
5
5
 
6
6
  # Workflow Attributes (MVP)
7
7
 
8
- This is a minimal, **experimental** subset of the [planned Workflow Attributes feature for 5.0.0](https://github.com/vercel/workflow/pull/1933). See [discussion #132](https://github.com/vercel/workflow/discussions/132) for broader background on the use cases and the full design space.
8
+ This **experimental** minimum viable product (MVP) implements a subset of the [planned Workflow Attributes feature for 5.0.0](https://github.com/vercel/workflow/pull/1933). See [discussion #132](https://github.com/vercel/workflow/discussions/132) for broader background on the use cases and the full design space.
9
9
 
10
10
  The MVP lets workflow code attach plaintext `string → string` metadata to a run, viewable in any observability surface that reads the `WorkflowRun` entity. It is deliberately narrow: write-only, no reads from inside a run, no list/filter endpoints, no event-log representation. The wire format and SDK surface are chosen so the full 5.0.0 implementation replaces this without source-level breaking changes for end users.
11
11
 
12
+ ## Native v4 follow-up
13
+
14
+ The v4 implementation now completes the planned dispatch migration while preserving the experimental SDK name:
15
+
16
+ - `experimental_setAttributes()` appends a native `attr_set` event from workflow and step bodies instead of mutating through the internal step bridge.
17
+ - Native events carry writer attribution and are materialized immediately onto `run.attributes`.
18
+ - `start(workflow, args, { attributes })` seeds attributes on the initial `run_created` event, including the resilient-start creation path.
19
+ - The World spec is bumped to v4; attributes are supported for v4 runs rather than being backported into older run specifications.
20
+ - The direct server attributes endpoint remains available only as a deprecated compatibility path for clients that shipped with the MVP.
21
+
22
+ The remainder of this page documents the original MVP motivation and implementation.
23
+
12
24
  ## What MVP supports
13
25
 
14
26
  - `experimental_setAttributes(record)` callable from a **workflow body** (`"use workflow"` function), dispatched via an internal `__builtin_set_attributes` step bridge so the mutation gets a `step_created → step_completed` event pair
15
- - Attributes are materialized onto the `WorkflowRun` entity, plaintext, and visible via `world.runs.get()` / `world.runs.list()` and any observability UI built on top of those
27
+ - Attributes are materialized onto the `WorkflowRun` entity, plaintext, and visible through `world.runs.get()` / `world.runs.list()` and any observability user interface (UI) built on top of those
16
28
  - World implementations emit a side-channel observability record per successful write (in `world-vercel`, this hooks into the same observability/analytics pipeline already used for other run lifecycle events)
17
29
 
18
30
  Calling `experimental_setAttributes` from a step body was intentionally not supported in the MVP, but step-body calls are now supported as a follow-up. Plain host code remains unsupported because there is no active workflow run to attach attributes to.
@@ -30,15 +42,15 @@ See [PR #1933](https://github.com/vercel/workflow/pull/1933) for the design of t
30
42
 
31
43
  ## Why the MVP defers an `attr_set` event type
32
44
 
33
- The full design represents attribute changes as a new `attr_set` event type in the event log, replayed by the workflow runtime VM to reconstruct the attribute snapshot. That requires bumping `SPEC_VERSION_CURRENT`, because every world implementation (including community worlds) needs to handle the new event during replay, and the runtime's reconstruction logic gains a new case.
45
+ The full design represents attribute changes as a new `attr_set` event type in the event log, replayed by the workflow runtime virtual machine (VM) to reconstruct the attribute snapshot. That requires bumping `SPEC_VERSION_CURRENT`, because every world implementation (including community worlds) needs to handle the new event during replay, and the runtime's reconstruction logic gains a new case.
34
46
 
35
- A spec version bump is expensive: it gates every world adapter and ties the rollout to coordinated upgrades. We do not want to pay that cost twice — once for the MVP, again for the full feature.
47
+ A spec version bump is expensive: it gates every world adapter and ties the rollout to coordinated upgrades. We do not want to pay that cost twice: once for the MVP, again for the full feature.
36
48
 
37
49
  The MVP instead writes attributes via a direct entity-mutation path (outside the event log) which does not require a spec version bump. The downside is that MVP-era attributes have **no representation in the event log** and will not be visible to event-based reconstruction (e.g. a materialization rebuild). When the full feature ships, new writes use `attr_set` events; old runs created during the MVP window retain whatever attributes were materialized at the time, but their history is not recoverable.
38
50
 
39
51
  ## Implementation plan
40
52
 
41
- ### 1. `@workflow/world` — Storage interface addition
53
+ ### 1. `@workflow/world`: storage interface addition
42
54
 
43
55
  Add an `experimentalSetAttributes` method to `Storage.runs`:
44
56
 
@@ -73,7 +85,7 @@ runs: {
73
85
  }
74
86
  ```
75
87
 
76
- The method is **optional** to avoid forcing every World implementation (especially community-maintained adapters such as Redis, MongoDB, Turso, and similar) to ship support before the API stabilises. World implementations that do support it return the post-merge attribute snapshot so callers — notably the SDK helper and world adapters emitting observability records — have it without a follow-up read.
88
+ The method is **optional** to avoid forcing every World implementation (especially community-maintained adapters such as Redis, MongoDB, Turso, and similar) to ship support before the API stabilizes. World implementations that do support it return the post-merge attribute snapshot so callers (notably the SDK helper and world adapters emitting observability records) have it without a follow-up read.
77
89
 
78
90
  Add `attributes?: Record<string, string>` to `WorkflowRunBaseSchema` in `packages/world/src/runs.ts`. Optional for backward compatibility: runs created before this field landed have no `attributes` and read as `undefined`.
79
91
 
@@ -81,7 +93,7 @@ Add `attributes?: Record<string, string>` to `WorkflowRunBaseSchema` in `package
81
93
 
82
94
  `world-vercel` calls into a remote endpoint to persist attributes:
83
95
 
84
- ```
96
+ ```text
85
97
  POST /v2/runs/:runId/attributes
86
98
 
87
99
  {
@@ -95,9 +107,9 @@ POST /v2/runs/:runId/attributes
95
107
 
96
108
  Response: `{ "attributes": { "phase": "done", "tenant": "t1" } }`.
97
109
 
98
- The `changes` field **deliberately mirrors** the eventual `attr_set` event's `eventData.changes`. `allowReservedAttributes` is optional and framework-only; omit it for user-authored attributes. When the full feature ships, this endpoint goes away — the same `changes` shape is posted to `POST /v2/runs/:id/events` with `eventType: 'attr_set'` (plus a `writer` discriminator). No SDK signature change, no client-side migration.
110
+ The `changes` field **deliberately mirrors** the eventual `attr_set` event's `eventData.changes`. `allowReservedAttributes` is optional and framework-only; omit it for user-authored attributes. When the full feature ships, this endpoint goes away: the same `changes` shape is posted to `POST /v2/runs/:id/events` with `eventType: 'attr_set'` (plus a `writer` discriminator). No SDK signature change, no client-side migration.
99
111
 
100
- ### 3. `@workflow/core` — SDK surface
112
+ ### 3. `@workflow/core`: SDK surface
101
113
 
102
114
  A new export from `@workflow/core` (re-exported by `workflow`):
103
115
 
@@ -110,11 +122,11 @@ function experimental_setAttributes(
110
122
  ): Promise<void>
111
123
  ```
112
124
 
113
- `undefined` is normalized to `null` (unset). An empty object is a no-op (no RPC, no events).
125
+ `undefined` is normalized to `null` (unset). An empty object is a no-op. It makes no remote procedure call (RPC) and creates no events.
114
126
 
115
127
  Validation (shared helper, applied both client-side and server-side):
116
128
 
117
- - Key: 1–256 chars, must not start with `$` (reserved — see "Reserved `$` namespace" below)
129
+ - Key: 1–256 characters, must not start with `$` (reserved, see "Reserved `$` namespace" below)
118
130
  - Value: ≤ 256 bytes UTF-8
119
131
  - Maximum 64 attributes per run (validated against the post-merge snapshot when the server applies)
120
132
  - SDK-side violations throw `FatalError` from `@workflow/errors` before the internal step is dispatched; worlds revalidate as the final authority before mutating storage
@@ -134,7 +146,7 @@ await experimental_setAttributes(
134
146
  );
135
147
  ```
136
148
 
137
- The flag is per-call (no run-level "this run accepts reserved keys" mode), so each framework call site explicitly declares intent. Don't enable it from user code — misuse can conflict with observability surfaces, agent dashboards, or future platform features that rely on the reserved namespace.
149
+ The flag is per-call (no run-level "this run accepts reserved keys" mode), so each framework call site explicitly declares intent. Don't enable it from user code: misuse can conflict with observability surfaces, agent dashboards, or future platform features that rely on the reserved namespace.
138
150
 
139
151
  `experimental_setAttributes` is callable from a workflow body:
140
152
 
@@ -149,7 +161,7 @@ export async function myWorkflow(orderId: string) {
149
161
  }
150
162
  ```
151
163
 
152
- The workflow-body path validates input inside the VM and then dispatches the canonical `AttributeChange[]` through an internal `__builtin_set_attributes` step bridge — see "How workflow-body dispatch works" below. The mutation is materialized on the run entity by the step body.
164
+ The workflow-body path validates input inside the VM and then dispatches the canonical `AttributeChange[]` through an internal `__builtin_set_attributes` step bridge. See "How workflow-body dispatch works" below. The mutation is materialized on the run entity by the step body.
153
165
 
154
166
  Step-body calls resolve to the host-side export. In a step context, that export validates the input and posts the attribute changes directly to `world.runs.experimentalSetAttributes(runId, changes)`. Plain host code still throws `FatalError`.
155
167
 
@@ -168,7 +180,7 @@ const result = await processOrder();
168
180
  await experimental_setAttributes({ phase: 'done', orderId: result.id });
169
181
  ```
170
182
 
171
- **Fire-and-forget (`void`).** Drop the `await` to let the workflow proceed without blocking. The pending step queues on the workflow's next suspension (any `await` on a runtime primitive — a step, `sleep`, a hook). This is the canonical pattern for observability / tracking metadata where the workflow doesn't depend on the write.
183
+ **Fire-and-forget (`void`).** Drop the `await` to let the workflow proceed without blocking. The pending step queues on the workflow's next suspension (any `await` on a runtime primitive: a step, `sleep`, a hook). This is the canonical pattern for observability / tracking metadata where the workflow doesn't depend on the write.
172
184
 
173
185
  {/*@skip-typecheck - snippet, not runnable code*/}
174
186
 
@@ -183,18 +195,18 @@ return result;
183
195
 
184
196
  Two trade-offs to know about:
185
197
 
186
- 1. **Order of arrival at the world is not workflow-source order.** Fire-and-forget steps run out-of-band on the queue worker. A `void` write to one key followed by an `await` write to the same key may race; LWW-by-arrival applies (see "Concurrent writes" below).
198
+ 1. **Order of arrival at the world is not workflow-source order.** Fire-and-forget steps run out-of-band on the queue worker. A `void` write to one key followed by an `await` write to the same key may race; last-write-wins (LWW) by arrival applies (see "Concurrent writes" below).
187
199
 
188
- 2. **The last `void` before `return` may not land.** If you place a `void experimental_setAttributes(...)` immediately before returning, with no intervening `await` on a runtime primitive, drain-on-completion commits the `step_created` event but the step body is not reliably dispatched before the run transitions to its terminal status — see the architectural note in the "Implementation notes" section. In practice workflows almost always have an `await` after the last fire-and-forget call (a step, a sleep, a hook); if you don't, add `await sleep('0s')` before returning, or use the awaited form for that final write.
200
+ 2. **The last `void` before `return` may not land.** If you place a `void experimental_setAttributes(...)` immediately before returning, with no intervening `await` on a runtime primitive, drain-on-completion commits the `step_created` event but the step body is not reliably dispatched before the run transitions to its terminal status. See the architectural note in the "Implementation notes" section. In practice workflows almost always have an `await` after the last fire-and-forget call (a step, a sleep, a hook); if you don't, add `await sleep('0s')` before returning, or use the awaited form for that final write.
189
201
 
190
- **Parallel (`Promise.all`).** Multiple calls dispatch concurrently. Writes to disjoint keys all land. Writes to the same key resolve last-write-wins by *arrival order at the world* (not the order the workflow body issued the calls) — so don't use `Promise.all` for writes that must observe a specific order to the same key.
202
+ **Parallel (`Promise.all`).** Multiple calls dispatch concurrently. Writes to disjoint keys all land. Writes to the same key resolve last-write-wins by *arrival order at the world* (not the order the workflow body issued the calls), so don't use `Promise.all` for writes that must observe a specific order to the same key.
191
203
 
192
204
  {/*@skip-typecheck - snippet, not runnable code*/}
193
205
 
194
206
  ```ts
195
207
  'use workflow';
196
208
  await Promise.all([
197
- experimental_setAttributes({ phase: 'init' }), // disjoint keys — all land
209
+ experimental_setAttributes({ phase: 'init' }), // disjoint keys: all land
198
210
  experimental_setAttributes({ orderId: 'ord_123' }),
199
211
  experimental_setAttributes({ tenant: 't1' }),
200
212
  ]);
@@ -212,11 +224,11 @@ Implement `experimentalSetAttributes(runId, changes)` by reading the run's JSON
212
224
 
213
225
  #### `world-postgres`
214
226
 
215
- Add an `attributes JSONB` column to the runs table (default `'{}'::jsonb`, NOT NULL). Apply the merge in SQL using `jsonb_set` / `jsonb_strip_nulls` so the database does the merge atomically without a read-modify-write cycle, returning the post-merge map via `RETURNING attributes`.
227
+ Add an `attributes JSONB` column to the runs table (default `'{}'::jsonb`, NOT NULL). Apply the merge in Structured Query Language (SQL) using `jsonb_set` / `jsonb_strip_nulls` so the database does the merge atomically without a read-modify-write cycle, returning the post-merge map through `RETURNING attributes`.
216
228
 
217
229
  #### `world-vercel`
218
230
 
219
- Pure HTTP wrapper. Calls the wire endpoint described in §2 and returns the response's `attributes`. The backing service materializes the attribute map onto its run-row storage; where the underlying data store supports atomic per-key map updates, the merge is a single atomic operation rather than a read-modify-write cycle — the same shape the future `attr_set` event handler will use, so the storage layout is forward-compatible.
231
+ Pure HTTP wrapper. Calls the wire endpoint described in §2 and returns the response's `attributes`. The backing service materializes the attribute map onto its run-row storage; where the underlying data store supports atomic per-key map updates, the merge is a single atomic operation rather than a read-modify-write cycle, the same shape the future `attr_set` event handler will use, so the storage layout is forward-compatible.
220
232
 
221
233
  After the persistence ack, the service emits a side-channel observability record carrying the post-merge attribute snapshot, decoupled from the request path so the runtime never waits on analytics emission.
222
234
 
@@ -233,8 +245,8 @@ The MVP applies **last-write-wins by arrival order at the world**. Two concurren
233
245
  Writes from a single `await`-ed call chain are serialized by the workflow VM and land in workflow-source order. The concurrent / racy case applies to:
234
246
 
235
247
  - Multiple `experimental_setAttributes` calls inside one `Promise.all` writing the same key (the workflow VM dispatches them concurrently; the world sees them in scheduler order, not source order).
236
- - `void experimental_setAttributes(...)` followed by another call to the same key — the fire-and-forget step may still be in flight when the next call lands.
237
- - Multiple workflows writing the same key on the same run (rare — usually one workflow owns a run).
248
+ - `void experimental_setAttributes(...)` followed by another call to the same key: the fire-and-forget step may still be in flight when the next call lands.
249
+ - Multiple workflows writing the same key on the same run (rare: usually one workflow owns a run).
238
250
 
239
251
  Disjoint-key writes are unaffected: every call lands, regardless of pattern. Applications that need conditional semantics on a shared key should wait for the 5.0.0 release; we will not retrofit conditional writes onto the MVP path.
240
252
 
@@ -242,11 +254,11 @@ Disjoint-key writes are unaffected: every call lands, regardless of pattern. App
242
254
 
243
255
  Attribute changes do not appear in `world.events.list(runId)`. There is no record of *when* a key changed or *which step attempt* set it. The current snapshot on the run entity is authoritative; the history is lost.
244
256
 
245
- When the full feature ships, new writes carry writer attribution (`writer: { type: 'workflow' }` or `writer: { type: 'step', stepId, attempt }`) in their `attr_set` events. MVP-era writes will not have this — history starts at the `attr_set` cutover.
257
+ When the full feature ships, new writes carry writer attribution (`writer: { type: 'workflow' }` or `writer: { type: 'step', stepId, attempt }`) in their `attr_set` events. MVP-era writes will not have this. History starts at the `attr_set` cutover.
246
258
 
247
259
  ### MVP attributes do not survive materialization rebuild
248
260
 
249
- Any tooling that reconstructs the run entity from the event log (disaster recovery, debugging, audit) will see no attributes on MVP-era runs, because the writes are not in the event log. This is the chief reason `experimentalSetAttributes` is named "experimental" — it is a known break from the otherwise-strict event-sourced model.
261
+ Any tooling that reconstructs the run entity from the event log (disaster recovery, debugging, audit) will see no attributes on MVP-era runs, because the writes are not in the event log. This is the chief reason `experimentalSetAttributes` is named "experimental": it is a known break from the otherwise-strict event-sourced model.
250
262
 
251
263
  The 5.0.0 path closes this gap.
252
264
 
@@ -260,8 +272,8 @@ If you need behavior the MVP does not provide (read, list, filter, initial attri
260
272
 
261
273
  Unit tests in `@workflow/world` (validation surface) and `@workflow/core` (VM-side dispatch + host-side stub):
262
274
 
263
- - Validation rules — key length, value byte cap, `$` prefix, per-batch duplicates, post-merge count cap (with `existingKeys` so updates of present keys don't falsely trip the cap)
264
- - Reserved `$` namespace — rejected by default, accepted when `allowReservedAttributes: true` is passed (both for `validateAttributeKey` and at the batch level via `validateAttributeChanges`)
275
+ - Validation rules: key length, value byte cap, `$` prefix, per-batch duplicates, post-merge count cap (with `existingKeys` so updates of present keys don't falsely trip the cap)
276
+ - Reserved `$` namespace: rejected by default and accepted by the contextual `validateAttributeChanges` check when `allowReservedAttributes: true` is passed
265
277
  - `experimental_setAttributes({})` is a no-op (no dispatch, no events)
266
278
  - `undefined` value normalizes to a `null`-valued change on the wire
267
279
  - The `{ allowReservedAttributes: true }` opt-in is forwarded through the step bridge so the world receives the flag
@@ -293,16 +305,16 @@ End-to-end in `workbench/nextjs-turbopack` (exercises the full SWC plugin + work
293
305
 
294
306
  - Awaited workflow-body calls dispatch through the `__builtin_set_attributes` step bridge and merge correctly (the test inspects the run's event log to confirm a `step_created` / `step_completed` pair was emitted)
295
307
  - Fire-and-forget (`void experimental_setAttributes`) attributes land before the run terminates
296
- - `Promise.all` of disjoint-key writes — every key persists
297
- - Workflow throws after an awaited `experimental_setAttributes` — the attribute persists on the now-`failed` run (the per-run file lock on `run_failed` re-reads inside the critical section so the attribute snapshot survives the lifecycle write)
308
+ - `Promise.all` of disjoint-key writes: every key persists
309
+ - Workflow throws after an awaited `experimental_setAttributes`: the attribute persists on the now-`failed` run (the per-run file lock on `run_failed` re-reads inside the critical section so the attribute snapshot survives the lifecycle write)
298
310
 
299
- ## Migration to 5.0.0
311
+ ## Migration to native v4
300
312
 
301
- When the full attributes feature ships:
313
+ The native attributes follow-up applies this migration:
302
314
 
303
- - `experimental_setAttributes` (SDK) — unchanged signature, new dispatch path
304
- - `runs.experimentalSetAttributes` (world interface) — deprecated, then removed; replaced by `events.create(runId, { eventType: 'attr_set', eventData: { changes, writer } })`
305
- - Wire endpoint — `POST /v2/runs/:runId/attributes` removed; the same `changes` shape posts to `POST /v2/runs/:id/events`
315
+ - `experimental_setAttributes` (SDK): unchanged signature, new dispatch path
316
+ - `runs.experimentalSetAttributes` (world interface): deprecated, then removed; replaced by `events.create(runId, { eventType: 'attr_set', eventData: { changes, writer } })`
317
+ - Wire endpoint: `POST /v2/runs/:runId/attributes` is deprecated but temporarily retained for older clients; new calls post the same `changes` shape to events as `attr_set`
306
318
  - Pre-existing attribute values on MVP-era runs remain on the run entity but are not represented in the event log
307
319
 
308
320
  Skew protection means workflows started under the MVP will continue to run with the MVP dispatch path on their original deployment. New deployments use the new path. No in-place data migration is needed.
@@ -318,10 +330,10 @@ This section records concrete decisions taken while landing the MVP that weren't
318
330
  1. The workflow VM bundle resolves `experimental_setAttributes` to `packages/core/src/workflow/set-attributes.ts` (via the `workflow` package-exports condition).
319
331
  2. That helper validates the input record inline (no shared helper, no cross-file dependency from a 'use step' file) and produces canonical `AttributeChange[]`.
320
332
  3. It dispatches through the standard workflow-VM step mechanism: `globalThis[WORKFLOW_USE_STEP]('__builtin_set_attributes')(changes)`. The `useStep` dispatcher is the same one used by every other step call from a workflow body, populated by `packages/core/src/workflow.ts` at VM bootstrap.
321
- 4. The dispatch queues a step (`step_created`), the host runs `__builtin_set_attributes(changes, options)` from `packages/workflow/src/internal/builtins.ts`. The step body reads the active world, current run id, and attempt number directly from `globalThis` symbols (`Symbol.for('@workflow/world//cache')` and `Symbol.for('WORKFLOW_STEP_CONTEXT_STORAGE')`) — populated by the host runtime — and calls `world.runs.experimentalSetAttributes(runId, changes, options)`.
333
+ 4. The dispatch queues a step (`step_created`), the host runs `__builtin_set_attributes(changes, options)` from `packages/workflow/src/internal/builtins.ts`. The step body reads the active world, current run id, and attempt number directly from `globalThis` symbols (`Symbol.for('@workflow/world//cache')` and `Symbol.for('WORKFLOW_STEP_CONTEXT_STORAGE')`), populated by the host runtime, and calls `world.runs.experimentalSetAttributes(runId, changes, options)`.
322
334
  5. The step completes (`step_completed`), the workflow resumes.
323
335
 
324
- This puts the mutation on the event log as a normal `step_created → step_completed` pair without inventing a new event type — that stays for the full 5.0.0 cutover.
336
+ This puts the mutation on the event log as a normal `step_created → step_completed` pair without inventing a new event type. That stays for the full 5.0.0 cutover.
325
337
 
326
338
  The internal step is best-effort during the experimental phase. It sets `maxRetries = 2`, for three total attempts. If `world.runs.experimentalSetAttributes` fails on attempts 1 or 2, the error is rethrown so the runtime retries the step normally. If it still fails on attempt 3, the step logs `console.error` and returns; the workflow run continues instead of receiving a retry-exhaustion `FatalError` for failed tag posting.
327
339
 
@@ -335,15 +347,15 @@ When the full 5.0.0 attributes feature lands, `__builtin_set_attributes` is repl
335
347
 
336
348
  ### Endpoint lives under `v2`, not a fresh namespace
337
349
 
338
- The initial draft placed the new endpoint at `POST /v3/runs/:runId/attributes`, on the assumption that introducing a new wire feature warranted a major namespace bump. In practice `world-vercel` mixes `/v1/...` and `/v2/...` endpoints already, and creating a `v3Api` subrouter just for a single endpoint would have required duplicating the auth / flags / rate-limit middleware stack. The MVP endpoint is therefore mounted under the existing `v2Api`. The wire body shape is unchanged, so the migration path described above (rerouting from `/v2/runs/:runId/attributes` to `/v2/runs/:id/events`) still holds — just within the same namespace.
350
+ The initial draft placed the new endpoint at `POST /v3/runs/:runId/attributes`, on the assumption that introducing a new wire feature warranted a major namespace bump. In practice `world-vercel` mixes `/v1/...` and `/v2/...` endpoints already, and creating a `v3Api` subrouter for a single endpoint would have required duplicating the auth / flags / rate-limit middleware stack. The MVP endpoint is therefore mounted under the existing `v2Api`. The wire body shape is unchanged, so the migration path described above (rerouting from `/v2/runs/:runId/attributes` to `/v2/runs/:id/events`) still holds, only within the same namespace.
339
351
 
340
352
  ### Concurrent writes: read-modify-write, not per-key atomic
341
353
 
342
354
  The plan called for per-key atomic `UpdateExpression` updates (`SET #attrs.#k = :v` / `REMOVE #attrs.#k`) in the `world-vercel` backing store, on the basis that it eliminates the read-modify-write race. The MVP ships with the simpler read-modify-write path instead:
343
355
 
344
- - **In `world-postgres`** the SQL-side `jsonb_set` / `-` chain *is* used and is genuinely atomic on the run row, so the only race is the cap check (a separate `SELECT`). Documented as LWW-by-arrival for the cap; the merge itself is atomic.
345
- - **In the `world-vercel` backing service** the attributes column is laid out as a native key-addressable map so the atomic `UpdateItem` variant can be enabled later without a data migration. The MVP commits the merged map via the existing entity update path. Two concurrent writers therefore race; whichever lands second wins on shared keys, and any write to a non-overlapping key is preserved.
346
- - **In `world-local`** an in-process per-run mutex serializes the read-merge-write sequence so parallel `experimental_setAttributes` calls from concurrent steps do not lose writes within a single process. There is a corresponding test that exercises 20 parallel writes to the same run.
356
+ - **In `world-postgres`**: The SQL-side `jsonb_set` / `-` chain *is* used and is genuinely atomic on the run row, so the only race is the cap check (a separate `SELECT`). Documented as LWW-by-arrival for the cap; the merge itself is atomic.
357
+ - **In the `world-vercel` backing service**: The attributes column is laid out as a native key-addressable map so the atomic `UpdateItem` variant can be enabled later without a data migration. The MVP commits the merged map through the existing entity update path. Two concurrent writers therefore race; whichever lands second wins on shared keys, and any write to a non-overlapping key is preserved.
358
+ - **In `world-local`**: An in-process per-run mutex serializes the read-merge-write sequence so parallel `experimental_setAttributes` calls from concurrent steps do not lose writes within a single process. There is a corresponding test that exercises 20 parallel writes to the same run.
347
359
 
348
360
  This is consistent with the original "concurrent writes are LWW by arrival" caveat. Promoting to per-key atomic writes is a no-API-break change once the event-sourced path lands.
349
361
 
@@ -355,7 +367,7 @@ For the MVP the endpoint reuses the existing `WORKFLOW_EVENT` fact with `eventTy
355
367
 
356
368
  ### Validation rules are shared between SDK and world
357
369
 
358
- Validation lives in a single helper exported from `@workflow/world` (`validateAttributeChanges`, `validateAttributeKey`, `validateAttributeValue`). Both the SDK `experimental_setAttributes` helper and the `world-local` / `world-postgres` implementations call it; the `world-vercel` backing service applies the same rules independently. The shared module is the authoritative spec for the limits (256-char keys, 256-byte values, max 64 attributes per run, `$`-prefixed keys reserved) — any future change goes through one file.
370
+ Context-free validation lives in the exported Zod schemas (`AttributeKeySchema`, `AttributeValueSchema`, `AttributeChangeSchema`, and `AttributeChangesSchema`). The schema-free `validateAttributeChanges` helper adds rules that depend on caller context, including the post-merge count and reserved `$` namespace. Both the SDK `experimental_setAttributes` helper and the `world-local` / `world-postgres` implementations call it; the `world-vercel` backing service applies the same rules independently. The shared module remains the authoritative spec for the limits (256-char keys, 256-byte values, max 64 attributes per run, `$`-prefixed keys reserved).
359
371
 
360
372
  ### Run row reconstruction had to thread `attributes` through
361
373
 
@@ -363,6 +375,6 @@ Validation lives in a single helper exported from `@workflow/world` (`validateAt
363
375
 
364
376
  ### Optional world method: feature-detect, warn once
365
377
 
366
- `runs.experimentalSetAttributes` is optional on the `World` interface so community worlds (Redis, MongoDB, Turso, etc.) continue to build and run without adopting the experimental API. The SDK helper feature-detects the method's presence on first dispatch; if absent, it logs a single `console.warn` for the lifetime of the process and resolves silently for that call and all subsequent calls. Users do not need to feature-detect in their own code — calling `experimental_setAttributes` against an unsupporting world is safe but ineffective.
378
+ `runs.experimentalSetAttributes` is optional on the `World` interface so community worlds (Redis, MongoDB, Turso, etc.) continue to build and run without adopting the experimental API. The SDK helper feature-detects the method's presence on first dispatch; if absent, it logs a single `console.warn` for the lifetime of the process and resolves silently for that call and all subsequent calls. Users do not need to feature-detect in their own code: calling `experimental_setAttributes` against an unsupporting world is safe but ineffective.
367
379
 
368
380
  See "Test coverage" above for the full test surface that ships with this change.
@@ -0,0 +1,79 @@
1
+ ---
2
+ title: Batched event writes
3
+ description: An optional World API (events.createBatch) that appends an ordered set of events in one durable write with per-event outcomes, and a suspension fan-out fold that uses it.
4
+ ---
5
+
6
+ # Batched event writes (`events.createBatch`)
7
+
8
+ ## Motivation
9
+
10
+ A workflow suspension that schedules several steps and waits previously wrote one event per entity: one `world.events.create` call per `step_created` and `wait_created`. Against a remote World each write is its own network round trip and its own crash boundary. Batching folds a suspension's schedule into **one durable write** with per-event outcomes, cutting request count and making the whole fan-out land atomically per attempt.
11
+
12
+ ## The World spec addition
13
+
14
+ `Storage['events']` gains one **optional** method:
15
+
16
+ ```ts
17
+ import type {
18
+ BatchEventRequest,
19
+ CreateEventBatchParams,
20
+ EventBatchResult,
21
+ } from '@workflow/world';
22
+
23
+ interface BatchCapableEvents {
24
+ createBatch?(
25
+ runId: string,
26
+ events: BatchEventRequest[],
27
+ params?: CreateEventBatchParams
28
+ ): Promise<EventBatchResult>;
29
+ }
30
+ ```
31
+
32
+ The supporting types, excerpted (canonical definitions live in `@workflow/world`):
33
+
34
+ {/* @skip-typecheck illustrative excerpts of the canonical @workflow/world types */}
35
+ ```ts
36
+ interface BatchEventRequest {
37
+ /** The event: the same discriminated union the single `create` takes. */
38
+ event: CreateEventRequest;
39
+ /** Client event time; under slot identity, the source of the durable createdAt. */
40
+ occurredAt?: Date;
41
+ /** Per-event compute attribution, same as the single create's CreateEventParams. */
42
+ computeInstanceId?: string;
43
+ }
44
+
45
+ type BatchEventItemResult =
46
+ | { status: 200; event: Event; run?: WorkflowRun; step?: Step; wait?: Wait }
47
+ | { status: number; error: string; message: string };
48
+
49
+ interface EventBatchResult {
50
+ /** One entry per submitted event, in request order. */
51
+ results: BatchEventItemResult[];
52
+ }
53
+ ```
54
+
55
+ The contract:
56
+
57
+ - **Ordered**: events land in the run's log in request order at consecutive slots. A concurrent writer may push the whole batch to slots above the caller's view; no skipped-event report accompanies the batch result, so a position-tracking caller compares committed slots against its expectation and reloads to observe what interleaved (its local view stays a strict prefix of the log, never a hole).
58
+ - **Per-event outcomes**: the batch is processed as a whole, and each event reports what its own single `create` would have returned: `200` plus the materialized entity, or the single-path status/code (`409`/`conflict` for an event an earlier delivery already applied). Callers reuse their single-path conflict handling per event.
59
+ - **Idempotent on retry, for entity-conditioned shapes**: creates, terminal transitions, and the born-running pair are each guarded by their own entity condition, so retrying a batch of them that (partially) committed converges to per-event `409`s with nothing written twice. A standalone bare `step_started` or a `step_retrying` re-patches its step instead of converging, so `world-vercel` only auto-retries batches whose every event is retry-convergent (everything the runtime folds today is), and rejects `hook_received` in a batch outright.
60
+ - **Method presence is the capability declaration.** A World that doesn't implement it keeps the single-event path; a World that implements it must make each attempt atomic (a lost race leaves nothing behind). `world-vercel` implements it against `POST /v4/runs/:runId/events/batch` for slot-identity runs with `specVersion >= 6`. `world-local` and `world-postgres` deliberately do not because batching provides no benefit for a local write.
61
+ - **Not batchable** (Worlds reject the request): `run_created`, `run_started`, `run_cancelled`, `hook_created`, `hook_disposed`, `attr_set`, and multiple events targeting one entity, except `step_created` followed by `step_started` for the same step, which creates the step born-running.
62
+
63
+ ## The runtime integration (suspension fan-out fold)
64
+
65
+ **On by default.** The suspension handler folds a **clean fan-out** (the suspension's eager `step_created` and `wait_created` writes) into `createBatch` calls of at most 32 events (mirroring the server's transaction budgets). Chunks of a larger fan-out commit **concurrently**: slot assignment is the World's, so parallel chunks race for slot ranges exactly like the pre-fold path's parallel single writes did, and per-entity conditions, not commit order, carry correctness. The fold only engages when the World implements `createBatch`, the run is on slot identity, and the suspension carries no attribute writes and no resilient step dispatch; everything else keeps the single-event path byte-for-byte. A suspension that also creates or disposes hooks still folds: hook writes are not batchable, so they go through the single-event path **concurrently** with the fold rather than ahead of it.
66
+
67
+ **Per-chunk continuation.** Each chunk's follow-on work starts the moment **that chunk** commits, not when the whole fold does: a chunk's step-execution queue messages publish right off its own commit (publish-after-create holds per step), and only the chunk carrying the inline pairs gates the replay's continuation: trailing chunks' commits and publishes are joined before the invocation can acknowledge its message, so the durability contract ("every create durable before ack") is unchanged.
68
+
69
+ **Pre-claimed inline pairs.** When the fold engages with at least two inline steps, the steps the runtime is about to execute inline join the batch as adjacent `[step_created, step_started]` pairs: the created row carrying the input, the started row a bare ownership-stamped claim the World folds into a born-running create. The pairs commit in a chunk of their own, ahead of the plain `step_created` and `wait_created` chunks, so the write the inline bodies wait for carries only two rows per inline step (a small transaction that commits faster than a full 32-event chunk) while the plain creates commit concurrently beside it. The inline bodies start straight off the pair chunk's commit (in parallel with the queue publishes and the sibling chunks) with no per-step claim POST at all, and a pair that loses its atomic create-claim to a concurrent delivery skips its body exactly as a lost lazy claim does. A lone inline step keeps the optimistic lazy-start path (one row, whose claim overlaps the body) even when eager creates batch beside it: the pairs share no round trip with those creates, so only two or more inline steps make a pair chunk worth the trade. The exception is a lone inline step in a suspension that creates a hook: the runtime never starts a body before its claim settles while a hook is being created, and a lazy claim could only be sent after the hook write committed, so the step's pair is folded instead and its claim commits concurrently with the hook write. A plain partition of exactly one `step_created` or `wait_created` beside the pairs is written through the ordinary single path rather than a one-row batch, and its queue message still waits for that write.
70
+
71
+ Per-event `409`s are tolerated the same way the single path tolerates `EntityConflictError` (a concurrent delivery already created the entity); any other per-event failure fails the suspension write the way a single-path rejection would. A batch carrying a `step_started` (that is, any batch with inline pairs) is **not** retried in-process on a transport blip: a pair's `409` cannot be told apart from the caller's own earlier attempt having committed it, so recovery goes through queue redelivery instead, where the step's ownership stamp routes it back to the same invocation.
72
+
73
+ `createBatch` is optional, so today only the Vercel World folds at all: every other World keeps the single-event path and never sends a pair.
74
+
75
+ **Escape hatch**: Set `WORKFLOW_BATCH_TRANSITIONS=0` (or `false`) to disable batching and restore the exact prior one-write-per-event path. See [`WORKFLOW_BATCH_TRANSITIONS`](/docs/configuration/worlds#workflow_batch_transitions).
76
+
77
+ ## Follow-up
78
+
79
+ The deferred sequential transition (holding `step_completed(N)` across the replay turn and committing `[step_completed(N), step_created(N+1), step_started(N+1)]` as one batch at the next lazy start) builds on this contract and ships separately.