Skip to content

Cross-deployment start() stamps the starter's spec version, not the executing deployment's #4251

Description

@pranaygp

Problem

A run's specVersion is chosen in start() (packages/core/src/runtime/start.ts: const specVersion = opts.specVersion ?? world.specVersion) and written on run_created and the queue message. That is the spec version of the SDK calling start().

When start() is called without a deploymentId, the run executes on the same deployment, so the caller's version is the executor's version and everything is consistent.

When start({ deploymentId }) targets another deployment, the run is stamped with the caller's spec version even though a different deployment — possibly on an older or newer @workflow/core — will read and write its event log. Every consumer of the spec version then reasons about the wrong party:

  • Reader gates that decide "does this run's runtime understand row X?" from run.specVersion (sealed-log noop, slot identity, and now the hook_disposed{forceClaimedBy} victim gate from feat(hooks): createHook({ experimental_force: true }) takes a held token over #4193 / vercel/workflow-server#980) can be wrong for cross-deployment runs: a new starter stamps ≥ N onto a run executed by an older deployment that does not read N.
  • Writer behaviour keyed on the stamped version (compression ≥ 5, CBOR queue transport, attributes) is chosen by the starter but has to be honoured by the executor.

Deployments are pinned on Vercel, so there is no rolling-version window within one deployment; the discrepancy exists specifically between two different deployments.

Proposed fix

The executing deployment should determine the run's spec version. Options, roughly in order of preference:

  1. start() already probes the target deployment via the health check for a cross-deployment start (healthCheck(world, { deploymentId, ... }) in start.ts; the response carries specVersion and workflowCoreVersion, parsed in runtime/helpers.ts). Use the probed specVersion (capped at what this SDK and the World can mint) instead of world.specVersion when deploymentId !== currentDeploymentId. Same-deployment starts are unchanged. If the probe fails or the target predates a versioned health response, fall back to the lowest version both sides are known to support rather than the caller's current version.
  2. Alternatively let the target's run_started (written by the executor) settle/override the version, with Worlds treating run_created's version as provisional until then. Larger change; touches every World.

Either way, add mixed-version tests: new starter → older executor and older starter → newer executor, for the sealed-log and hook-force-claim reader gates.

Context

Surfaced while gating createHook({ experimental_force }) victims on specVersion >= 8 (#4193). An intermediate revision replaced that gate with a per-run executionContext.hookForceClaimReaderVersion attestation stamped from the probe (same pattern as hookResumeInputVersion); it was reverted to keep a single versioning mechanism and instead fix the stamping here for all gates at once. The reverted commit (0feb2fd on the hook-force-claim branch) shows how the probe value can be threaded into start().

🤖 Generated with Claude Code

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions