Skip to content

Commit 54b48ef

Browse files
[world-vercel] Attest the executor's spec version on run_started (#4366)
run_started repeats the spec version the caller of start() stamped, which is older when the run was started from another deployment. Send the version this SDK mints alongside it, so the backend can move such a run onto the event-id scheme this runtime reads before any event is numbered.
1 parent f20be08 commit 54b48ef

10 files changed

Lines changed: 219 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/world-vercel": patch
3+
---
4+
5+
Fix runs started from an older deployment with `deploymentId: 'latest'` failing on a newer one
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/world": patch
3+
---
4+
5+
Classify each spec version as capability-only or structural (`CAPABILITY_ONLY_SPEC_VERSIONS`, `STRUCTURAL_SPEC_VERSIONS`, `crossesStructuralSpecVersion`), so a backend raising a run to its executor's version knows which moves need a fresh log

‎packages/world-vercel/src/events-v4.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,13 @@ interface CreateEventV4InputBase {
225225
* user data (e.g. step_started). */
226226
payload?: Uint8Array;
227227
specVersion: number;
228+
/**
229+
* `run_started` only: the spec version this SDK runs. `specVersion` on
230+
* `run_started` repeats the version the caller of `start()` stamped, which
231+
* can be older when the run was started from another deployment. The server
232+
* uses this to raise such a run to the version this runtime runs.
233+
*/
234+
executorSpecVersion?: number;
228235
correlationId?: string;
229236
vercelId?: string;
230237
/** Compute instance that wrote this event; rides the frame meta by `vercelId`. */
@@ -594,6 +601,9 @@ function buildPostFrameMeta(
594601
eventType: input.eventType,
595602
specVersion: input.specVersion,
596603
};
604+
if (input.executorSpecVersion !== undefined) {
605+
meta.executorSpecVersion = input.executorSpecVersion;
606+
}
597607
if (input.correlationId !== undefined)
598608
meta.correlationId = input.correlationId;
599609
if (input.vercelId !== undefined) meta.vercelId = input.vercelId;

‎packages/world-vercel/src/events.test.ts‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import {
55
type AnyEventRequest,
66
type CreateEventParams,
77
EventSchema,
8+
mintedSpecVersion,
9+
SEALED_LOG_ENV_VAR,
10+
SPEC_VERSION_SUPPORTS_CBOR_QUEUE_TRANSPORT,
11+
SPEC_VERSION_SUPPORTS_SLOT_IDENTITY,
812
} from '@workflow/world';
913
import { decode, encode } from 'cbor-x';
1014
import { ulid } from 'ulid';
@@ -235,6 +239,90 @@ describe('createWorkflowRunEvent with v1Compat', () => {
235239
});
236240
});
237241

242+
/**
243+
* `run_started` attests the spec version this SDK runs, separately from the
244+
* `specVersion` it repeats from the queue message (the stamp of whoever called
245+
* `start()`, possibly an older deployment). The backend raises a run stamped
246+
* lower to this value.
247+
*/
248+
describe('createWorkflowRunEvent executorSpecVersion', () => {
249+
async function postRunStartedAndCaptureMeta(
250+
extraConfig: { mintedSpecVersion?: number } = {}
251+
): Promise<Record<string, unknown> | undefined> {
252+
const agent = mockAgent();
253+
let capturedMeta: Record<string, unknown> | undefined;
254+
agent
255+
.get(ORIGIN)
256+
.intercept({
257+
path: '/api/v4/runs/wrun_1/events/run_started',
258+
method: 'POST',
259+
})
260+
.reply(
261+
200,
262+
(opts: { body?: unknown }) => {
263+
capturedMeta = decodePostedMeta(opts.body);
264+
return runStartedResponse();
265+
},
266+
{
267+
headers: {
268+
'content-type': V4_FRAME_CONTENT_TYPE,
269+
'x-wf-event-id': 'evnt_1',
270+
'x-wf-run-id': 'wrun_1',
271+
'x-wf-created-at': '2026-06-10T00:00:00.000Z',
272+
'x-wf-max-events': '10000',
273+
},
274+
}
275+
);
276+
await createWorkflowRunEvent(
277+
'wrun_1',
278+
{
279+
eventType: 'run_started',
280+
specVersion: SPEC_VERSION_SUPPORTS_CBOR_QUEUE_TRANSPORT,
281+
} as AnyEventRequest,
282+
undefined,
283+
{ token: 'test-token', dispatcher: agent, ...extraConfig }
284+
);
285+
agent.assertNoPendingInterceptors();
286+
return capturedMeta;
287+
}
288+
289+
it("sends the version this SDK mints next to the caller's stamp", async () => {
290+
const meta = await postRunStartedAndCaptureMeta();
291+
expect(meta?.specVersion).toBe(SPEC_VERSION_SUPPORTS_CBOR_QUEUE_TRANSPORT);
292+
expect(meta?.executorSpecVersion).toBe(mintedSpecVersion());
293+
});
294+
295+
it('attests the version the World declared at creation, not a later env read', async () => {
296+
// `createWorld` records what it declared; flipping the kill switch
297+
// in-process afterwards must not make run_started claim more than the
298+
// runtime validated.
299+
vi.stubEnv(SEALED_LOG_ENV_VAR, '1');
300+
try {
301+
const meta = await postRunStartedAndCaptureMeta({
302+
mintedSpecVersion: SPEC_VERSION_SUPPORTS_SLOT_IDENTITY,
303+
});
304+
expect(meta?.executorSpecVersion).toBe(
305+
SPEC_VERSION_SUPPORTS_SLOT_IDENTITY
306+
);
307+
} finally {
308+
vi.unstubAllEnvs();
309+
}
310+
});
311+
312+
it('follows the sealed-log kill switch', async () => {
313+
vi.stubEnv(SEALED_LOG_ENV_VAR, '0');
314+
try {
315+
const meta = await postRunStartedAndCaptureMeta();
316+
expect(meta?.executorSpecVersion).toBe(mintedSpecVersion());
317+
expect(meta?.executorSpecVersion).toBe(
318+
SPEC_VERSION_SUPPORTS_SLOT_IDENTITY
319+
);
320+
} finally {
321+
vi.unstubAllEnvs();
322+
}
323+
});
324+
});
325+
238326
/**
239327
* A replay-context create names the position its decisions were made at:
240328
* `eventCount`, the highest event slot the runtime had loaded. Locks in that it

‎packages/world-vercel/src/events.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
isHookEventRequiringExistence,
5454
type ListEventsByCorrelationIdParams,
5555
type ListEventsParams,
56+
mintedSpecVersion,
5657
type PaginatedResponse,
5758
validateUlidTimestamp,
5859
type WorkflowRun,
@@ -774,6 +775,11 @@ async function createWorkflowRunEventInner(
774775
const input = {
775776
runId: id,
776777
specVersion: data.specVersion ?? 2,
778+
...(data.eventType === 'run_started'
779+
? {
780+
executorSpecVersion: config?.mintedSpecVersion ?? mintedSpecVersion(),
781+
}
782+
: {}),
777783
...(data.correlationId ? { correlationId: data.correlationId } : {}),
778784
...(params?.requestId ? { vercelId: params.requestId } : {}),
779785
...(params?.computeInstanceId

‎packages/world-vercel/src/index.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ export function createWorld(config?: APIConfig): World {
2828
// Use config value first (set correctly by CLI/web), fall back to env var (runtime).
2929
const projectId =
3030
config?.projectConfig?.projectId || process.env.VERCEL_PROJECT_ID;
31+
// Read once: the runtime validates this declaration, and `run_started`
32+
// attests the same value (see `APIConfig.mintedSpecVersion`).
33+
const specVersion = mintedSpecVersion();
34+
config = { ...config, mintedSpecVersion: specVersion };
3135

3236
return {
3337
// The version is what tells the backend which id scheme a run uses: it is
@@ -39,7 +43,7 @@ export function createWorld(config?: APIConfig): World {
3943
// version that introduced slots: a bump has to move this declaration with
4044
// it, or the runtime's compatibility floor rises past the adapter shipped
4145
// alongside it and rejects it (see `assertWorldSupportsRuntimeProtocol`).
42-
specVersion: mintedSpecVersion(),
46+
specVersion,
4347
capabilities: {
4448
hookRetention: { active: true },
4549
// Vercel Queues supports maxConcurrency-limited consumers, which

‎packages/world-vercel/src/utils.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,16 @@ export interface APIConfig {
128128
teamId?: string;
129129
environment?: string;
130130
};
131+
/**
132+
* The spec version this World declared when it was created. Set by
133+
* `createWorld`, which reads `mintedSpecVersion()` once; `run_started`
134+
* attests this value as `executorSpecVersion` so the attestation always
135+
* matches what the runtime validated, even if `WORKFLOW_SEALED_LOG` changes
136+
* in-process afterwards.
137+
*
138+
* @internal
139+
*/
140+
mintedSpecVersion?: number;
131141
}
132142

133143
export const DEFAULT_RESOLVE_DATA_OPTION = 'all';

‎packages/world/src/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ export {
155155
} from './slot-identity.js';
156156
export type { SpecVersion } from './spec-version.js';
157157
export {
158+
CAPABILITY_ONLY_SPEC_VERSIONS,
159+
crossesStructuralSpecVersion,
158160
isLegacySpecVersion,
159161
mintedSpecVersion,
160162
requiresNewerWorld,
@@ -169,6 +171,7 @@ export {
169171
SPEC_VERSION_SUPPORTS_HOOK_FORCE_CLAIM,
170172
SPEC_VERSION_SUPPORTS_SEALED_LOG,
171173
SPEC_VERSION_SUPPORTS_SLOT_IDENTITY,
174+
STRUCTURAL_SPEC_VERSIONS,
172175
} from './spec-version.js';
173176
export type * from './steps.js';
174177
export {

‎packages/world/src/spec-version.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { describe, expect, it } from 'vitest';
2+
import * as specVersions from './spec-version.js';
23
import {
4+
CAPABILITY_ONLY_SPEC_VERSIONS,
5+
crossesStructuralSpecVersion,
36
isLegacySpecVersion,
47
mintedSpecVersion,
58
requiresNewerWorld,
@@ -12,8 +15,49 @@ import {
1215
SPEC_VERSION_SUPPORTS_HOOK_FORCE_CLAIM,
1316
SPEC_VERSION_SUPPORTS_SEALED_LOG,
1417
SPEC_VERSION_SUPPORTS_SLOT_IDENTITY,
18+
STRUCTURAL_SPEC_VERSIONS,
1519
} from './spec-version.js';
1620

21+
describe('spec version classification', () => {
22+
const versionConstants = Object.entries(specVersions).filter(([name]) =>
23+
name.startsWith('SPEC_VERSION_SUPPORTS_')
24+
) as [string, number][];
25+
26+
it.each(
27+
versionConstants
28+
)('%s is classified as capability-only or structural', (_name, version) => {
29+
// A new version constant must be placed in exactly one set: a backend
30+
// raises running runs across capability-only versions, so leaving a
31+
// structural one out of STRUCTURAL_SPEC_VERSIONS is only safe because
32+
// the check is an allow-list, and this keeps the lists honest.
33+
expect(
34+
Number(CAPABILITY_ONLY_SPEC_VERSIONS.has(version)) +
35+
Number(STRUCTURAL_SPEC_VERSIONS.has(version))
36+
).toBe(1);
37+
});
38+
39+
it('treats slot identity and the sealed log as structural', () => {
40+
expect(crossesStructuralSpecVersion(5, 6)).toBe(true);
41+
expect(crossesStructuralSpecVersion(6, 7)).toBe(true);
42+
expect(crossesStructuralSpecVersion(3, 8)).toBe(true);
43+
});
44+
45+
it('lets capability-only versions be crossed', () => {
46+
expect(crossesStructuralSpecVersion(3, 5)).toBe(false);
47+
expect(crossesStructuralSpecVersion(7, 8)).toBe(false);
48+
expect(crossesStructuralSpecVersion(8, 8)).toBe(false);
49+
});
50+
51+
it('treats an unclassified future version as structural', () => {
52+
expect(
53+
crossesStructuralSpecVersion(
54+
SPEC_VERSION_MAX_SUPPORTED,
55+
SPEC_VERSION_MAX_SUPPORTED + 1
56+
)
57+
).toBe(true);
58+
});
59+
});
60+
1761
describe('spec version constants', () => {
1862
it('current spec version is the hook-force-claim version', () => {
1963
expect(SPEC_VERSION_SUPPORTS_SLOT_IDENTITY).toBe(6);

‎packages/world/src/spec-version.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,49 @@ export function mintedSpecVersion(
188188
export const SPEC_VERSION_MAX_SUPPORTED =
189189
SPEC_VERSION_SUPPORTS_HOOK_FORCE_CLAIM as SpecVersion;
190190

191+
/**
192+
* Spec versions whose only effect is to switch on capabilities of a run's
193+
* reader and writer, which is the runtime executing it. A backend may raise
194+
* a running run across these (to its executor's attested version, see
195+
* `executorSpecVersion` on `run_started`), because nothing already in the
196+
* run's log changes meaning.
197+
*
198+
* Every other version is STRUCTURAL: it changes how the log is laid out or
199+
* read (event sourcing itself, slot-numbered event ids, sealed-log
200+
* sequencing), so a run may only be moved across it before any event past
201+
* `run_created` exists. That includes versions not listed here yet: an
202+
* unclassified future version is structural by default, and adding a
203+
* version constant without classifying it fails this package's tests.
204+
*/
205+
export const CAPABILITY_ONLY_SPEC_VERSIONS: ReadonlySet<number> = new Set([
206+
SPEC_VERSION_SUPPORTS_CBOR_QUEUE_TRANSPORT,
207+
SPEC_VERSION_SUPPORTS_ATTRIBUTES,
208+
SPEC_VERSION_SUPPORTS_COMPRESSION,
209+
SPEC_VERSION_SUPPORTS_HOOK_FORCE_CLAIM,
210+
]);
211+
212+
/** The structural spec versions; see {@link CAPABILITY_ONLY_SPEC_VERSIONS}. */
213+
export const STRUCTURAL_SPEC_VERSIONS: ReadonlySet<number> = new Set([
214+
SPEC_VERSION_SUPPORTS_EVENT_SOURCING,
215+
SPEC_VERSION_SUPPORTS_SLOT_IDENTITY,
216+
SPEC_VERSION_SUPPORTS_SEALED_LOG,
217+
]);
218+
219+
/**
220+
* Whether moving a run from spec version `from` up to `to` crosses a
221+
* structural version, and so is only allowed while the run's log holds
222+
* nothing but `run_created`. See {@link CAPABILITY_ONLY_SPEC_VERSIONS}.
223+
*/
224+
export function crossesStructuralSpecVersion(
225+
from: number,
226+
to: number
227+
): boolean {
228+
for (let v = from + 1; v <= to; v++) {
229+
if (!CAPABILITY_ONLY_SPEC_VERSIONS.has(v)) return true;
230+
}
231+
return false;
232+
}
233+
191234
/**
192235
* Check if a spec version is legacy (<= SPEC_VERSION_LEGACY or undefined).
193236
* Legacy runs require different handling - they use direct entity mutation

0 commit comments

Comments
 (0)