From d46e1c01ab6b3dd36c4f33a3f6862ba22468871e Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Thu, 13 Aug 2026 18:14:38 -0700 Subject: [PATCH 1/3] Set default specVersion to 6 Signed-off-by: Peter Wielander --- .changeset/real-pens-cut.md | 5 +++++ packages/world/src/spec-version.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/real-pens-cut.md diff --git a/.changeset/real-pens-cut.md b/.changeset/real-pens-cut.md new file mode 100644 index 0000000000..8366fa0822 --- /dev/null +++ b/.changeset/real-pens-cut.md @@ -0,0 +1,5 @@ +--- +"@workflow/world": patch +--- + +Set default specVersion to 6 diff --git a/packages/world/src/spec-version.ts b/packages/world/src/spec-version.ts index 21112c7553..0d2e838b4f 100644 --- a/packages/world/src/spec-version.ts +++ b/packages/world/src/spec-version.ts @@ -62,7 +62,7 @@ export const SPEC_VERSION_SUPPORTS_SLOT_IDENTITY = 6 as SpecVersion; * runs it produces. */ export const SPEC_VERSION_CURRENT = - SPEC_VERSION_SUPPORTS_COMPRESSION as SpecVersion; + SPEC_VERSION_SUPPORTS_SLOT_IDENTITY as SpecVersion; /** * The highest spec version this SDK can read. From d7cfaccb0ed46f9c578de297dd80650a8f07e701 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Fri, 14 Aug 2026 09:45:44 -0700 Subject: [PATCH 2/3] Make spec 6 the declared version everywhere, not just the default The constant's own doc argued against the bump this branch makes, so rewrite it around what is now true: slot-numbered event ids are a requirement of the World contract (`requireEventSlot` fails a run whose ids are not positions), which makes 6 both the version a World stamps and the lowest one the runtime can serve. Same for the compatibility range's rationale and the `World.specVersion` docs. world-vercel declared the slot-identity literal rather than the current version. Equal today, but the next bump would raise the runtime's floor past the adapter shipped alongside it and reject it, so declare the current version instead. Document the requirement for World authors: the building-a-world guide never mentioned `specVersion`, and the conformance suite checked that event ids are positions without checking the version declared next to them. --- .../conformance-declared-spec-version.md | 5 ++ .changeset/real-pens-cut.md | 3 +- .changeset/worlds-declare-spec-6.md | 7 +++ docs/content/worlds/v5/building-a-world.mdx | 23 +++++++++ .../core/src/runtime/world-compatibility.ts | 11 +++-- packages/world-testing/src/event-ids.mts | 13 +++++ packages/world-vercel/src/index.ts | 18 ++++--- packages/world-vercel/src/queue.ts | 7 +-- packages/world/src/interfaces.ts | 11 +++-- packages/world/src/spec-version.ts | 48 ++++++++++++------- 10 files changed, 109 insertions(+), 37 deletions(-) create mode 100644 .changeset/conformance-declared-spec-version.md create mode 100644 .changeset/worlds-declare-spec-6.md diff --git a/.changeset/conformance-declared-spec-version.md b/.changeset/conformance-declared-spec-version.md new file mode 100644 index 0000000000..4a6fb2f8ea --- /dev/null +++ b/.changeset/conformance-declared-spec-version.md @@ -0,0 +1,5 @@ +--- +"@workflow/world-testing": patch +--- + +The conformance suite now checks the spec version a World declares, not just that its event ids are positions. diff --git a/.changeset/real-pens-cut.md b/.changeset/real-pens-cut.md index 8366fa0822..d93ac37282 100644 --- a/.changeset/real-pens-cut.md +++ b/.changeset/real-pens-cut.md @@ -1,5 +1,6 @@ --- "@workflow/world": patch +"@workflow/core": patch --- -Set default specVersion to 6 +New runs are created at spec version 6. A World that declares an older spec version is now rejected before the first run rather than failing partway through one. diff --git a/.changeset/worlds-declare-spec-6.md b/.changeset/worlds-declare-spec-6.md new file mode 100644 index 0000000000..e051e4fdda --- /dev/null +++ b/.changeset/worlds-declare-spec-6.md @@ -0,0 +1,7 @@ +--- +"@workflow/world-local": patch +"@workflow/world-postgres": patch +"@workflow/world-vercel": patch +--- + +Declare spec version 6, matching the slot-numbered event ids these Worlds already allocate. diff --git a/docs/content/worlds/v5/building-a-world.mdx b/docs/content/worlds/v5/building-a-world.mdx index fc1024283c..0d0bfef497 100644 --- a/docs/content/worlds/v5/building-a-world.mdx +++ b/docs/content/worlds/v5/building-a-world.mdx @@ -39,6 +39,7 @@ interface WorldCapabilities { } interface World extends Storage, Queue, Streamer { + specVersion: number; capabilities?: WorldCapabilities; start?(): Promise; close?(): Promise; @@ -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. diff --git a/packages/core/src/runtime/world-compatibility.ts b/packages/core/src/runtime/world-compatibility.ts index 247ad73d68..6409a64c6b 100644 --- a/packages/core/src/runtime/world-compatibility.ts +++ b/packages/core/src/runtime/world-compatibility.ts @@ -16,11 +16,12 @@ type WorldSpecVersionMetadata = Pick; * 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 diff --git a/packages/world-testing/src/event-ids.mts b/packages/world-testing/src/event-ids.mts index 1b26b3bff2..4968e70d96 100644 --- a/packages/world-testing/src/event-ids.mts +++ b/packages/world-testing/src/event-ids.mts @@ -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'; @@ -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); diff --git a/packages/world-vercel/src/index.ts b/packages/world-vercel/src/index.ts index 9af8a5db36..224f3091be 100644 --- a/packages/world-vercel/src/index.ts +++ b/packages/world-vercel/src/index.ts @@ -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'; @@ -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 diff --git a/packages/world-vercel/src/queue.ts b/packages/world-vercel/src/queue.ts index cfec381ecd..29f6af2563 100644 --- a/packages/world-vercel/src/queue.ts +++ b/packages/world-vercel/src/queue.ts @@ -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 { readonly contentType = 'application/cbor'; @@ -51,8 +51,9 @@ class CborTransport implements Transport { } /** - * 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 { readonly contentType = 'application/json'; diff --git a/packages/world/src/interfaces.ts b/packages/world/src/interfaces.ts index ed76fd0c00..ea7bb66eea 100644 --- a/packages/world/src/interfaces.ts +++ b/packages/world/src/interfaces.ts @@ -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; diff --git a/packages/world/src/spec-version.ts b/packages/world/src/spec-version.ts index 0d2e838b4f..b6dab76964 100644 --- a/packages/world/src/spec-version.ts +++ b/packages/world/src/spec-version.ts @@ -42,24 +42,34 @@ 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. + * + * 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. * - * 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. + * 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_SLOT_IDENTITY as SpecVersion; @@ -67,11 +77,13 @@ export const SPEC_VERSION_CURRENT = /** * 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; From 00059e2839d2b839f5a1979c5f51392f63bccab4 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Fri, 14 Aug 2026 09:49:35 -0700 Subject: [PATCH 3/3] Merge the spec-6 changesets into one and mark it breaking The three entries described one change from three angles, which reads as three separate release notes. The narrowed World compatibility range is the breaking part: a World declaring an older spec version stops working against this runtime. --- .changeset/conformance-declared-spec-version.md | 5 ----- .changeset/real-pens-cut.md | 10 +++++++--- .changeset/worlds-declare-spec-6.md | 7 ------- 3 files changed, 7 insertions(+), 15 deletions(-) delete mode 100644 .changeset/conformance-declared-spec-version.md delete mode 100644 .changeset/worlds-declare-spec-6.md diff --git a/.changeset/conformance-declared-spec-version.md b/.changeset/conformance-declared-spec-version.md deleted file mode 100644 index 4a6fb2f8ea..0000000000 --- a/.changeset/conformance-declared-spec-version.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@workflow/world-testing": patch ---- - -The conformance suite now checks the spec version a World declares, not just that its event ids are positions. diff --git a/.changeset/real-pens-cut.md b/.changeset/real-pens-cut.md index d93ac37282..0e30561b5b 100644 --- a/.changeset/real-pens-cut.md +++ b/.changeset/real-pens-cut.md @@ -1,6 +1,10 @@ --- -"@workflow/world": patch -"@workflow/core": patch +'@workflow/world': patch +'@workflow/core': patch +'@workflow/world-local': patch +'@workflow/world-postgres': patch +'@workflow/world-vercel': patch +'@workflow/world-testing': patch --- -New runs are created at spec version 6. A World that declares an older spec version is now rejected before the first run rather than failing partway through one. +**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. diff --git a/.changeset/worlds-declare-spec-6.md b/.changeset/worlds-declare-spec-6.md deleted file mode 100644 index e051e4fdda..0000000000 --- a/.changeset/worlds-declare-spec-6.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@workflow/world-local": patch -"@workflow/world-postgres": patch -"@workflow/world-vercel": patch ---- - -Declare spec version 6, matching the slot-numbered event ids these Worlds already allocate.