Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/real-pens-cut.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@workflow/world': patch
'@workflow/core': patch
'@workflow/world-local': patch
'@workflow/world-postgres': patch
'@workflow/world-vercel': patch
'@workflow/world-testing': patch
---

**Breaking**: New runs are created at spec version 6, and a World that declares an older spec version is now rejected before the first run rather than failing partway through one.
23 changes: 23 additions & 0 deletions docs/content/worlds/v5/building-a-world.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ interface WorldCapabilities {
}

interface World extends Storage, Queue, Streamer {
specVersion: number;
capabilities?: WorldCapabilities;
start?(): Promise<void>;
close?(): Promise<void>;
Expand All @@ -47,8 +48,30 @@ interface World extends Storage, Queue, Streamer {
}
```

`specVersion` is required. See [Declaring the spec version](#declaring-the-spec-version).

The optional `capabilities` object advertises additional behavior. Set `hookRetention.active` to `true` only when the World implements Hook token retention. Note what is *not* in there: [slot-numbered event IDs](#event-id-allocation) are a requirement of this contract, not a capability to opt into. The optional `start()` method initializes background tasks (for example, queue polling). The optional `close()` method releases resources like connection pools and listeners. The optional `getEncryptionKeyForRun()` method returns the AES-256 key used to encrypt data for a run; if it is not implemented, encryption is disabled.

### Declaring the spec version

`specVersion` is the protocol version your World implements, and the version stamped on every run it creates. Export `SPEC_VERSION_CURRENT` from `@workflow/world` rather than writing a number:

{/* @skip-typecheck - partial World, the other members are elided */}
```typescript
import { SPEC_VERSION_CURRENT } from '@workflow/world';

export function createWorld(): World {
return {
specVersion: SPEC_VERSION_CURRENT,
// ...
};
}
```

The runtime checks this before it creates or replays anything and throws if the version is outside the range it supports, naming both the range and what your World declared. A version below the range means your World allocates event IDs the runtime cannot read positions out of; above it means your World speaks a protocol this runtime has not learned.

Using the constant is what keeps that check passing across upgrades: it moves with the `@workflow/world` version your package resolves, so a spec bump raises your declaration and the runtime's requirement together. A hard-coded number leaves your World a version behind the next bump, and the runtime rejects it. Keep `@workflow/world` in the same release channel as the `workflow` version your users install.

## The Event Log Model

Workflow storage is built on an **append-only event log**. All state changes happen through events — you never modify runs, steps, or hooks directly. Instead, you create events that update the materialized state.
Expand Down
11 changes: 6 additions & 5 deletions packages/core/src/runtime/world-compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ type WorldSpecVersionMetadata = Pick<World, 'specVersion'>;
* ceiling means a World built against a newer spec than this runtime knows how
* to read.
*
* The range has a floor and a ceiling rather than a single value because a
* World may opt into a spec version above the default: `world-vercel` declares
* the slot-identity version so its new runs are created with slot event ids,
* while every other World stays on the default. An equality check would make
* this runtime refuse the adapter shipped alongside it.
* Both bounds are the same version today, so this currently admits exactly one.
* It stays written as a range because the two constants answer different
* questions and come apart while a spec bump is staged: the ceiling rises when
* this runtime learns to read the next version, the floor when that version
* becomes the one Worlds stamp. An equality check against either constant alone
* would reject a World during that window.
*/
export function assertWorldSupportsRuntimeProtocol(
world: WorldSpecVersionMetadata
Expand Down
13 changes: 13 additions & 0 deletions packages/world-testing/src/event-ids.mts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
eventIdToSlot,
FIRST_EVENT_SLOT,
SPEC_VERSION_CURRENT,
SPEC_VERSION_MAX_SUPPORTED,
slotToEventId,
} from '@workflow/world';
import { expect, test, vi } from 'vitest';
Expand Down Expand Up @@ -35,6 +37,17 @@ export function eventIds(world: string) {
{ interval: 200, timeout: 25_000 }
);

// The version the World declared, stamped on the run it created. Slot ids
// and this number have to agree: the World allocates the positions, but the
// number is what a backend reads to decide which scheme a run uses, and
// what the runtime checks before it replays anything. A World that numbers
// its events correctly while declaring an older version is rejected at
// startup, which reads as a broken install rather than as a stale
// constant. Declaring `SPEC_VERSION_CURRENT` moves it with the runtime.
const run = await server.getRun(result.runId);
expect(run.specVersion).toBeGreaterThanOrEqual(SPEC_VERSION_CURRENT);
expect(run.specVersion).toBeLessThanOrEqual(SPEC_VERSION_MAX_SUPPORTED);

const events = await server.getEvents(result.runId);
expect(events.length).toBeGreaterThan(0);

Expand Down
18 changes: 11 additions & 7 deletions packages/world-vercel/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { World } from '@workflow/world';
import { SPEC_VERSION_SUPPORTS_SLOT_IDENTITY } from '@workflow/world';
import { SPEC_VERSION_CURRENT } from '@workflow/world';
import { createAnalytics } from './analytics.js';
import { createRunId, describeRun } from './create-run-id.js';
import { createGetEncryptionKeyForRun } from './encryption.js';
Expand Down Expand Up @@ -30,12 +30,16 @@ export function createWorld(config?: APIConfig): World {
config?.projectConfig?.projectId || process.env.VERCEL_PROJECT_ID;

return {
// Spec v6 adds slot-numbered event ids on top of v5's client-side
// zstd/gzip payload compression. The version is what tells the backend
// which id scheme a run uses: it is stamped on `run_created` and read back
// on every later write, so a run created before v6 keeps its ULIDs even
// though this adapter now asks for slots.
specVersion: SPEC_VERSION_SUPPORTS_SLOT_IDENTITY,
// The version is what tells the backend which id scheme a run uses: it is
// stamped on `run_created` and read back on every later write, so a run
// created before spec 6 keeps its ULIDs for its whole life even though this
// adapter now asks for slot-numbered ids.
//
// Declared as the runtime's current version rather than as the literal
// version that introduced slots: a bump has to move this declaration with
// it, or the runtime's compatibility floor rises past the adapter shipped
// alongside it and rejects it (see `assertWorldSupportsRuntimeProtocol`).
specVersion: SPEC_VERSION_CURRENT,
capabilities: {
hookRetention: { active: true },
// Vercel Queues supports maxConcurrency-limited consumers, which
Expand Down
7 changes: 4 additions & 3 deletions packages/world-vercel/src/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { isWsEventsTransportEnabled } from './ws-transport-enabled.js';
* decodes on receive, preserving Uint8Array values natively (workflow
* input is a Uint8Array in specVersion >= 2).
*
* Used for specVersion >= SPEC_VERSION_CURRENT (3).
* Used for specVersion >= SPEC_VERSION_SUPPORTS_CBOR_QUEUE_TRANSPORT.
*/
class CborTransport implements Transport<unknown> {
readonly contentType = 'application/cbor';
Expand All @@ -51,8 +51,9 @@ class CborTransport implements Transport<unknown> {
}

/**
* JSON-based queue transport. Used for specVersion < SPEC_VERSION_CURRENT
* to maintain compatibility with older deployments that expect JSON messages.
* JSON-based queue transport. Used for specVersion <
* SPEC_VERSION_SUPPORTS_CBOR_QUEUE_TRANSPORT to maintain compatibility with
* older deployments that expect JSON messages.
*/
class JsonTransport implements Transport<unknown> {
readonly contentType = 'application/json';
Expand Down
11 changes: 8 additions & 3 deletions packages/world/src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,10 +449,15 @@ export interface World extends Queue, Streamer, Storage {
analytics?: Analytics;

/**
* The Workflow protocol spec version this World implements.
* The Workflow protocol spec version this World implements, and the version
* stamped on every run it creates.
*
* Current runtimes require this to exactly match their
* `SPEC_VERSION_CURRENT` before they create or replay runs.
* Declare `SPEC_VERSION_CURRENT` rather than a literal. The runtime checks
* this against `[SPEC_VERSION_CURRENT, SPEC_VERSION_MAX_SUPPORTED]` before it
* creates or replays anything, and refuses a World outside that range: below
* the floor the World allocates event ids the runtime cannot read positions
* out of (see the event log contract above), above the ceiling it speaks a
* spec this runtime has not learned.
*/
specVersion: number;

Expand Down
50 changes: 31 additions & 19 deletions packages/world/src/spec-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,36 +42,48 @@ export const SPEC_VERSION_SUPPORTS_COMPRESSION = 5 as SpecVersion;
* and the spec version stamped on `run_created` is what carries it. A run
* created before the backend adopted slots stays on ULIDs for its whole life
* because its stamped version is below this one.
*
* Slots are no longer optional for a World: the runtime reads a position out
* of every event id it loads (`requireEventSlot`) and fails the run if it
* cannot. That makes this version the lowest one this runtime can serve at
* all. See `SPEC_VERSION_CURRENT`.
*/
export const SPEC_VERSION_SUPPORTS_SLOT_IDENTITY = 6 as SpecVersion;

/**
* Current spec version (event-sourced architecture with native attributes
* and compressed payloads).
* Current spec version: event-sourced architecture with native attributes,
* compressed payloads and slot-numbered event ids.
*
* This is both the version a World stamps on the runs it creates and the
* *lowest* one this runtime accepts from a World (see
* `assertWorldSupportsRuntimeProtocol`). The two coincide because slot
* numbering is a requirement of the World contract rather than a capability to
* opt into: a World declaring anything below this allocates event ids the
* runtime cannot read positions out of, so admitting it would only move the
* failure from startup to the middle of a run.
*
* Deliberately NOT bumped for slot-numbered event ids. Slot numbering is a
* property of a run's whole log rather than of an individual event, and it is
* already self-describing: a run's scheme is readable from the shape of its
* own first event id (see `isSlotEventId`), so a World that owns its own id
* allocation needs no version negotiation to pin one. Bumping this constant
* would stamp the new version on every World
* including ones that have not adopted slots yet, which is exactly the
* cross-version breakage the pin exists to avoid. A World that does allocate
* slots declares the higher version itself (see `world-vercel`), and
* `SPEC_VERSION_MAX_SUPPORTED` is what keeps this reader from rejecting the
* runs it produces.
* A World therefore declares this constant rather than a literal, so a bump
* moves the declaration and the floor together. Pinning a literal would leave
* the adapter one version behind the next bump and get it rejected by the
* runtime it ships alongside.
*
* Bumping this does not touch runs already created: their stamped version is
* persisted, every version test in the runtime is `>=`, and a World resolves a
* run's identity scheme from what is stored rather than from this constant.
*/
export const SPEC_VERSION_CURRENT =
SPEC_VERSION_SUPPORTS_COMPRESSION as SpecVersion;
SPEC_VERSION_SUPPORTS_SLOT_IDENTITY as SpecVersion;

@vercel vercel Bot Aug 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unit test in spec-version.test.ts still asserts SPEC_VERSION_CURRENT === SPEC_VERSION_SUPPORTS_COMPRESSION (5), but the constant was intentionally bumped to 6, so the assertion fails and breaks the @workflow/world test suite.

Fix on Vercel


/**
* The highest spec version this SDK can read.
*
* Distinct from `SPEC_VERSION_CURRENT`, which is the *default* a World stamps
* on runs it creates. A World may declare a higher version than the default,
* so the "was this run made by a newer SDK?" test has to be against the
* ceiling: comparing against the default would make the SDK reject runs its
* own adapters just created.
* Kept distinct from `SPEC_VERSION_CURRENT` even though the two are equal
* today. They answer different questions, "what do we write?" versus "what can
* we still read?", and they come apart in the release order a spec bump
* follows: a reader that can already handle the next version raises this
* ceiling first, and `SPEC_VERSION_CURRENT` follows only once the version is
* safe to stamp. Collapsing them into one constant would make that staging
* impossible to express.
*/
export const SPEC_VERSION_MAX_SUPPORTED =
SPEC_VERSION_SUPPORTS_SLOT_IDENTITY as SpecVersion;
Expand Down
Loading