diff --git a/.changeset/real-pens-cut.md b/.changeset/real-pens-cut.md new file mode 100644 index 0000000000..0e30561b5b --- /dev/null +++ b/.changeset/real-pens-cut.md @@ -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. 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 21112c7553..b6dab76964 100644 --- a/packages/world/src/spec-version.ts +++ b/packages/world/src/spec-version.ts @@ -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; /** * 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;