Upgrading a World to v5
Port a custom World implementation from the v4 spec to v5, including the interface delta, the contract changes, and the new event ID allocation rules.
This page is for people who implement the World interface themselves, or who maintain a build integration that compiles workflow files. It covers what changed between the v4 and v5 spec, which of those changes break an existing implementation, and what new surface is worth adopting.
Three things are required to be a v5 World: the interface changes, the contract changes, and event ID allocation. The last one is the largest piece of work and the only one that is not visible from the type signatures. Everything under new optional surface can wait.
If your application runs on the Vercel, Local, or Postgres World, you need nothing from this page. Those implementations ship with the SDK and are already on the v5 spec. For the application-facing changes, see What's new in v5.
The fastest way to start is to install the World migration skill and hand the job to an agent:
This is a different skill from migrating-workflow-v4-to-v5, which upgrades the application code. If the same repository does both, run the application one first.
If you would rather work from the diff directly:
We're working on bringing back World compatibility tests and reporting on the Worlds page, to make it easier to see which Workflow versions each World is compatible with.
Spec versions
A World declares the protocol version it speaks on specVersion, and that number is stamped on every run it creates. Declare mintedSpecVersion() from @workflow/world, not a literal:
In v4 the runtime required that number to equal its own current version exactly. In v5 it checks the declaration against a range before it creates or replays anything, and refuses a World outside it with an error naming both the range and what your World declared. The floor is the version that introduced slot-numbered event IDs, because a World below it allocates IDs the runtime cannot read positions out of, and admitting one would only move the failure from startup into the middle of a run. The ceiling is the highest version this runtime can read.
mintedSpecVersion() is a function rather than a constant because the version a World stamps is a deployment-level choice. It answers with the sealed-log version by default, and with the slot-identity version when WORKFLOW_SEALED_LOG=0 opts new runs out. Both sit inside the accepted range, so either answer is a valid declaration. Reading it per createWorld() call rather than once at module load is what lets a single process create Worlds in both modes.
Calling it is also what keeps the check passing across upgrades, since it moves with the @workflow/world version your package resolves. A hard-coded number leaves your World a version behind the next bump and gets it rejected by the runtime it ships alongside. That includes the constants: SPEC_VERSION_CURRENT and SPEC_VERSION_SUPPORTS_SLOT_IDENTITY are literals by another name for this purpose, since neither follows the sealed-log setting. Keep @workflow/world in the same release channel as the workflow version your users install.
Sealed logs and noop events
The sealed-log version exists for a World whose store makes allocating a position at the commit a contention bottleneck. Such a World may hand positions out from a per-run counter before the commit, so concurrent writers never race for one, and then restore density at read time by writing a noop event into any position it can prove was abandoned. A noop occupies its position and means nothing: replay steps over it without delivering it and without advancing the deterministic clock.
Two consequences for an implementation:
- If you allocate at the commit, you are already compliant and have nothing to build. No write can leave a position empty, so you have no holes to seal and will never emit a
noop.@workflow/world-localand@workflow/world-postgresare in this position. - What the version actually gates is the reader. A run stamped at the sealed-log version can only be replayed by a reader that knows to skip
noop. That is every runtime on this release train, but a runtime pinning its own accepted range separately, such as the Python runtime, has to catch up first.WORKFLOW_SEALED_LOG=0is the switch for an environment where it has not.
Runs carry a spec version too, and a run keeps the version it was created under for its whole life. Read the stamped version off the run rather than assuming every run matches what your World declares today. Changing what you stamp does not reach runs already in your store: their version is persisted, every version test in the runtime is a lower bound, and a run's event ID scheme is resolved from what is stored.
Interface changes
These break an existing v4 implementation. Each one is a signature or module-shape change your World has to follow.
| Change | What to do |
|---|---|
getWorld() and createWorld() are async | await getWorld(). This already worked in 4.x, so it is safe to write before upgrading. See getWorld. |
Stream methods moved to world.streams.*, with runId first | writeToStream(name, runId, chunk) becomes streams.write(runId, name, chunk); likewise writeToStreamMulti → streams.writeMulti, closeStream → streams.close, readFromStream → streams.get, getStreamChunks → streams.getChunks, listStreamsByRunId → streams.list. |
world.steps.get() requires runId | The first argument is no longer string | undefined. Pass the run ID that owns the step. |
events.listByCorrelationId() requires runId | A correlation ID identifies a step, hook, or wait within its run, not across runs, so the lookup is scoped to one run. Pass the run that owns the correlation ID. The same applies to analytics.events.listByCorrelationId(). A World that paginates by event ID also needs the scope in its cursor comparison because two runs can hold the same correlation ID. |
createLocalWorld() and createVercelWorld() removed | Export a createWorld() factory from your package instead, matching the first-party Worlds. The arguments are unchanged. |
| Worlds are injected into host bundles at build time | Selection is static rather than resolved dynamically at runtime. Verify your World still resolves after the upgrade, and that its module graph survives bundling. |
@workflow/world-local stream chunks moved | Chunks live at streams/chunks/<streamName>/. Files written in the old flat layout are not read back, so local development state from 4.x can be deleted. Only relevant if your World inherited that layout. |
Contract changes
These do not change any signature, so an implementation ported by types alone will compile and then behave incorrectly.
Suspension and dispatch. The asymmetric { timeoutSeconds } wait-return contract is gone. A wait is now an ordinary queue continuation with delaySeconds, and a suspension dispatches its waits and its steps as one parallel batch. A queue that assumed one message per suspension needs to handle the batch.
Step queue topics are retired. The 'step' queue kind no longer exists. Queued steps travel on the workflow topic, carrying stepId and stepName in the payload, and execute in the combined flow handler. A World that provisioned separate __wkf_step_* topics can drop them.
Capabilities fail closed. The optional capabilities object advertises behavior the runtime otherwise assumes is absent. An unadvertised capability costs performance, never correctness, so a partial World stays correct while it catches up. The reverse is not true: advertising something you do not enforce removes a guard the runtime was relying on. Only set a flag once the behavior is implemented.
A stale replay no longer has to be refused. v5 shipped with a preconditionGuard capability for a World that rejected an event creation whose snapshot was behind the log. It is gone, and nothing replaced it: allocating positions at the commit means a reader's log is a prefix rather than a prefix with a hole, replay is deterministic on a prefix, and a write reports the events it was pushed past. As a result, a stale replay costs a merge instead of a rejection. If you implemented the guard, you can delete it. PreconditionFailedError and the runtime's handling of it remain for a World that allocates positions away from the commit (see Event ID allocation); no World in the SDK throws it.
Process-wide state has to live on globalThis. A module's top-level const or let is one instance per module instance, not per process, and a host server routinely holds several. Next.js compiles its server output into independent module graphs, and a bundled module is compiled into each one with its own module-scope bindings. Since @workflow/world-vercel moved from external to bundled, every module-scope singleton in it quietly became one per layer. The visible casualty was the WebSocket events transport: the queue consumer registered its channel in the route copy's registry while the write path looked it up in the instrumentation copy's empty one, so every event silently fell back to HTTP for the life of the process.
This bites rather than merely wasting memory because the runtime caches the World object process-wide while any module state that World closes over stays layer-local. Anything your World reaches at request time therefore has to be process-wide too: connection pools, transport registries, ID factories, caches, and log-once latches. Hold them in one object behind globalSingleton() from @workflow/utils, which keys the object off a Symbol.for on globalThis. A let cannot be shared by reference, so a latch becomes a field on that object.
One World per process. The workflow entrypoint's queue handler is now built from the runtime World that getWorld() returns, rather than from getWorldHandlers(). A stateful World is no longer instantiated twice in one process, so it stops getting duplicate connection pools and duplicate queue workers. If you added your own de-duplication to work around that, it is now redundant, though harmless if it keys on process-wide state.
Your own transport is your own business, except for the tracing. How a World ships events to its backend is unconstrained: @workflow/world-vercel uses a WebSocket by default and falls back to HTTP. What is constrained is what a reader of a trace sees. A non-HTTP transport still has to emit the per-event client span that an HTTP write would, or the per-event view of a run silently disappears. See WORKFLOW_EVENTS_TRANSPORT for the span shape and attributes the Vercel World uses, including a separate span for the handshake.
Replay reads the event log with resolveData: 'skip-step-inputs'. A World may leave input out of step_created and step_started events for this value, and must otherwise treat it as 'all'. A World that tests resolveData === 'all', or validates against ['none', 'all'], strips step results or rejects the read, and every replay fails. Test for 'none' instead, or map with entityResolveData() from @workflow/world. @workflow/world-testing covers this case.
Event creation can return a delta. events.create() may return events alongside the one it created, in events with a matching cursor and hasMore. The runtime uses this to skip a follow-up events.list round trip on run_started, on step-terminal writes that carried a sinceCursor, and on hook_received writes that carried preloadEvents. All three are advisory: a World that returns only the created event stays correct and pays one more round trip.
Event ID allocation
This is the largest change for a World implementation, and it is required.
In v4 an event ID was a ULID your World minted however it liked. In v5 an event ID is its slot: evnt_ followed by the event's 1-based position in that run's log, zero-padded to 26 characters, so a run's first event is evnt_00000000000000000000000001. Format one with slotToEventId() from @workflow/world.
There is no capability to declare and no fallback path. The runtime reads a position out of every ID it loads and fails the run when it cannot. A World whose IDs are not positions will pass a type check and start runs, but it cannot replay a single workflow. The first replay fails with Event id is not slot-numbered.
The scheme exists for what a reader can conclude from a log it just fetched: positions are dense, so a truncated log is distinguishable from a complete one by its length alone. The runtime relies on that, and it fails a run with CORRUPTED_EVENT_LOG rather than replay across a hole, so four rules bind an implementation:
-
Uniqueness. Settle a race for a position where the store settles it, with a unique constraint on
(runId, eventId)or a conditional write, not by reading the maximum in your own process and adding one. -
Density. Positions run from 1 with no holes. A writer that loses a race re-derives its position from the store instead of incrementing a local number, which would leave a permanent hole.
-
Bump and report.
events.create()params carryeventCount, so the expected position iseventCount + 1. When it is taken, do not reject the write: commit at the next free position and return the events you skipped on the success response. A stale count is the normal case for a parallel fan-out, and rejecting it would serialize writes the runtime deliberately issues concurrently. -
Allocate at the commit. Take the position in the same operation that appends the event, not earlier. This is what makes a reader's log a prefix of the run's log rather than a prefix with a hole in it: nothing can land behind a position a reader has already passed. A World that mints a position in a request handler and commits later breaks the property every replay depends on, and is the only kind that still has a use for a stale-write rejection.
The one sanctioned exception is the sealed log, which is what the sealed-log spec version is for: a World may pre-assign positions if it also seals the holes that leaves. Everything below assumes you allocate at the commit, which is the simpler contract and the one both first-party non-Vercel Worlds keep.
Event ID Allocation carries the full rules, and Event IDs covers what the format means for anything that reads an ID back.
One consequence is specific to an upgrade, and it is the thing to plan around.
Runs already in your store cannot be replayed by the new code. A ULID-numbered run is not readable as positions, and the runtime refuses it rather than guessing, so there is no mixed-scheme mode and no per-run fallback. Drain those runs on your 4.x build before deploying a v5 World, or accept that the ones still in flight will fail. On a platform such as Vercel, where a run executes on the deployment that created it, this resolves itself: those runs finish on the build that started them and never meet the new code. Anywhere a single deployment serves every run, sequencing matters.
New optional surface
None of this is required. Each entry is a hook the runtime uses if your World provides it, and routes around if it does not.
| Member | What it buys |
|---|---|
capabilities | Advertises hookRetention.active, hookResumeDedup, hookForceClaim, deploymentAffinity, maxConcurrency, and dynamicWorkflowCode. See the contract note above about failing closed. Event ID allocation is not in here: it is a requirement, not a capability. |
events.createBatch | Appends an ordered list of events in one durable write, with a per-event outcome for each. Implementing the method is the declaration: the runtime folds a suspension's step_created / wait_created writes into batches only when it exists, and otherwise takes the single-event path unchanged. Implement it with real atomicity per attempt, so a lost race leaves nothing behind, or leave it out. See Batched event writes. |
runs.waitForTerminalStatus | Long-polls until a run reaches a terminal status. await run.returnValue uses it when present, instead of polling on an interval. |
analytics | A metadata-only read namespace for observability surfaces. Payload-bearing reads stay on runs, steps, events, and hooks. |
runs.experimentalSetAttributes | Backs setAttributes() from application code. Without it, run attributes are unavailable. |
runs.cancelMany | Bulk cancellation: up to 500 unique run IDs per request (BULK_CANCEL_MAX_RUN_IDS), an optional cancelReason of at most 512 characters, and a per-run outcome for every ID. Without it, the runtime falls back to bounded-concurrency individual cancels. |
getRuntimeDeadline() | The absolute time the current invocation will be terminated. The runtime derives its inline replay budget from this, so a host with a long function timeout gets more work per invocation. Without it, the budget is a flat two minutes. See WORKFLOW_V2_TIMEOUT_MS. |
getEnvironment() | The environment your World's writes are attributed to. Must be synchronous, side-effect free, and match what the backend will actually apply. A wrong answer is worse than undefined, because callers use it to detect cross-environment mismatches. Worlds with a single tenant should omit it. |
createRunId(options) | Mints the bare run ID; the core adds the wrun_ prefix. Return a valid ULID. You may embed World-specific metadata in it, as @workflow/world-vercel does with a region identifier. Read only the option keys you recognize and ignore the rest. |
describeRun(run) | World-specific display fields for observability surfaces. |
getEncryptionKeyForRun() | Returns a ready-to-use 32-byte AES-256 key. Without it, data is stored unencrypted. Two overloads: pass the WorkflowRun when you have it, or a runId plus opaque World context when the entity is not available locally. |
resolveLatestDeploymentId() | Resolves deploymentId: 'latest'. Only meaningful for Worlds where deployment routing exists. |
close() | Releases connection pools and listeners so CLI commands and short-lived processes can exit without process.exit(). |
streams.streamFlushIntervalMs | Sets the stream flush window. The v5 default is 0, so the first chunk flushes immediately; set a value to coalesce writes again. |
| Hook token retention | Hook.tokenRetentionUntil marks the earliest time a token may become available after its run ends. Keep the owning run readable at least that long, and honor hook_disposed as an immediate release. Declare capabilities.hookRetention.active only once this is implemented, since the runtime otherwise rejects retained hooks before registration. |
| Hook resume dedup | resumeHook() writes hook_received and dispatches the queue message in parallel when the backend collapses concurrent writes carrying the same (runId, resumeId) onto one committed event. Declare capabilities.hookResumeDedup only if you enforce that constraint and events.list() returns the committed event's top-level resumeId. A World that omits either guarantee must leave the flag unset, which keeps the sequential path. See Durable hook resume. |
| Hook token takeover | Backs createHook({ experimental_force: true }). When a forced hook_created names a token another live run holds, append hook_disposed with forceClaimedBy: { runId, hookId } to the holder's log and refuse later hook_received writes to it, then re-point the token to the claimer and record Hook.claimedFrom. Refuse the takeover with an ordinary hook_conflict carrying forceRefusedReason: 'victim-spec-version' when the holder was stamped below spec version 8, and answer a hook_received refused by a takeover with HookForceClaimedError so resumeHook() can follow the token. Declare capabilities.hookForceClaim only once you give these guarantees. Without it, the runtime fails a forced createHook() at registration. |
If you also maintain a build integration
Compiling workflow files changed independently of the storage contract.
| Change | What to do |
|---|---|
The client SWC transform mode was removed | It merged into step mode. Integrations passing mode: 'client' pass mode: 'step'. |
stepEntrypoint removed from workflow/runtime | Steps execute through the combined workflow handler the framework integrations generate. A custom host builds that handler from the World getWorld() returns. getWorldHandlers() still exists for the build-time view of a World, which is what a build integration wants; it is no longer how a request-time handler is assembled. |
| Step, workflow and webhook bundles are ESM | Generated output moved from CJS to ESM, with a createRequire banner for CJS dependencies. The VM-executed workflow bundle stays CJS. The CLI's standalone output is renamed to match: flow.mjs, webhook.mjs, and __step_registrations.mjs in place of flow.js, webhook.js, and step.js. Consumers import the namespace rather than a default. |
workflow/internal/private and @workflow/core/private removed | These were never public API. The compiler no longer emits imports from them, so regenerate build output rather than importing them yourself. |
| Duplicate step or workflow IDs fail the build | 4.x resolved collisions across non-exported workspace files last-write-wins. A build integration that derived IDs from a partial path may now produce build failures. |
Verifying the upgrade
Run a workflow end to end against your World, not just the type checker. The contract changes above compile cleanly and fail at runtime.
The cases worth covering explicitly:
- A run that suspends on a step and one that suspends on a wait, to exercise the batched dispatch.
- A parallel fan-out, so concurrent
events.create()calls race on the same position or the same precondition. - A hook resumed after its run has already progressed, and a hook whose token is retained past the end of its run.
- A stream written and read back, including a stream closed before the reader attaches.
- A run created under an older spec version, if your World has any, read back by the new code.
@workflow/world-testing is the shared suite the first-party Worlds run, and it now covers event ID allocation directly: numbers events by position fails a World whose IDs do not decode to slots, whose run is not dense from 1, or whose IDs are not in canonical form. A World padding to a different width sorts its own log incorrectly past ten events. Run it against your World before the end-to-end cases above; it turns the failure that would otherwise appear on a first replay into one line of test output.
The first-party implementations in packages/world-local and packages/world-postgres are the reference for everything else, and their test suites are the closest thing to full conformance while the compatibility tests are being rebuilt.