Skip to content

feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side - #3244

Merged
pranaygp merged 3 commits into
mainfrom
pgp/runinput-environment-guard
Jul 31, 2026
Merged

feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side#3244
pranaygp merged 3 commits into
mainfrom
pgp/runinput-environment-guard

Conversation

@pranaygp

@pranaygppranaygp commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

The bug

start() makes two writes that have to land in the same tenant:

  1. the run_created event, attributed to whatever environment the caller authenticates as, and
  2. the queue message, pinned to a deployment.

A misconfigured caller can split them — writing the run to one environment while addressing the message to a deployment in another. What happens next is not a failure, it is a fork:

  • the consuming deployment looks for the run under its own tenant and doesn't find it;
  • the backend's resilient start (run_started creates the run when run_created was never seen) mints a second copy of the same run id in the consumer's environment;
  • both copies are real. The creator's sits pending forever, the consumer's executes, and subsequent cross-tenant queue acks can't find their messages.

This happened on 2026-07-30: a CI harness ran with WORKFLOW_VERCEL_ENV=production while VERCEL_DEPLOYMENT_ID pointed at a preview deployment, and wrun_41KYTFGXEC0GPWVN8C0J0KG9ZK ended up existing in both environments of the same project, with two distinct run_created events 1.18s apart.

The deployment id is not the discriminator — it matched end to end in that incident. The environment is, and nothing on the wire carried it.

The fix

Carry the environment, then check it on the consumer.

  • @workflow/world — optional World.getEnvironment(): string | undefined (sync, side-effect free, styled after createRunId), and an optional environment on RunInputSchema.
  • @workflow/world-vercel — implements it. Both auth paths, one source of truth: with projectConfig (CLI/CI via the api.vercel.com proxy) the backend attributes the write to the x-vercel-environment header, so getEnvironment() and that header now derive from one shared resolveClientEnvironment() helper and cannot drift; without projectConfig (inside a deployment, OIDC) it reads VERCEL_ENV, which the platform mints the token's environment claim from.
  • @workflow/corestart() stamps the value into the queue message's runInput, and the queue handler refuses a delivery whose creator environment disagrees with its own.

Why the rejection is client-side

The consuming process already knows its own environment, so it needs nothing from the server to catch this. More importantly it can refuse before run_started — the write that would create the fork. Any check that happens after that write is too late; refusing before it means the resilient start never creates anything, and the creator's run is left pending in its own environment where a human can find it.

Why it acks instead of throwing

The mismatch is baked into this message: it is pinned to this deployment, and the creator's environment is a field in the payload. Every redelivery would reach the identical verdict, so throwing would hot-loop the handler until MAX_QUEUE_DELIVERIES and end with a misleading max-deliveries failure. Returning normally acks and discards, which is how the handler already treats deliveries whose verdict cannot change (EntityConflictError, RunExpiredError on the run_started path both log and return).

The error log names both environments, the run id, the pinned deployment, and the likely cause (WORKFLOW_VERCEL_ENV for CLI/CI clients, or the OIDC token's environment inside a deployment).

Fails safe, by construction

The check runs only when both sides are known, so nothing that works today changes behavior:

SituationrunInput.environmentworld.getEnvironment()Result
world-local / world-postgres consumeranynot implementedcheck skipped
run started by an older SDKabsentanycheck skipped
Vercel system env vars not exposedpresentundefinedcheck skipped
environments agreeproductionproductionexecutes normally
environments disagreeproductionpreviewrefused, logged, acked

undefined is deliberately preserved rather than defaulted: guessing 'production' in a bare Node process would fabricate a mismatch against a genuine preview deployment. A wrong answer here is worse than no answer.

Secondary: deployment-pinning diagnostic

Alongside it, when runInput.deploymentId and the handler's own getDeploymentId() are both known and differ, the handler logs an error with both ids and the run id — and continues.

Warn rather than refuse, because a differing deployment id is not by itself evidence of the fork. world-local derives its id from the installed package version (dpl_local@<version>), so upgrading the SDK mid-run changes it with nothing wrong; refusing on that signal would strand correct runs. The two checks live side by side and are individually tested, with the environment guard short-circuiting first so a cross-environment delivery produces one clear error rather than two.

Wire compatibility

Verified read-only against workflow-server and the SDK's own schemas.

  • New field, current server.run_started eventData is built field-by-field in runtime.ts, not spread from runInput, so environment never reaches the events API — it exists only in the queue payload. It would have been safe anyway: CreateEventSchemaV2.eventData is z.record(z.any()) and InitialRunAttributesSchema is .passthrough(), so an unknown key is accepted, not rejected.
  • New field, old SDK consumer.RunInputSchema, WorkflowInvokePayloadSchema, and QueuePayloadSchema are all plain z.object — zod strips unknown keys instead of rejecting them, so a message from a new producer is consumed normally by an older deployment mid-rollout (it just skips the check). Pinned by a regression test so nobody makes these strict later.

No deviation from the intended design was needed.

Tests

  • packages/world/src/queue.test.tsRunInputSchema round-trip, absent field, non-string rejection, unknown-key stripping.
  • packages/world-vercel/src/utils.test.tsresolveClientEnvironment matrix, plus an agreement test asserting it always equals the x-vercel-environment header for the same config, so the two can't drift.
  • packages/core/src/runtime/start.test.ts — stamps the environment, omits it when undefined, omits it when the world doesn't implement the hook.
  • packages/core/src/runtime/cross-environment-delivery.test.ts — mismatch produces no events at all (no run_started), acks with 204, logs both environments; refuses on a redelivery too; matching environments execute normally; each skip case (field absent, hook absent, hook returns undefined, re-enqueued message) executes normally; plus the deployment-pinning diagnostic and its ordering against the guard.

pnpm typecheck passes (41/41 packages). @workflow/core 1671 passed + 3 expected-fail, @workflow/world 84 passed, @workflow/world-vercel 322 passed.

pnpm lint reports 2 pre-existing errors on main (nested biome.json files declaring $schema 2.0.0 against CLI 2.4.4) — confirmed present on origin/main with these changes stashed, and untouched here.

🤖 Generated with Claude Code

@pranaygp
pranaygp requested a review from ijjk as a code ownerJuly 30, 2026 23:45
CopilotAI review requested due to automatic review settings July 30, 2026 23:45
@pranaygp
pranaygp requested a review from a team as a code ownerJuly 30, 2026 23:45
@changeset-bot

changeset-botBot commented Jul 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 82bd72e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
NameType
@workflow/coreMinor
@workflow/worldMinor
@workflow/world-vercelMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowMinor
@workflow/world-testingPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewJul 31, 2026 6:02pm
example-nextjs-workflow-webpackReadyReadyPreviewJul 31, 2026 6:02pm
example-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-astro-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-express-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-fastify-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-hono-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-nestjs-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-nitro-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-nuxt-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-sveltekit-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-tanstack-start-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-vite-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workflow-docsReadyReadyPreview, v0Jul 31, 2026 6:02pm
workflow-swc-playgroundReadyReadyPreviewJul 31, 2026 6:02pm
workflow-tarballsReadyReadyPreviewJul 31, 2026 6:02pm
workflow-webReadyReadyPreviewJul 31, 2026 6:02pm

@github-actions

github-actionsBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 82bd72e · Fri, 31 Jul 2026 18:22:36 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1317 (+401%) 🔻1402 🔴 (-2.3%)1439 🔴 (-11%)1539 🔴 (-23%) 💚30
TTFSstream1287 (+421%) 🔻1365 🔴 (-2.6%)1421 🔴 (-9.7%)1525 🔴 (-25%) 💚30
TTFShook + stream1582 (+294%) 🔻1690 🔴 (+30%) 🔻1721 🔴 (+27%) 🔻1837 🔴 (+25%) 🔻30
STSO1020 steps (inline)203 (+15%)503 (-2.1%)560 (-3.1%)796 (+1.9%)1016
STSO1020 steps (queue-hop)2365 (+59%) 🔻3496 (+25%) 🔻3496 (+25%) 🔻3496 (+25%) 🔻3
WO1020 steps434394 (+0.5%)434394 (+0.5%)434394 (+0.5%)434394 (+0.5%)1
SLstream latency130 (+48%) 🔻172 🔴 (+8.9%)192 🔴 (+12%)270 🔴 (+37%) 🔻30
SOstream overhead (text)128 (-8.6%)214 (±0%)243 (+7.5%)324 (-35%) 💚30
SOstream overhead (structured)147 (+9.7%)198 (-27%) 💚238 (-32%) 💚310 (-34%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 424581ms → this run 423919ms (Δ -662ms, 0%)

 150-200 ms ┃ main 10 this 0 -10
200-250 ms █████████┃██ main 84 this 67 -17
250-300 ms █████████████████┃ main 123 this 120 -3
300-350 ms ████████████████████┃ main 140 this 144 +4
350-400 ms █████████████████████░░┃ main 141 this 163 +22
400-450 ms ████████████████░░░┃ main 107 this 135 +28
450-500 ms █████████████████┃ main 120 this 125 +5
500-550 ms ███████████████████░┃ main 132 this 140 +8
550-600 ms █████████┃███ main 85 this 68 -17
600-650 ms ███┃█ main 33 this 25 -8
650-700 ms █┃█ main 20 this 13 -7
700-750 ms ┃ main 8 this 5 -3
750-800 ms ┃ main 7 this 3 -4
800-850 ms ┃ main 2 this 1 -1
850-900 ms ┃ main 1 this 2 +1
950-1000 ms ┃ main 0 this 2 +2
1000-1050 ms ┃ main 1 this 1 +0
1050-1100 ms ┃ main 1 this 0 -1
1100-1150 ms ┃ main 0 this 1 +1
1300-1350 ms ┃ main 1 this 0 -1
1600-1650 ms ┃ main 0 this 1 +1

1020 steps (queue-hop)

Cumulative STSO time: main 6430ms → this run 9083ms (Δ +2653ms, +41%)

1000-1500 ms ┃███████████ main 1 this 0 -1
2000-2500 ms ███████████┃ main 1 this 1 +0
2500-3000 ms ┃███████████ main 1 this 0 -1
3000-3500 ms ░░░░░░░░░░░░░░░░░░░░░░░┃ main 0 this 2 +2
📜 Previous results (1)

2308af2

Fri, 31 Jul 2026 00:25:05 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep261 (-74%) 💚1360 🔴 (+20%) 🔻1393 🔴 (+20%) 🔻1431 🔴 (+13%)30
TTFSstream232 (+13%)1365 🔴 (+24%) 🔻1394 🔴 (+23%) 🔻1435 🔴 (-12%)30
TTFShook + stream369 (-72%) 💚1606 🔴 (+12%)1627 🔴 (+11%)1888 🔴 (+15%) 🔻30
STSO1020 steps (inline)178 (-3.3%)492 (-5.2%)544 (-5.4%)737 (-8.8%)1016
STSO1020 steps (queue-hop)2271 (+12%)2910 (-38%) 💚2910 (-38%) 💚2910 (-38%) 💚3
WO1020 steps421233 (-6.6%)421233 (-6.6%)421233 (-6.6%)421233 (-6.6%)1
SLstream latency109 (-2.7%)182 🔴 (+9.6%)208 🔴 (±0%)435 🔴 (+12%)30
SOstream overhead (text)124 (-6.1%)258 🔴 (-9.8%)373 (-24%) 💚2528 🔴 (-8.2%)30
SOstream overhead (structured)110 (-17%) 💚207 (-26%) 💚256 (-34%) 💚344 (-78%) 💚30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7519011328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

CopilotAI left a comment

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.

Pull request overview

Adds a cross-environment “resilient start” guard by stamping the creator’s environment into queued runInput, wiring it through the v4 events meta path, and treating server rejections for environment mismatch as terminal (ack, don’t retry) to prevent the same wrun_ from being created in two different Vercel environments.

Changes:

  • Introduces World.getEnvironment?() and carries an optional environment field through RunInput and run_started event data.
  • Implements environment resolution in @workflow/world-vercel via a shared resolveClientEnvironment() used both for x-vercel-environment and getEnvironment(), and forwards environment through v4 frame meta.
  • Updates the core runtime to (a) stamp creator environment at start(), (b) log deployment pin mismatches diagnostically, and (c) ack queue messages on run_environment_mismatch rejections instead of retrying.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated no comments.

Show a summary per file
FileDescription
packages/world/src/queue.tsAdds optional environment field to RunInputSchema.
packages/world/src/queue.test.tsAdds schema coverage for environment round-trip and unknown-key stripping behavior.
packages/world/src/interfaces.tsAdds optional World.getEnvironment?() hook contract.
packages/world/src/events.tsExtends run_started event data with optional environment.
packages/world-vercel/src/utils.tsAdds resolveClientEnvironment() and reuses it for x-vercel-environment header generation.
packages/world-vercel/src/utils.test.tsTests environment resolution matrix and header agreement.
packages/world-vercel/src/index.tsImplements getEnvironment() for world-vercel using resolveClientEnvironment().
packages/world-vercel/src/events.tsRoutes environment through v4 meta splitting allowlist.
packages/world-vercel/src/events.test.tsTests splitEventDataForV4 includes/omits environment appropriately.
packages/world-vercel/src/events-v4.tsAdds environment to v4 frame meta and fixes error-code extraction to read error field.
packages/world-vercel/src/events-v4.test.tsTests error-field fallback and meta wire round-trip for environment.
packages/core/src/runtime/start.tsStamps world.getEnvironment?.() into queued runInput.environment.
packages/core/src/runtime/start.test.tsTests environment stamping/omission behavior.
packages/core/src/runtime/cross-environment-start.test.tsAdds end-to-end runtime handler tests for deployment pin diagnostics + cross-env 422 ack behavior (including turbo behavior).
packages/core/src/runtime.tsAdds deployment pin mismatch diagnostic + terminal handling for cross-env resilient start rejection.
packages/core/src/classify-error.tsAdds isCrossEnvironmentStartRejection and constant for the server error code.
packages/core/src/classify-error.test.tsAdds classification/negative tests for cross-environment rejection detection.
.changeset/world-vercel-get-environment.mdChangeset for @workflow/world-vercel additions/behavior changes.
.changeset/world-get-environment.mdChangeset for @workflow/world interface/schema additions.
.changeset/core-cross-environment-start-guard.mdChangeset for @workflow/core runtime behavior change.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…environment queue deliveries client-side
`start()` makes two writes that have to land in the same tenant: the
`run_created` event, attributed to whatever environment the caller
authenticates as, and the queue message, pinned to a deployment. A
misconfigured caller can split them — writing the run to one environment
while addressing the message to a deployment in another. The consumer
finds no run under its own tenant, the backend's resilient start
(`run_started` creates the run when `run_created` was never seen) mints a
second copy of the same run id in the consumer's environment, and both
copies are real: the creator's sits pending forever while the other
executes.
The deployment id is not the discriminator — it matched end to end in the
incident that motivated this. The environment is. So carry it: add an
optional `World.getEnvironment()`, implement it in world-vercel from the
same resolution that produces the `x-vercel-environment` header, and stamp
it into the queue message's `runInput`.
The consuming deployment already knows its own environment, so it can
refuse the delivery itself with no server coordination — and refuse before
`run_started`, the write that would create the fork. The refusal acks the
message instead of throwing: the mismatch is baked into the message, so
every redelivery would reach the same verdict and throwing would hot-loop
until MAX_QUEUE_DELIVERIES.
Both sides must be known for the check to run, so worlds with a single
tenant (local, Postgres) and runs started by an older SDK behave exactly
as before. A companion diagnostic logs a deployment-id mismatch without
refusing, since deployment ids differ for benign reasons too.
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
@pranaygp
pranaygpforce-pushed the pgp/runinput-environment-guard branch from 3021bcb to 2308af2CompareJuly 31, 2026 00:01
@pranaygppranaygp changed the title feat(core): stamp creator environment into runInput and fail cross-environment resilient starts fastfeat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-sideJul 31, 2026

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed as the third layer of the incident stack (with #3243 and vercel/wait-for-deployment-action#6), and against the earlier investigation of this same race from June (workbench-express, same mechanism: prod URL + preview dpl_ from the changeset-release branch). This is the right hardening and the design choices hold up under scrutiny:

  • Refusing before run_started is the only placement that prevents the fork rather than reporting it — verified the guard sits ahead of the event write and ahead of all attempt-dependent branching, with tests pinning that ordering.
  • Ack-and-discard over throwing is correct: the verdict is baked into the message, and it matches how the handler already treats EntityConflictError/RunExpiredError.
  • Fail-safe skip matrix is genuinely fail-safe: I checked each row (no getEnvironment on local/postgres, absent field from older SDKs, undefined from missing env vars) and none can produce a false refusal. Keeping producer stamp and consumer header derived from one resolveClientEnvironment is the load-bearing detail, and the agreement test locks it.
  • Wire compat claims check out: run_started eventData is built field-by-field so environment never reaches the server, and the unknown-key-stripping regression test protects the old-consumer direction.
  • The deployment-pinning check staying diagnostic-only is right, given dpl_local@<version> churn.

On CI: the Biome failure is the same one inherited from main (organizeImports in files whose only change here is additive imports — the two flagged files fail identically on main), and the two e2e failures are flakes unrelated to the guard: the local-prod lane doesn't implement getEnvironment at all, and the Vercel lane failure is a clock-skew sleep assertion in a test whose run executed (i.e. the guard matched and passed).

Does this make #3243 / #6 irrelevant? No — worth being explicit since it's been asked. This PR removes the corruption (no more forked run IDs, no cross-tenant ack noise), but a misconfigured caller still ends up with a stuck-pending run and a red e2e lane — just a diagnosable one. #3243 is still needed to stop CI from producing the mismatched pair in the first place, and the long-term fix for the action remains the vercel/api one identified in the June investigation (publish the dashboard URL in the env-scoped GitHub Deployment status so ID resolution is tokenless and exact), which supersedes #6's token path.

Two non-blocking notes inline. Approving.

Comment threadpackages/world-vercel/src/utils.ts Outdated
if (projectConfig) {
return projectConfig.environment || 'production';
}
return process.env.VERCEL_ENV || undefined;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Non-blocking question (custom environments): for a deployment in a Vercel custom environment, VERCEL_ENV reports preview and the custom environment's name lives in VERCEL_TARGET_ENV. Meanwhile the proxy path will happily stamp an arbitrary string from projectConfig.environment (e.g. staging). If the backend keys tenants on the custom-env name but this helper returns preview on the in-deployment path (or vice versa), the guard could refuse a legitimate delivery — the one failure mode this PR is otherwise careful to make impossible.

Given the interface doc's MUST ("match the attribution the backend will actually apply"), it's worth verifying what workflow-server actually attributes for (a) an OIDC client in a custom environment and (b) a proxy client sending a custom-env x-vercel-environment, and either incorporating VERCEL_TARGET_ENV here or documenting that custom environments are out of scope. Non-blocking because such a setup already forks today — the guard can only make it louder, not worse — but a wrong answer here turns into a false refusal rather than a skipped check.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Verified against the actual minting code in vercel/api, and you were right to flag it — VERCEL_ENV alone was wrong for custom environments, in exactly the false-refusal direction:

  • Runtime OIDC path: the deployment token's claim is minted as environment: customEnvironment?.slug ?? envTarget (services/api-deployments/src/services/gen-oidc-token.ts caller in create-deployment.ts), so a custom-env deployment is attributed by its slug, not preview — while VERCEL_ENV in that same process says preview.
  • Proxy path: x-vercel-environment accepts production, preview, or a custom environment's slug or ID, and the minted claim is always matchingEnv.slug (services/api-workflow/src/index.ts).
  • The fix (82bd72e): the in-deployment branch now returns VERCEL_TARGET_ENV || VERCEL_ENV. VERCEL_TARGET_ENV is populated from exactly the same pair as the claim (customEnvironmentSlug || projectEnvTarget in packages/projects-transforms/gen-system-envs.ts), so it matches the tenant key by construction, for standard and custom environments alike; VERCEL_ENV stays as the fallback where VERCEL_TARGET_ENV isn't injected (e.g. vercel dev). Tests cover the custom-env case and the standard-env no-op.

One residual documented rather than solved: on the projectConfig path the proxy accepts a custom env ID but attributes the slug, so configuring the ID would stamp a value the consumer can't match. The helper's doc now says to configure the slug; canonicalizing an ID client-side would need an API call, which didn't seem worth it for that corner.

return false;
}

runLogger.error(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Non-blocking (operational visibility): the refusal is logged only on the consuming deployment's runtime logs — the environment the operator is probably not looking at, since from their side the symptom is a run stuck pending in the creator's environment with no error anywhere on it. That's still strictly better than the fork, and I agree nothing more is possible client-side (the consumer can't write to the creator's tenant). Worth a follow-up though: this is exactly the case for the "carry the creator's environment in runInput so resilient start can detect/refuse server-side" hardening — the server can see both tenants and could mark the creator's run failed or surface a health annotation instead of leaving it pending forever.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Agreed on all points — the consumer-side refusal makes the fork impossible but leaves the creator's run pending with the evidence in the other environment's logs. Tracking the server-side layer as follow-up work; its ingredients are already mapped out from this incident:

  • workflow-server#689 (closed as superseded by this PR) contains a working implementation of the resilient-start guard and can be revived as the reporting/annotation layer you describe — the server is indeed the only place that can see both tenants and mark the creator's copy failed instead of pending-forever.
  • Two prerequisites discovered along the way: the v4 events wire rebuilds eventData from field allowlists (parseV4EventMeta/buildEventData), so runInput.environment needs explicit plumbing on both sides before the server can see it; and covering old SDKs at run_created time needs the deployment's target exposed from vercel/api's get-deployment-info endpoint (the Cosmos deployment doc it already loads carries it).

Until then, the creator-side symptom is at least the pre-existing one (stuck pending with "unhealthy deployment" health checks) rather than a silent fork, and the consumer-side error log names both environments and the runId for anyone who goes looking.

…ent-guard
* origin/main:
ci: stop deploying changeset-release/main, run its e2e against production (#3243)
fix(world-local): bound stalled queue deliveries (#3255)
Sort imports in runtime.ts and step-executor.ts (#3241)
…_ENV
For a deployment in a Vercel custom environment, the OIDC token's
environment claim is the custom environment's slug (the platform mints
`customEnvironment?.slug ?? envTarget`) while VERCEL_ENV reports
'preview' — so keying the cross-environment guard on VERCEL_ENV could
false-refuse a legitimate delivery, e.g. a CLI client attributed to
'staging' starting a run on the staging deployment. VERCEL_TARGET_ENV
is populated from exactly the same slug-or-target pair as the claim, so
prefer it, keeping VERCEL_ENV as the fallback for contexts that don't
inject it. Also sorts runtime.ts imports per the Biome rule that landed
on main in #3241.
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
@pranaygp
pranaygp merged commit ee944d2 into mainJul 31, 2026
176 of 178 checks passed
@pranaygp
pranaygp deleted the pgp/runinput-environment-guard branch July 31, 2026 22:53
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for ee944d2 (AI decision).

This adds new public API surface and wire format rather than fixing a defect in existing stable code: an optional World.getEnvironment() hook, a new optional environment field on RunInputSchema, a world-vercel implementation, and a new consumer-side guard that discards deliveries — all carrying minor changesets across three packages. Although motivated by a real incident, the fork was caused by caller misconfiguration (WORKFLOW_VERCEL_ENV pointing at a different environment than VERCEL_DEPLOYMENT_ID), and the change is a new defensive capability plus a new diagnostic, which is feature work by the backport criteria. If the maintenance line needs this protection, it can be forced through via workflow_dispatch after a human weighs the new API surface and behavior change on stable.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

ee944d2476daca81b89ba545b522385a7902ec03

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@pranaygp@TooTallNate
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side by pranaygp · Pull Request #3244 · vercel/workflow · GitHub
Skip to content

feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side - #3244

Merged
pranaygp merged 3 commits into
mainfrom
pgp/runinput-environment-guard
Jul 31, 2026
Merged

feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side#3244
pranaygp merged 3 commits into
mainfrom
pgp/runinput-environment-guard

Conversation

@pranaygp

@pranaygppranaygp commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

The bug

start() makes two writes that have to land in the same tenant:

  1. the run_created event, attributed to whatever environment the caller authenticates as, and
  2. the queue message, pinned to a deployment.

A misconfigured caller can split them — writing the run to one environment while addressing the message to a deployment in another. What happens next is not a failure, it is a fork:

  • the consuming deployment looks for the run under its own tenant and doesn't find it;
  • the backend's resilient start (run_started creates the run when run_created was never seen) mints a second copy of the same run id in the consumer's environment;
  • both copies are real. The creator's sits pending forever, the consumer's executes, and subsequent cross-tenant queue acks can't find their messages.

This happened on 2026-07-30: a CI harness ran with WORKFLOW_VERCEL_ENV=production while VERCEL_DEPLOYMENT_ID pointed at a preview deployment, and wrun_41KYTFGXEC0GPWVN8C0J0KG9ZK ended up existing in both environments of the same project, with two distinct run_created events 1.18s apart.

The deployment id is not the discriminator — it matched end to end in that incident. The environment is, and nothing on the wire carried it.

The fix

Carry the environment, then check it on the consumer.

  • @workflow/world — optional World.getEnvironment(): string | undefined (sync, side-effect free, styled after createRunId), and an optional environment on RunInputSchema.
  • @workflow/world-vercel — implements it. Both auth paths, one source of truth: with projectConfig (CLI/CI via the api.vercel.com proxy) the backend attributes the write to the x-vercel-environment header, so getEnvironment() and that header now derive from one shared resolveClientEnvironment() helper and cannot drift; without projectConfig (inside a deployment, OIDC) it reads VERCEL_ENV, which the platform mints the token's environment claim from.
  • @workflow/corestart() stamps the value into the queue message's runInput, and the queue handler refuses a delivery whose creator environment disagrees with its own.

Why the rejection is client-side

The consuming process already knows its own environment, so it needs nothing from the server to catch this. More importantly it can refuse before run_started — the write that would create the fork. Any check that happens after that write is too late; refusing before it means the resilient start never creates anything, and the creator's run is left pending in its own environment where a human can find it.

Why it acks instead of throwing

The mismatch is baked into this message: it is pinned to this deployment, and the creator's environment is a field in the payload. Every redelivery would reach the identical verdict, so throwing would hot-loop the handler until MAX_QUEUE_DELIVERIES and end with a misleading max-deliveries failure. Returning normally acks and discards, which is how the handler already treats deliveries whose verdict cannot change (EntityConflictError, RunExpiredError on the run_started path both log and return).

The error log names both environments, the run id, the pinned deployment, and the likely cause (WORKFLOW_VERCEL_ENV for CLI/CI clients, or the OIDC token's environment inside a deployment).

Fails safe, by construction

The check runs only when both sides are known, so nothing that works today changes behavior:

SituationrunInput.environmentworld.getEnvironment()Result
world-local / world-postgres consumeranynot implementedcheck skipped
run started by an older SDKabsentanycheck skipped
Vercel system env vars not exposedpresentundefinedcheck skipped
environments agreeproductionproductionexecutes normally
environments disagreeproductionpreviewrefused, logged, acked

undefined is deliberately preserved rather than defaulted: guessing 'production' in a bare Node process would fabricate a mismatch against a genuine preview deployment. A wrong answer here is worse than no answer.

Secondary: deployment-pinning diagnostic

Alongside it, when runInput.deploymentId and the handler's own getDeploymentId() are both known and differ, the handler logs an error with both ids and the run id — and continues.

Warn rather than refuse, because a differing deployment id is not by itself evidence of the fork. world-local derives its id from the installed package version (dpl_local@<version>), so upgrading the SDK mid-run changes it with nothing wrong; refusing on that signal would strand correct runs. The two checks live side by side and are individually tested, with the environment guard short-circuiting first so a cross-environment delivery produces one clear error rather than two.

Wire compatibility

Verified read-only against workflow-server and the SDK's own schemas.

  • New field, current server.run_started eventData is built field-by-field in runtime.ts, not spread from runInput, so environment never reaches the events API — it exists only in the queue payload. It would have been safe anyway: CreateEventSchemaV2.eventData is z.record(z.any()) and InitialRunAttributesSchema is .passthrough(), so an unknown key is accepted, not rejected.
  • New field, old SDK consumer.RunInputSchema, WorkflowInvokePayloadSchema, and QueuePayloadSchema are all plain z.object — zod strips unknown keys instead of rejecting them, so a message from a new producer is consumed normally by an older deployment mid-rollout (it just skips the check). Pinned by a regression test so nobody makes these strict later.

No deviation from the intended design was needed.

Tests

  • packages/world/src/queue.test.tsRunInputSchema round-trip, absent field, non-string rejection, unknown-key stripping.
  • packages/world-vercel/src/utils.test.tsresolveClientEnvironment matrix, plus an agreement test asserting it always equals the x-vercel-environment header for the same config, so the two can't drift.
  • packages/core/src/runtime/start.test.ts — stamps the environment, omits it when undefined, omits it when the world doesn't implement the hook.
  • packages/core/src/runtime/cross-environment-delivery.test.ts — mismatch produces no events at all (no run_started), acks with 204, logs both environments; refuses on a redelivery too; matching environments execute normally; each skip case (field absent, hook absent, hook returns undefined, re-enqueued message) executes normally; plus the deployment-pinning diagnostic and its ordering against the guard.

pnpm typecheck passes (41/41 packages). @workflow/core 1671 passed + 3 expected-fail, @workflow/world 84 passed, @workflow/world-vercel 322 passed.

pnpm lint reports 2 pre-existing errors on main (nested biome.json files declaring $schema 2.0.0 against CLI 2.4.4) — confirmed present on origin/main with these changes stashed, and untouched here.

🤖 Generated with Claude Code

@pranaygp
pranaygp requested a review from ijjk as a code ownerJuly 30, 2026 23:45
CopilotAI review requested due to automatic review settings July 30, 2026 23:45
@pranaygp
pranaygp requested a review from a team as a code ownerJuly 30, 2026 23:45
@changeset-bot

changeset-botBot commented Jul 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 82bd72e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
NameType
@workflow/coreMinor
@workflow/worldMinor
@workflow/world-vercelMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowMinor
@workflow/world-testingPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewJul 31, 2026 6:02pm
example-nextjs-workflow-webpackReadyReadyPreviewJul 31, 2026 6:02pm
example-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-astro-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-express-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-fastify-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-hono-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-nestjs-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-nitro-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-nuxt-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-sveltekit-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-tanstack-start-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-vite-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workflow-docsReadyReadyPreview, v0Jul 31, 2026 6:02pm
workflow-swc-playgroundReadyReadyPreviewJul 31, 2026 6:02pm
workflow-tarballsReadyReadyPreviewJul 31, 2026 6:02pm
workflow-webReadyReadyPreviewJul 31, 2026 6:02pm

@github-actions

github-actionsBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 82bd72e · Fri, 31 Jul 2026 18:22:36 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1317 (+401%) 🔻1402 🔴 (-2.3%)1439 🔴 (-11%)1539 🔴 (-23%) 💚30
TTFSstream1287 (+421%) 🔻1365 🔴 (-2.6%)1421 🔴 (-9.7%)1525 🔴 (-25%) 💚30
TTFShook + stream1582 (+294%) 🔻1690 🔴 (+30%) 🔻1721 🔴 (+27%) 🔻1837 🔴 (+25%) 🔻30
STSO1020 steps (inline)203 (+15%)503 (-2.1%)560 (-3.1%)796 (+1.9%)1016
STSO1020 steps (queue-hop)2365 (+59%) 🔻3496 (+25%) 🔻3496 (+25%) 🔻3496 (+25%) 🔻3
WO1020 steps434394 (+0.5%)434394 (+0.5%)434394 (+0.5%)434394 (+0.5%)1
SLstream latency130 (+48%) 🔻172 🔴 (+8.9%)192 🔴 (+12%)270 🔴 (+37%) 🔻30
SOstream overhead (text)128 (-8.6%)214 (±0%)243 (+7.5%)324 (-35%) 💚30
SOstream overhead (structured)147 (+9.7%)198 (-27%) 💚238 (-32%) 💚310 (-34%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 424581ms → this run 423919ms (Δ -662ms, 0%)

 150-200 ms ┃ main 10 this 0 -10
200-250 ms █████████┃██ main 84 this 67 -17
250-300 ms █████████████████┃ main 123 this 120 -3
300-350 ms ████████████████████┃ main 140 this 144 +4
350-400 ms █████████████████████░░┃ main 141 this 163 +22
400-450 ms ████████████████░░░┃ main 107 this 135 +28
450-500 ms █████████████████┃ main 120 this 125 +5
500-550 ms ███████████████████░┃ main 132 this 140 +8
550-600 ms █████████┃███ main 85 this 68 -17
600-650 ms ███┃█ main 33 this 25 -8
650-700 ms █┃█ main 20 this 13 -7
700-750 ms ┃ main 8 this 5 -3
750-800 ms ┃ main 7 this 3 -4
800-850 ms ┃ main 2 this 1 -1
850-900 ms ┃ main 1 this 2 +1
950-1000 ms ┃ main 0 this 2 +2
1000-1050 ms ┃ main 1 this 1 +0
1050-1100 ms ┃ main 1 this 0 -1
1100-1150 ms ┃ main 0 this 1 +1
1300-1350 ms ┃ main 1 this 0 -1
1600-1650 ms ┃ main 0 this 1 +1

1020 steps (queue-hop)

Cumulative STSO time: main 6430ms → this run 9083ms (Δ +2653ms, +41%)

1000-1500 ms ┃███████████ main 1 this 0 -1
2000-2500 ms ███████████┃ main 1 this 1 +0
2500-3000 ms ┃███████████ main 1 this 0 -1
3000-3500 ms ░░░░░░░░░░░░░░░░░░░░░░░┃ main 0 this 2 +2
📜 Previous results (1)

2308af2

Fri, 31 Jul 2026 00:25:05 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep261 (-74%) 💚1360 🔴 (+20%) 🔻1393 🔴 (+20%) 🔻1431 🔴 (+13%)30
TTFSstream232 (+13%)1365 🔴 (+24%) 🔻1394 🔴 (+23%) 🔻1435 🔴 (-12%)30
TTFShook + stream369 (-72%) 💚1606 🔴 (+12%)1627 🔴 (+11%)1888 🔴 (+15%) 🔻30
STSO1020 steps (inline)178 (-3.3%)492 (-5.2%)544 (-5.4%)737 (-8.8%)1016
STSO1020 steps (queue-hop)2271 (+12%)2910 (-38%) 💚2910 (-38%) 💚2910 (-38%) 💚3
WO1020 steps421233 (-6.6%)421233 (-6.6%)421233 (-6.6%)421233 (-6.6%)1
SLstream latency109 (-2.7%)182 🔴 (+9.6%)208 🔴 (±0%)435 🔴 (+12%)30
SOstream overhead (text)124 (-6.1%)258 🔴 (-9.8%)373 (-24%) 💚2528 🔴 (-8.2%)30
SOstream overhead (structured)110 (-17%) 💚207 (-26%) 💚256 (-34%) 💚344 (-78%) 💚30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7519011328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

CopilotAI left a comment

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.

Pull request overview

Adds a cross-environment “resilient start” guard by stamping the creator’s environment into queued runInput, wiring it through the v4 events meta path, and treating server rejections for environment mismatch as terminal (ack, don’t retry) to prevent the same wrun_ from being created in two different Vercel environments.

Changes:

  • Introduces World.getEnvironment?() and carries an optional environment field through RunInput and run_started event data.
  • Implements environment resolution in @workflow/world-vercel via a shared resolveClientEnvironment() used both for x-vercel-environment and getEnvironment(), and forwards environment through v4 frame meta.
  • Updates the core runtime to (a) stamp creator environment at start(), (b) log deployment pin mismatches diagnostically, and (c) ack queue messages on run_environment_mismatch rejections instead of retrying.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated no comments.

Show a summary per file
FileDescription
packages/world/src/queue.tsAdds optional environment field to RunInputSchema.
packages/world/src/queue.test.tsAdds schema coverage for environment round-trip and unknown-key stripping behavior.
packages/world/src/interfaces.tsAdds optional World.getEnvironment?() hook contract.
packages/world/src/events.tsExtends run_started event data with optional environment.
packages/world-vercel/src/utils.tsAdds resolveClientEnvironment() and reuses it for x-vercel-environment header generation.
packages/world-vercel/src/utils.test.tsTests environment resolution matrix and header agreement.
packages/world-vercel/src/index.tsImplements getEnvironment() for world-vercel using resolveClientEnvironment().
packages/world-vercel/src/events.tsRoutes environment through v4 meta splitting allowlist.
packages/world-vercel/src/events.test.tsTests splitEventDataForV4 includes/omits environment appropriately.
packages/world-vercel/src/events-v4.tsAdds environment to v4 frame meta and fixes error-code extraction to read error field.
packages/world-vercel/src/events-v4.test.tsTests error-field fallback and meta wire round-trip for environment.
packages/core/src/runtime/start.tsStamps world.getEnvironment?.() into queued runInput.environment.
packages/core/src/runtime/start.test.tsTests environment stamping/omission behavior.
packages/core/src/runtime/cross-environment-start.test.tsAdds end-to-end runtime handler tests for deployment pin diagnostics + cross-env 422 ack behavior (including turbo behavior).
packages/core/src/runtime.tsAdds deployment pin mismatch diagnostic + terminal handling for cross-env resilient start rejection.
packages/core/src/classify-error.tsAdds isCrossEnvironmentStartRejection and constant for the server error code.
packages/core/src/classify-error.test.tsAdds classification/negative tests for cross-environment rejection detection.
.changeset/world-vercel-get-environment.mdChangeset for @workflow/world-vercel additions/behavior changes.
.changeset/world-get-environment.mdChangeset for @workflow/world interface/schema additions.
.changeset/core-cross-environment-start-guard.mdChangeset for @workflow/core runtime behavior change.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…environment queue deliveries client-side
`start()` makes two writes that have to land in the same tenant: the
`run_created` event, attributed to whatever environment the caller
authenticates as, and the queue message, pinned to a deployment. A
misconfigured caller can split them — writing the run to one environment
while addressing the message to a deployment in another. The consumer
finds no run under its own tenant, the backend's resilient start
(`run_started` creates the run when `run_created` was never seen) mints a
second copy of the same run id in the consumer's environment, and both
copies are real: the creator's sits pending forever while the other
executes.
The deployment id is not the discriminator — it matched end to end in the
incident that motivated this. The environment is. So carry it: add an
optional `World.getEnvironment()`, implement it in world-vercel from the
same resolution that produces the `x-vercel-environment` header, and stamp
it into the queue message's `runInput`.
The consuming deployment already knows its own environment, so it can
refuse the delivery itself with no server coordination — and refuse before
`run_started`, the write that would create the fork. The refusal acks the
message instead of throwing: the mismatch is baked into the message, so
every redelivery would reach the same verdict and throwing would hot-loop
until MAX_QUEUE_DELIVERIES.
Both sides must be known for the check to run, so worlds with a single
tenant (local, Postgres) and runs started by an older SDK behave exactly
as before. A companion diagnostic logs a deployment-id mismatch without
refusing, since deployment ids differ for benign reasons too.
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
@pranaygp
pranaygpforce-pushed the pgp/runinput-environment-guard branch from 3021bcb to 2308af2CompareJuly 31, 2026 00:01
@pranaygppranaygp changed the title feat(core): stamp creator environment into runInput and fail cross-environment resilient starts fastfeat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-sideJul 31, 2026

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed as the third layer of the incident stack (with #3243 and vercel/wait-for-deployment-action#6), and against the earlier investigation of this same race from June (workbench-express, same mechanism: prod URL + preview dpl_ from the changeset-release branch). This is the right hardening and the design choices hold up under scrutiny:

  • Refusing before run_started is the only placement that prevents the fork rather than reporting it — verified the guard sits ahead of the event write and ahead of all attempt-dependent branching, with tests pinning that ordering.
  • Ack-and-discard over throwing is correct: the verdict is baked into the message, and it matches how the handler already treats EntityConflictError/RunExpiredError.
  • Fail-safe skip matrix is genuinely fail-safe: I checked each row (no getEnvironment on local/postgres, absent field from older SDKs, undefined from missing env vars) and none can produce a false refusal. Keeping producer stamp and consumer header derived from one resolveClientEnvironment is the load-bearing detail, and the agreement test locks it.
  • Wire compat claims check out: run_started eventData is built field-by-field so environment never reaches the server, and the unknown-key-stripping regression test protects the old-consumer direction.
  • The deployment-pinning check staying diagnostic-only is right, given dpl_local@<version> churn.

On CI: the Biome failure is the same one inherited from main (organizeImports in files whose only change here is additive imports — the two flagged files fail identically on main), and the two e2e failures are flakes unrelated to the guard: the local-prod lane doesn't implement getEnvironment at all, and the Vercel lane failure is a clock-skew sleep assertion in a test whose run executed (i.e. the guard matched and passed).

Does this make #3243 / #6 irrelevant? No — worth being explicit since it's been asked. This PR removes the corruption (no more forked run IDs, no cross-tenant ack noise), but a misconfigured caller still ends up with a stuck-pending run and a red e2e lane — just a diagnosable one. #3243 is still needed to stop CI from producing the mismatched pair in the first place, and the long-term fix for the action remains the vercel/api one identified in the June investigation (publish the dashboard URL in the env-scoped GitHub Deployment status so ID resolution is tokenless and exact), which supersedes #6's token path.

Two non-blocking notes inline. Approving.

Comment threadpackages/world-vercel/src/utils.ts Outdated
if (projectConfig) {
return projectConfig.environment || 'production';
}
return process.env.VERCEL_ENV || undefined;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Non-blocking question (custom environments): for a deployment in a Vercel custom environment, VERCEL_ENV reports preview and the custom environment's name lives in VERCEL_TARGET_ENV. Meanwhile the proxy path will happily stamp an arbitrary string from projectConfig.environment (e.g. staging). If the backend keys tenants on the custom-env name but this helper returns preview on the in-deployment path (or vice versa), the guard could refuse a legitimate delivery — the one failure mode this PR is otherwise careful to make impossible.

Given the interface doc's MUST ("match the attribution the backend will actually apply"), it's worth verifying what workflow-server actually attributes for (a) an OIDC client in a custom environment and (b) a proxy client sending a custom-env x-vercel-environment, and either incorporating VERCEL_TARGET_ENV here or documenting that custom environments are out of scope. Non-blocking because such a setup already forks today — the guard can only make it louder, not worse — but a wrong answer here turns into a false refusal rather than a skipped check.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Verified against the actual minting code in vercel/api, and you were right to flag it — VERCEL_ENV alone was wrong for custom environments, in exactly the false-refusal direction:

  • Runtime OIDC path: the deployment token's claim is minted as environment: customEnvironment?.slug ?? envTarget (services/api-deployments/src/services/gen-oidc-token.ts caller in create-deployment.ts), so a custom-env deployment is attributed by its slug, not preview — while VERCEL_ENV in that same process says preview.
  • Proxy path: x-vercel-environment accepts production, preview, or a custom environment's slug or ID, and the minted claim is always matchingEnv.slug (services/api-workflow/src/index.ts).
  • The fix (82bd72e): the in-deployment branch now returns VERCEL_TARGET_ENV || VERCEL_ENV. VERCEL_TARGET_ENV is populated from exactly the same pair as the claim (customEnvironmentSlug || projectEnvTarget in packages/projects-transforms/gen-system-envs.ts), so it matches the tenant key by construction, for standard and custom environments alike; VERCEL_ENV stays as the fallback where VERCEL_TARGET_ENV isn't injected (e.g. vercel dev). Tests cover the custom-env case and the standard-env no-op.

One residual documented rather than solved: on the projectConfig path the proxy accepts a custom env ID but attributes the slug, so configuring the ID would stamp a value the consumer can't match. The helper's doc now says to configure the slug; canonicalizing an ID client-side would need an API call, which didn't seem worth it for that corner.

return false;
}

runLogger.error(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Non-blocking (operational visibility): the refusal is logged only on the consuming deployment's runtime logs — the environment the operator is probably not looking at, since from their side the symptom is a run stuck pending in the creator's environment with no error anywhere on it. That's still strictly better than the fork, and I agree nothing more is possible client-side (the consumer can't write to the creator's tenant). Worth a follow-up though: this is exactly the case for the "carry the creator's environment in runInput so resilient start can detect/refuse server-side" hardening — the server can see both tenants and could mark the creator's run failed or surface a health annotation instead of leaving it pending forever.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Agreed on all points — the consumer-side refusal makes the fork impossible but leaves the creator's run pending with the evidence in the other environment's logs. Tracking the server-side layer as follow-up work; its ingredients are already mapped out from this incident:

  • workflow-server#689 (closed as superseded by this PR) contains a working implementation of the resilient-start guard and can be revived as the reporting/annotation layer you describe — the server is indeed the only place that can see both tenants and mark the creator's copy failed instead of pending-forever.
  • Two prerequisites discovered along the way: the v4 events wire rebuilds eventData from field allowlists (parseV4EventMeta/buildEventData), so runInput.environment needs explicit plumbing on both sides before the server can see it; and covering old SDKs at run_created time needs the deployment's target exposed from vercel/api's get-deployment-info endpoint (the Cosmos deployment doc it already loads carries it).

Until then, the creator-side symptom is at least the pre-existing one (stuck pending with "unhealthy deployment" health checks) rather than a silent fork, and the consumer-side error log names both environments and the runId for anyone who goes looking.

…ent-guard
* origin/main:
ci: stop deploying changeset-release/main, run its e2e against production (#3243)
fix(world-local): bound stalled queue deliveries (#3255)
Sort imports in runtime.ts and step-executor.ts (#3241)
…_ENV
For a deployment in a Vercel custom environment, the OIDC token's
environment claim is the custom environment's slug (the platform mints
`customEnvironment?.slug ?? envTarget`) while VERCEL_ENV reports
'preview' — so keying the cross-environment guard on VERCEL_ENV could
false-refuse a legitimate delivery, e.g. a CLI client attributed to
'staging' starting a run on the staging deployment. VERCEL_TARGET_ENV
is populated from exactly the same slug-or-target pair as the claim, so
prefer it, keeping VERCEL_ENV as the fallback for contexts that don't
inject it. Also sorts runtime.ts imports per the Biome rule that landed
on main in #3241.
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
@pranaygp
pranaygp merged commit ee944d2 into mainJul 31, 2026
176 of 178 checks passed
@pranaygp
pranaygp deleted the pgp/runinput-environment-guard branch July 31, 2026 22:53
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for ee944d2 (AI decision).

This adds new public API surface and wire format rather than fixing a defect in existing stable code: an optional World.getEnvironment() hook, a new optional environment field on RunInputSchema, a world-vercel implementation, and a new consumer-side guard that discards deliveries — all carrying minor changesets across three packages. Although motivated by a real incident, the fork was caused by caller misconfiguration (WORKFLOW_VERCEL_ENV pointing at a different environment than VERCEL_DEPLOYMENT_ID), and the change is a new defensive capability plus a new diagnostic, which is feature work by the backport criteria. If the maintenance line needs this protection, it can be forced through via workflow_dispatch after a human weighs the new API surface and behavior change on stable.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

ee944d2476daca81b89ba545b522385a7902ec03

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@pranaygp@TooTallNate
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side by pranaygp · Pull Request #3244 · vercel/workflow · GitHub
Skip to content

feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side - #3244

Merged
pranaygp merged 3 commits into
mainfrom
pgp/runinput-environment-guard
Jul 31, 2026
Merged

feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side#3244
pranaygp merged 3 commits into
mainfrom
pgp/runinput-environment-guard

Conversation

@pranaygp

@pranaygppranaygp commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

The bug

start() makes two writes that have to land in the same tenant:

  1. the run_created event, attributed to whatever environment the caller authenticates as, and
  2. the queue message, pinned to a deployment.

A misconfigured caller can split them — writing the run to one environment while addressing the message to a deployment in another. What happens next is not a failure, it is a fork:

  • the consuming deployment looks for the run under its own tenant and doesn't find it;
  • the backend's resilient start (run_started creates the run when run_created was never seen) mints a second copy of the same run id in the consumer's environment;
  • both copies are real. The creator's sits pending forever, the consumer's executes, and subsequent cross-tenant queue acks can't find their messages.

This happened on 2026-07-30: a CI harness ran with WORKFLOW_VERCEL_ENV=production while VERCEL_DEPLOYMENT_ID pointed at a preview deployment, and wrun_41KYTFGXEC0GPWVN8C0J0KG9ZK ended up existing in both environments of the same project, with two distinct run_created events 1.18s apart.

The deployment id is not the discriminator — it matched end to end in that incident. The environment is, and nothing on the wire carried it.

The fix

Carry the environment, then check it on the consumer.

  • @workflow/world — optional World.getEnvironment(): string | undefined (sync, side-effect free, styled after createRunId), and an optional environment on RunInputSchema.
  • @workflow/world-vercel — implements it. Both auth paths, one source of truth: with projectConfig (CLI/CI via the api.vercel.com proxy) the backend attributes the write to the x-vercel-environment header, so getEnvironment() and that header now derive from one shared resolveClientEnvironment() helper and cannot drift; without projectConfig (inside a deployment, OIDC) it reads VERCEL_ENV, which the platform mints the token's environment claim from.
  • @workflow/corestart() stamps the value into the queue message's runInput, and the queue handler refuses a delivery whose creator environment disagrees with its own.

Why the rejection is client-side

The consuming process already knows its own environment, so it needs nothing from the server to catch this. More importantly it can refuse before run_started — the write that would create the fork. Any check that happens after that write is too late; refusing before it means the resilient start never creates anything, and the creator's run is left pending in its own environment where a human can find it.

Why it acks instead of throwing

The mismatch is baked into this message: it is pinned to this deployment, and the creator's environment is a field in the payload. Every redelivery would reach the identical verdict, so throwing would hot-loop the handler until MAX_QUEUE_DELIVERIES and end with a misleading max-deliveries failure. Returning normally acks and discards, which is how the handler already treats deliveries whose verdict cannot change (EntityConflictError, RunExpiredError on the run_started path both log and return).

The error log names both environments, the run id, the pinned deployment, and the likely cause (WORKFLOW_VERCEL_ENV for CLI/CI clients, or the OIDC token's environment inside a deployment).

Fails safe, by construction

The check runs only when both sides are known, so nothing that works today changes behavior:

SituationrunInput.environmentworld.getEnvironment()Result
world-local / world-postgres consumeranynot implementedcheck skipped
run started by an older SDKabsentanycheck skipped
Vercel system env vars not exposedpresentundefinedcheck skipped
environments agreeproductionproductionexecutes normally
environments disagreeproductionpreviewrefused, logged, acked

undefined is deliberately preserved rather than defaulted: guessing 'production' in a bare Node process would fabricate a mismatch against a genuine preview deployment. A wrong answer here is worse than no answer.

Secondary: deployment-pinning diagnostic

Alongside it, when runInput.deploymentId and the handler's own getDeploymentId() are both known and differ, the handler logs an error with both ids and the run id — and continues.

Warn rather than refuse, because a differing deployment id is not by itself evidence of the fork. world-local derives its id from the installed package version (dpl_local@<version>), so upgrading the SDK mid-run changes it with nothing wrong; refusing on that signal would strand correct runs. The two checks live side by side and are individually tested, with the environment guard short-circuiting first so a cross-environment delivery produces one clear error rather than two.

Wire compatibility

Verified read-only against workflow-server and the SDK's own schemas.

  • New field, current server.run_started eventData is built field-by-field in runtime.ts, not spread from runInput, so environment never reaches the events API — it exists only in the queue payload. It would have been safe anyway: CreateEventSchemaV2.eventData is z.record(z.any()) and InitialRunAttributesSchema is .passthrough(), so an unknown key is accepted, not rejected.
  • New field, old SDK consumer.RunInputSchema, WorkflowInvokePayloadSchema, and QueuePayloadSchema are all plain z.object — zod strips unknown keys instead of rejecting them, so a message from a new producer is consumed normally by an older deployment mid-rollout (it just skips the check). Pinned by a regression test so nobody makes these strict later.

No deviation from the intended design was needed.

Tests

  • packages/world/src/queue.test.tsRunInputSchema round-trip, absent field, non-string rejection, unknown-key stripping.
  • packages/world-vercel/src/utils.test.tsresolveClientEnvironment matrix, plus an agreement test asserting it always equals the x-vercel-environment header for the same config, so the two can't drift.
  • packages/core/src/runtime/start.test.ts — stamps the environment, omits it when undefined, omits it when the world doesn't implement the hook.
  • packages/core/src/runtime/cross-environment-delivery.test.ts — mismatch produces no events at all (no run_started), acks with 204, logs both environments; refuses on a redelivery too; matching environments execute normally; each skip case (field absent, hook absent, hook returns undefined, re-enqueued message) executes normally; plus the deployment-pinning diagnostic and its ordering against the guard.

pnpm typecheck passes (41/41 packages). @workflow/core 1671 passed + 3 expected-fail, @workflow/world 84 passed, @workflow/world-vercel 322 passed.

pnpm lint reports 2 pre-existing errors on main (nested biome.json files declaring $schema 2.0.0 against CLI 2.4.4) — confirmed present on origin/main with these changes stashed, and untouched here.

🤖 Generated with Claude Code

@pranaygp
pranaygp requested a review from ijjk as a code ownerJuly 30, 2026 23:45
CopilotAI review requested due to automatic review settings July 30, 2026 23:45
@pranaygp
pranaygp requested a review from a team as a code ownerJuly 30, 2026 23:45
@changeset-bot

changeset-botBot commented Jul 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 82bd72e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
NameType
@workflow/coreMinor
@workflow/worldMinor
@workflow/world-vercelMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowMinor
@workflow/world-testingPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewJul 31, 2026 6:02pm
example-nextjs-workflow-webpackReadyReadyPreviewJul 31, 2026 6:02pm
example-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-astro-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-express-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-fastify-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-hono-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-nestjs-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-nitro-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-nuxt-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-sveltekit-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-tanstack-start-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-vite-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workflow-docsReadyReadyPreview, v0Jul 31, 2026 6:02pm
workflow-swc-playgroundReadyReadyPreviewJul 31, 2026 6:02pm
workflow-tarballsReadyReadyPreviewJul 31, 2026 6:02pm
workflow-webReadyReadyPreviewJul 31, 2026 6:02pm

@github-actions

github-actionsBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 82bd72e · Fri, 31 Jul 2026 18:22:36 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1317 (+401%) 🔻1402 🔴 (-2.3%)1439 🔴 (-11%)1539 🔴 (-23%) 💚30
TTFSstream1287 (+421%) 🔻1365 🔴 (-2.6%)1421 🔴 (-9.7%)1525 🔴 (-25%) 💚30
TTFShook + stream1582 (+294%) 🔻1690 🔴 (+30%) 🔻1721 🔴 (+27%) 🔻1837 🔴 (+25%) 🔻30
STSO1020 steps (inline)203 (+15%)503 (-2.1%)560 (-3.1%)796 (+1.9%)1016
STSO1020 steps (queue-hop)2365 (+59%) 🔻3496 (+25%) 🔻3496 (+25%) 🔻3496 (+25%) 🔻3
WO1020 steps434394 (+0.5%)434394 (+0.5%)434394 (+0.5%)434394 (+0.5%)1
SLstream latency130 (+48%) 🔻172 🔴 (+8.9%)192 🔴 (+12%)270 🔴 (+37%) 🔻30
SOstream overhead (text)128 (-8.6%)214 (±0%)243 (+7.5%)324 (-35%) 💚30
SOstream overhead (structured)147 (+9.7%)198 (-27%) 💚238 (-32%) 💚310 (-34%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 424581ms → this run 423919ms (Δ -662ms, 0%)

 150-200 ms ┃ main 10 this 0 -10
200-250 ms █████████┃██ main 84 this 67 -17
250-300 ms █████████████████┃ main 123 this 120 -3
300-350 ms ████████████████████┃ main 140 this 144 +4
350-400 ms █████████████████████░░┃ main 141 this 163 +22
400-450 ms ████████████████░░░┃ main 107 this 135 +28
450-500 ms █████████████████┃ main 120 this 125 +5
500-550 ms ███████████████████░┃ main 132 this 140 +8
550-600 ms █████████┃███ main 85 this 68 -17
600-650 ms ███┃█ main 33 this 25 -8
650-700 ms █┃█ main 20 this 13 -7
700-750 ms ┃ main 8 this 5 -3
750-800 ms ┃ main 7 this 3 -4
800-850 ms ┃ main 2 this 1 -1
850-900 ms ┃ main 1 this 2 +1
950-1000 ms ┃ main 0 this 2 +2
1000-1050 ms ┃ main 1 this 1 +0
1050-1100 ms ┃ main 1 this 0 -1
1100-1150 ms ┃ main 0 this 1 +1
1300-1350 ms ┃ main 1 this 0 -1
1600-1650 ms ┃ main 0 this 1 +1

1020 steps (queue-hop)

Cumulative STSO time: main 6430ms → this run 9083ms (Δ +2653ms, +41%)

1000-1500 ms ┃███████████ main 1 this 0 -1
2000-2500 ms ███████████┃ main 1 this 1 +0
2500-3000 ms ┃███████████ main 1 this 0 -1
3000-3500 ms ░░░░░░░░░░░░░░░░░░░░░░░┃ main 0 this 2 +2
📜 Previous results (1)

2308af2

Fri, 31 Jul 2026 00:25:05 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep261 (-74%) 💚1360 🔴 (+20%) 🔻1393 🔴 (+20%) 🔻1431 🔴 (+13%)30
TTFSstream232 (+13%)1365 🔴 (+24%) 🔻1394 🔴 (+23%) 🔻1435 🔴 (-12%)30
TTFShook + stream369 (-72%) 💚1606 🔴 (+12%)1627 🔴 (+11%)1888 🔴 (+15%) 🔻30
STSO1020 steps (inline)178 (-3.3%)492 (-5.2%)544 (-5.4%)737 (-8.8%)1016
STSO1020 steps (queue-hop)2271 (+12%)2910 (-38%) 💚2910 (-38%) 💚2910 (-38%) 💚3
WO1020 steps421233 (-6.6%)421233 (-6.6%)421233 (-6.6%)421233 (-6.6%)1
SLstream latency109 (-2.7%)182 🔴 (+9.6%)208 🔴 (±0%)435 🔴 (+12%)30
SOstream overhead (text)124 (-6.1%)258 🔴 (-9.8%)373 (-24%) 💚2528 🔴 (-8.2%)30
SOstream overhead (structured)110 (-17%) 💚207 (-26%) 💚256 (-34%) 💚344 (-78%) 💚30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7519011328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

CopilotAI left a comment

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.

Pull request overview

Adds a cross-environment “resilient start” guard by stamping the creator’s environment into queued runInput, wiring it through the v4 events meta path, and treating server rejections for environment mismatch as terminal (ack, don’t retry) to prevent the same wrun_ from being created in two different Vercel environments.

Changes:

  • Introduces World.getEnvironment?() and carries an optional environment field through RunInput and run_started event data.
  • Implements environment resolution in @workflow/world-vercel via a shared resolveClientEnvironment() used both for x-vercel-environment and getEnvironment(), and forwards environment through v4 frame meta.
  • Updates the core runtime to (a) stamp creator environment at start(), (b) log deployment pin mismatches diagnostically, and (c) ack queue messages on run_environment_mismatch rejections instead of retrying.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated no comments.

Show a summary per file
FileDescription
packages/world/src/queue.tsAdds optional environment field to RunInputSchema.
packages/world/src/queue.test.tsAdds schema coverage for environment round-trip and unknown-key stripping behavior.
packages/world/src/interfaces.tsAdds optional World.getEnvironment?() hook contract.
packages/world/src/events.tsExtends run_started event data with optional environment.
packages/world-vercel/src/utils.tsAdds resolveClientEnvironment() and reuses it for x-vercel-environment header generation.
packages/world-vercel/src/utils.test.tsTests environment resolution matrix and header agreement.
packages/world-vercel/src/index.tsImplements getEnvironment() for world-vercel using resolveClientEnvironment().
packages/world-vercel/src/events.tsRoutes environment through v4 meta splitting allowlist.
packages/world-vercel/src/events.test.tsTests splitEventDataForV4 includes/omits environment appropriately.
packages/world-vercel/src/events-v4.tsAdds environment to v4 frame meta and fixes error-code extraction to read error field.
packages/world-vercel/src/events-v4.test.tsTests error-field fallback and meta wire round-trip for environment.
packages/core/src/runtime/start.tsStamps world.getEnvironment?.() into queued runInput.environment.
packages/core/src/runtime/start.test.tsTests environment stamping/omission behavior.
packages/core/src/runtime/cross-environment-start.test.tsAdds end-to-end runtime handler tests for deployment pin diagnostics + cross-env 422 ack behavior (including turbo behavior).
packages/core/src/runtime.tsAdds deployment pin mismatch diagnostic + terminal handling for cross-env resilient start rejection.
packages/core/src/classify-error.tsAdds isCrossEnvironmentStartRejection and constant for the server error code.
packages/core/src/classify-error.test.tsAdds classification/negative tests for cross-environment rejection detection.
.changeset/world-vercel-get-environment.mdChangeset for @workflow/world-vercel additions/behavior changes.
.changeset/world-get-environment.mdChangeset for @workflow/world interface/schema additions.
.changeset/core-cross-environment-start-guard.mdChangeset for @workflow/core runtime behavior change.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…environment queue deliveries client-side
`start()` makes two writes that have to land in the same tenant: the
`run_created` event, attributed to whatever environment the caller
authenticates as, and the queue message, pinned to a deployment. A
misconfigured caller can split them — writing the run to one environment
while addressing the message to a deployment in another. The consumer
finds no run under its own tenant, the backend's resilient start
(`run_started` creates the run when `run_created` was never seen) mints a
second copy of the same run id in the consumer's environment, and both
copies are real: the creator's sits pending forever while the other
executes.
The deployment id is not the discriminator — it matched end to end in the
incident that motivated this. The environment is. So carry it: add an
optional `World.getEnvironment()`, implement it in world-vercel from the
same resolution that produces the `x-vercel-environment` header, and stamp
it into the queue message's `runInput`.
The consuming deployment already knows its own environment, so it can
refuse the delivery itself with no server coordination — and refuse before
`run_started`, the write that would create the fork. The refusal acks the
message instead of throwing: the mismatch is baked into the message, so
every redelivery would reach the same verdict and throwing would hot-loop
until MAX_QUEUE_DELIVERIES.
Both sides must be known for the check to run, so worlds with a single
tenant (local, Postgres) and runs started by an older SDK behave exactly
as before. A companion diagnostic logs a deployment-id mismatch without
refusing, since deployment ids differ for benign reasons too.
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
@pranaygp
pranaygpforce-pushed the pgp/runinput-environment-guard branch from 3021bcb to 2308af2CompareJuly 31, 2026 00:01
@pranaygppranaygp changed the title feat(core): stamp creator environment into runInput and fail cross-environment resilient starts fastfeat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-sideJul 31, 2026

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed as the third layer of the incident stack (with #3243 and vercel/wait-for-deployment-action#6), and against the earlier investigation of this same race from June (workbench-express, same mechanism: prod URL + preview dpl_ from the changeset-release branch). This is the right hardening and the design choices hold up under scrutiny:

  • Refusing before run_started is the only placement that prevents the fork rather than reporting it — verified the guard sits ahead of the event write and ahead of all attempt-dependent branching, with tests pinning that ordering.
  • Ack-and-discard over throwing is correct: the verdict is baked into the message, and it matches how the handler already treats EntityConflictError/RunExpiredError.
  • Fail-safe skip matrix is genuinely fail-safe: I checked each row (no getEnvironment on local/postgres, absent field from older SDKs, undefined from missing env vars) and none can produce a false refusal. Keeping producer stamp and consumer header derived from one resolveClientEnvironment is the load-bearing detail, and the agreement test locks it.
  • Wire compat claims check out: run_started eventData is built field-by-field so environment never reaches the server, and the unknown-key-stripping regression test protects the old-consumer direction.
  • The deployment-pinning check staying diagnostic-only is right, given dpl_local@<version> churn.

On CI: the Biome failure is the same one inherited from main (organizeImports in files whose only change here is additive imports — the two flagged files fail identically on main), and the two e2e failures are flakes unrelated to the guard: the local-prod lane doesn't implement getEnvironment at all, and the Vercel lane failure is a clock-skew sleep assertion in a test whose run executed (i.e. the guard matched and passed).

Does this make #3243 / #6 irrelevant? No — worth being explicit since it's been asked. This PR removes the corruption (no more forked run IDs, no cross-tenant ack noise), but a misconfigured caller still ends up with a stuck-pending run and a red e2e lane — just a diagnosable one. #3243 is still needed to stop CI from producing the mismatched pair in the first place, and the long-term fix for the action remains the vercel/api one identified in the June investigation (publish the dashboard URL in the env-scoped GitHub Deployment status so ID resolution is tokenless and exact), which supersedes #6's token path.

Two non-blocking notes inline. Approving.

Comment threadpackages/world-vercel/src/utils.ts Outdated
if (projectConfig) {
return projectConfig.environment || 'production';
}
return process.env.VERCEL_ENV || undefined;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Non-blocking question (custom environments): for a deployment in a Vercel custom environment, VERCEL_ENV reports preview and the custom environment's name lives in VERCEL_TARGET_ENV. Meanwhile the proxy path will happily stamp an arbitrary string from projectConfig.environment (e.g. staging). If the backend keys tenants on the custom-env name but this helper returns preview on the in-deployment path (or vice versa), the guard could refuse a legitimate delivery — the one failure mode this PR is otherwise careful to make impossible.

Given the interface doc's MUST ("match the attribution the backend will actually apply"), it's worth verifying what workflow-server actually attributes for (a) an OIDC client in a custom environment and (b) a proxy client sending a custom-env x-vercel-environment, and either incorporating VERCEL_TARGET_ENV here or documenting that custom environments are out of scope. Non-blocking because such a setup already forks today — the guard can only make it louder, not worse — but a wrong answer here turns into a false refusal rather than a skipped check.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Verified against the actual minting code in vercel/api, and you were right to flag it — VERCEL_ENV alone was wrong for custom environments, in exactly the false-refusal direction:

  • Runtime OIDC path: the deployment token's claim is minted as environment: customEnvironment?.slug ?? envTarget (services/api-deployments/src/services/gen-oidc-token.ts caller in create-deployment.ts), so a custom-env deployment is attributed by its slug, not preview — while VERCEL_ENV in that same process says preview.
  • Proxy path: x-vercel-environment accepts production, preview, or a custom environment's slug or ID, and the minted claim is always matchingEnv.slug (services/api-workflow/src/index.ts).
  • The fix (82bd72e): the in-deployment branch now returns VERCEL_TARGET_ENV || VERCEL_ENV. VERCEL_TARGET_ENV is populated from exactly the same pair as the claim (customEnvironmentSlug || projectEnvTarget in packages/projects-transforms/gen-system-envs.ts), so it matches the tenant key by construction, for standard and custom environments alike; VERCEL_ENV stays as the fallback where VERCEL_TARGET_ENV isn't injected (e.g. vercel dev). Tests cover the custom-env case and the standard-env no-op.

One residual documented rather than solved: on the projectConfig path the proxy accepts a custom env ID but attributes the slug, so configuring the ID would stamp a value the consumer can't match. The helper's doc now says to configure the slug; canonicalizing an ID client-side would need an API call, which didn't seem worth it for that corner.

return false;
}

runLogger.error(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Non-blocking (operational visibility): the refusal is logged only on the consuming deployment's runtime logs — the environment the operator is probably not looking at, since from their side the symptom is a run stuck pending in the creator's environment with no error anywhere on it. That's still strictly better than the fork, and I agree nothing more is possible client-side (the consumer can't write to the creator's tenant). Worth a follow-up though: this is exactly the case for the "carry the creator's environment in runInput so resilient start can detect/refuse server-side" hardening — the server can see both tenants and could mark the creator's run failed or surface a health annotation instead of leaving it pending forever.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Agreed on all points — the consumer-side refusal makes the fork impossible but leaves the creator's run pending with the evidence in the other environment's logs. Tracking the server-side layer as follow-up work; its ingredients are already mapped out from this incident:

  • workflow-server#689 (closed as superseded by this PR) contains a working implementation of the resilient-start guard and can be revived as the reporting/annotation layer you describe — the server is indeed the only place that can see both tenants and mark the creator's copy failed instead of pending-forever.
  • Two prerequisites discovered along the way: the v4 events wire rebuilds eventData from field allowlists (parseV4EventMeta/buildEventData), so runInput.environment needs explicit plumbing on both sides before the server can see it; and covering old SDKs at run_created time needs the deployment's target exposed from vercel/api's get-deployment-info endpoint (the Cosmos deployment doc it already loads carries it).

Until then, the creator-side symptom is at least the pre-existing one (stuck pending with "unhealthy deployment" health checks) rather than a silent fork, and the consumer-side error log names both environments and the runId for anyone who goes looking.

…ent-guard
* origin/main:
ci: stop deploying changeset-release/main, run its e2e against production (#3243)
fix(world-local): bound stalled queue deliveries (#3255)
Sort imports in runtime.ts and step-executor.ts (#3241)
…_ENV
For a deployment in a Vercel custom environment, the OIDC token's
environment claim is the custom environment's slug (the platform mints
`customEnvironment?.slug ?? envTarget`) while VERCEL_ENV reports
'preview' — so keying the cross-environment guard on VERCEL_ENV could
false-refuse a legitimate delivery, e.g. a CLI client attributed to
'staging' starting a run on the staging deployment. VERCEL_TARGET_ENV
is populated from exactly the same slug-or-target pair as the claim, so
prefer it, keeping VERCEL_ENV as the fallback for contexts that don't
inject it. Also sorts runtime.ts imports per the Biome rule that landed
on main in #3241.
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
@pranaygp
pranaygp merged commit ee944d2 into mainJul 31, 2026
176 of 178 checks passed
@pranaygp
pranaygp deleted the pgp/runinput-environment-guard branch July 31, 2026 22:53
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for ee944d2 (AI decision).

This adds new public API surface and wire format rather than fixing a defect in existing stable code: an optional World.getEnvironment() hook, a new optional environment field on RunInputSchema, a world-vercel implementation, and a new consumer-side guard that discards deliveries — all carrying minor changesets across three packages. Although motivated by a real incident, the fork was caused by caller misconfiguration (WORKFLOW_VERCEL_ENV pointing at a different environment than VERCEL_DEPLOYMENT_ID), and the change is a new defensive capability plus a new diagnostic, which is feature work by the backport criteria. If the maintenance line needs this protection, it can be forced through via workflow_dispatch after a human weighs the new API surface and behavior change on stable.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

ee944d2476daca81b89ba545b522385a7902ec03

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@pranaygp@TooTallNate
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side by pranaygp · Pull Request #3244 · vercel/workflow · GitHub
Skip to content

feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side - #3244

Merged
pranaygp merged 3 commits into
mainfrom
pgp/runinput-environment-guard
Jul 31, 2026
Merged

feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side#3244
pranaygp merged 3 commits into
mainfrom
pgp/runinput-environment-guard

Conversation

@pranaygp

@pranaygppranaygp commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

The bug

start() makes two writes that have to land in the same tenant:

  1. the run_created event, attributed to whatever environment the caller authenticates as, and
  2. the queue message, pinned to a deployment.

A misconfigured caller can split them — writing the run to one environment while addressing the message to a deployment in another. What happens next is not a failure, it is a fork:

  • the consuming deployment looks for the run under its own tenant and doesn't find it;
  • the backend's resilient start (run_started creates the run when run_created was never seen) mints a second copy of the same run id in the consumer's environment;
  • both copies are real. The creator's sits pending forever, the consumer's executes, and subsequent cross-tenant queue acks can't find their messages.

This happened on 2026-07-30: a CI harness ran with WORKFLOW_VERCEL_ENV=production while VERCEL_DEPLOYMENT_ID pointed at a preview deployment, and wrun_41KYTFGXEC0GPWVN8C0J0KG9ZK ended up existing in both environments of the same project, with two distinct run_created events 1.18s apart.

The deployment id is not the discriminator — it matched end to end in that incident. The environment is, and nothing on the wire carried it.

The fix

Carry the environment, then check it on the consumer.

  • @workflow/world — optional World.getEnvironment(): string | undefined (sync, side-effect free, styled after createRunId), and an optional environment on RunInputSchema.
  • @workflow/world-vercel — implements it. Both auth paths, one source of truth: with projectConfig (CLI/CI via the api.vercel.com proxy) the backend attributes the write to the x-vercel-environment header, so getEnvironment() and that header now derive from one shared resolveClientEnvironment() helper and cannot drift; without projectConfig (inside a deployment, OIDC) it reads VERCEL_ENV, which the platform mints the token's environment claim from.
  • @workflow/corestart() stamps the value into the queue message's runInput, and the queue handler refuses a delivery whose creator environment disagrees with its own.

Why the rejection is client-side

The consuming process already knows its own environment, so it needs nothing from the server to catch this. More importantly it can refuse before run_started — the write that would create the fork. Any check that happens after that write is too late; refusing before it means the resilient start never creates anything, and the creator's run is left pending in its own environment where a human can find it.

Why it acks instead of throwing

The mismatch is baked into this message: it is pinned to this deployment, and the creator's environment is a field in the payload. Every redelivery would reach the identical verdict, so throwing would hot-loop the handler until MAX_QUEUE_DELIVERIES and end with a misleading max-deliveries failure. Returning normally acks and discards, which is how the handler already treats deliveries whose verdict cannot change (EntityConflictError, RunExpiredError on the run_started path both log and return).

The error log names both environments, the run id, the pinned deployment, and the likely cause (WORKFLOW_VERCEL_ENV for CLI/CI clients, or the OIDC token's environment inside a deployment).

Fails safe, by construction

The check runs only when both sides are known, so nothing that works today changes behavior:

SituationrunInput.environmentworld.getEnvironment()Result
world-local / world-postgres consumeranynot implementedcheck skipped
run started by an older SDKabsentanycheck skipped
Vercel system env vars not exposedpresentundefinedcheck skipped
environments agreeproductionproductionexecutes normally
environments disagreeproductionpreviewrefused, logged, acked

undefined is deliberately preserved rather than defaulted: guessing 'production' in a bare Node process would fabricate a mismatch against a genuine preview deployment. A wrong answer here is worse than no answer.

Secondary: deployment-pinning diagnostic

Alongside it, when runInput.deploymentId and the handler's own getDeploymentId() are both known and differ, the handler logs an error with both ids and the run id — and continues.

Warn rather than refuse, because a differing deployment id is not by itself evidence of the fork. world-local derives its id from the installed package version (dpl_local@<version>), so upgrading the SDK mid-run changes it with nothing wrong; refusing on that signal would strand correct runs. The two checks live side by side and are individually tested, with the environment guard short-circuiting first so a cross-environment delivery produces one clear error rather than two.

Wire compatibility

Verified read-only against workflow-server and the SDK's own schemas.

  • New field, current server.run_started eventData is built field-by-field in runtime.ts, not spread from runInput, so environment never reaches the events API — it exists only in the queue payload. It would have been safe anyway: CreateEventSchemaV2.eventData is z.record(z.any()) and InitialRunAttributesSchema is .passthrough(), so an unknown key is accepted, not rejected.
  • New field, old SDK consumer.RunInputSchema, WorkflowInvokePayloadSchema, and QueuePayloadSchema are all plain z.object — zod strips unknown keys instead of rejecting them, so a message from a new producer is consumed normally by an older deployment mid-rollout (it just skips the check). Pinned by a regression test so nobody makes these strict later.

No deviation from the intended design was needed.

Tests

  • packages/world/src/queue.test.tsRunInputSchema round-trip, absent field, non-string rejection, unknown-key stripping.
  • packages/world-vercel/src/utils.test.tsresolveClientEnvironment matrix, plus an agreement test asserting it always equals the x-vercel-environment header for the same config, so the two can't drift.
  • packages/core/src/runtime/start.test.ts — stamps the environment, omits it when undefined, omits it when the world doesn't implement the hook.
  • packages/core/src/runtime/cross-environment-delivery.test.ts — mismatch produces no events at all (no run_started), acks with 204, logs both environments; refuses on a redelivery too; matching environments execute normally; each skip case (field absent, hook absent, hook returns undefined, re-enqueued message) executes normally; plus the deployment-pinning diagnostic and its ordering against the guard.

pnpm typecheck passes (41/41 packages). @workflow/core 1671 passed + 3 expected-fail, @workflow/world 84 passed, @workflow/world-vercel 322 passed.

pnpm lint reports 2 pre-existing errors on main (nested biome.json files declaring $schema 2.0.0 against CLI 2.4.4) — confirmed present on origin/main with these changes stashed, and untouched here.

🤖 Generated with Claude Code

@pranaygp
pranaygp requested a review from ijjk as a code ownerJuly 30, 2026 23:45
CopilotAI review requested due to automatic review settings July 30, 2026 23:45
@pranaygp
pranaygp requested a review from a team as a code ownerJuly 30, 2026 23:45
@changeset-bot

changeset-botBot commented Jul 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 82bd72e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
NameType
@workflow/coreMinor
@workflow/worldMinor
@workflow/world-vercelMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowMinor
@workflow/world-testingPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewJul 31, 2026 6:02pm
example-nextjs-workflow-webpackReadyReadyPreviewJul 31, 2026 6:02pm
example-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-astro-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-express-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-fastify-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-hono-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-nestjs-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-nitro-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-nuxt-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-sveltekit-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-tanstack-start-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-vite-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workflow-docsReadyReadyPreview, v0Jul 31, 2026 6:02pm
workflow-swc-playgroundReadyReadyPreviewJul 31, 2026 6:02pm
workflow-tarballsReadyReadyPreviewJul 31, 2026 6:02pm
workflow-webReadyReadyPreviewJul 31, 2026 6:02pm

@github-actions

github-actionsBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 82bd72e · Fri, 31 Jul 2026 18:22:36 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1317 (+401%) 🔻1402 🔴 (-2.3%)1439 🔴 (-11%)1539 🔴 (-23%) 💚30
TTFSstream1287 (+421%) 🔻1365 🔴 (-2.6%)1421 🔴 (-9.7%)1525 🔴 (-25%) 💚30
TTFShook + stream1582 (+294%) 🔻1690 🔴 (+30%) 🔻1721 🔴 (+27%) 🔻1837 🔴 (+25%) 🔻30
STSO1020 steps (inline)203 (+15%)503 (-2.1%)560 (-3.1%)796 (+1.9%)1016
STSO1020 steps (queue-hop)2365 (+59%) 🔻3496 (+25%) 🔻3496 (+25%) 🔻3496 (+25%) 🔻3
WO1020 steps434394 (+0.5%)434394 (+0.5%)434394 (+0.5%)434394 (+0.5%)1
SLstream latency130 (+48%) 🔻172 🔴 (+8.9%)192 🔴 (+12%)270 🔴 (+37%) 🔻30
SOstream overhead (text)128 (-8.6%)214 (±0%)243 (+7.5%)324 (-35%) 💚30
SOstream overhead (structured)147 (+9.7%)198 (-27%) 💚238 (-32%) 💚310 (-34%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 424581ms → this run 423919ms (Δ -662ms, 0%)

 150-200 ms ┃ main 10 this 0 -10
200-250 ms █████████┃██ main 84 this 67 -17
250-300 ms █████████████████┃ main 123 this 120 -3
300-350 ms ████████████████████┃ main 140 this 144 +4
350-400 ms █████████████████████░░┃ main 141 this 163 +22
400-450 ms ████████████████░░░┃ main 107 this 135 +28
450-500 ms █████████████████┃ main 120 this 125 +5
500-550 ms ███████████████████░┃ main 132 this 140 +8
550-600 ms █████████┃███ main 85 this 68 -17
600-650 ms ███┃█ main 33 this 25 -8
650-700 ms █┃█ main 20 this 13 -7
700-750 ms ┃ main 8 this 5 -3
750-800 ms ┃ main 7 this 3 -4
800-850 ms ┃ main 2 this 1 -1
850-900 ms ┃ main 1 this 2 +1
950-1000 ms ┃ main 0 this 2 +2
1000-1050 ms ┃ main 1 this 1 +0
1050-1100 ms ┃ main 1 this 0 -1
1100-1150 ms ┃ main 0 this 1 +1
1300-1350 ms ┃ main 1 this 0 -1
1600-1650 ms ┃ main 0 this 1 +1

1020 steps (queue-hop)

Cumulative STSO time: main 6430ms → this run 9083ms (Δ +2653ms, +41%)

1000-1500 ms ┃███████████ main 1 this 0 -1
2000-2500 ms ███████████┃ main 1 this 1 +0
2500-3000 ms ┃███████████ main 1 this 0 -1
3000-3500 ms ░░░░░░░░░░░░░░░░░░░░░░░┃ main 0 this 2 +2
📜 Previous results (1)

2308af2

Fri, 31 Jul 2026 00:25:05 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep261 (-74%) 💚1360 🔴 (+20%) 🔻1393 🔴 (+20%) 🔻1431 🔴 (+13%)30
TTFSstream232 (+13%)1365 🔴 (+24%) 🔻1394 🔴 (+23%) 🔻1435 🔴 (-12%)30
TTFShook + stream369 (-72%) 💚1606 🔴 (+12%)1627 🔴 (+11%)1888 🔴 (+15%) 🔻30
STSO1020 steps (inline)178 (-3.3%)492 (-5.2%)544 (-5.4%)737 (-8.8%)1016
STSO1020 steps (queue-hop)2271 (+12%)2910 (-38%) 💚2910 (-38%) 💚2910 (-38%) 💚3
WO1020 steps421233 (-6.6%)421233 (-6.6%)421233 (-6.6%)421233 (-6.6%)1
SLstream latency109 (-2.7%)182 🔴 (+9.6%)208 🔴 (±0%)435 🔴 (+12%)30
SOstream overhead (text)124 (-6.1%)258 🔴 (-9.8%)373 (-24%) 💚2528 🔴 (-8.2%)30
SOstream overhead (structured)110 (-17%) 💚207 (-26%) 💚256 (-34%) 💚344 (-78%) 💚30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7519011328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

CopilotAI left a comment

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.

Pull request overview

Adds a cross-environment “resilient start” guard by stamping the creator’s environment into queued runInput, wiring it through the v4 events meta path, and treating server rejections for environment mismatch as terminal (ack, don’t retry) to prevent the same wrun_ from being created in two different Vercel environments.

Changes:

  • Introduces World.getEnvironment?() and carries an optional environment field through RunInput and run_started event data.
  • Implements environment resolution in @workflow/world-vercel via a shared resolveClientEnvironment() used both for x-vercel-environment and getEnvironment(), and forwards environment through v4 frame meta.
  • Updates the core runtime to (a) stamp creator environment at start(), (b) log deployment pin mismatches diagnostically, and (c) ack queue messages on run_environment_mismatch rejections instead of retrying.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated no comments.

Show a summary per file
FileDescription
packages/world/src/queue.tsAdds optional environment field to RunInputSchema.
packages/world/src/queue.test.tsAdds schema coverage for environment round-trip and unknown-key stripping behavior.
packages/world/src/interfaces.tsAdds optional World.getEnvironment?() hook contract.
packages/world/src/events.tsExtends run_started event data with optional environment.
packages/world-vercel/src/utils.tsAdds resolveClientEnvironment() and reuses it for x-vercel-environment header generation.
packages/world-vercel/src/utils.test.tsTests environment resolution matrix and header agreement.
packages/world-vercel/src/index.tsImplements getEnvironment() for world-vercel using resolveClientEnvironment().
packages/world-vercel/src/events.tsRoutes environment through v4 meta splitting allowlist.
packages/world-vercel/src/events.test.tsTests splitEventDataForV4 includes/omits environment appropriately.
packages/world-vercel/src/events-v4.tsAdds environment to v4 frame meta and fixes error-code extraction to read error field.
packages/world-vercel/src/events-v4.test.tsTests error-field fallback and meta wire round-trip for environment.
packages/core/src/runtime/start.tsStamps world.getEnvironment?.() into queued runInput.environment.
packages/core/src/runtime/start.test.tsTests environment stamping/omission behavior.
packages/core/src/runtime/cross-environment-start.test.tsAdds end-to-end runtime handler tests for deployment pin diagnostics + cross-env 422 ack behavior (including turbo behavior).
packages/core/src/runtime.tsAdds deployment pin mismatch diagnostic + terminal handling for cross-env resilient start rejection.
packages/core/src/classify-error.tsAdds isCrossEnvironmentStartRejection and constant for the server error code.
packages/core/src/classify-error.test.tsAdds classification/negative tests for cross-environment rejection detection.
.changeset/world-vercel-get-environment.mdChangeset for @workflow/world-vercel additions/behavior changes.
.changeset/world-get-environment.mdChangeset for @workflow/world interface/schema additions.
.changeset/core-cross-environment-start-guard.mdChangeset for @workflow/core runtime behavior change.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…environment queue deliveries client-side
`start()` makes two writes that have to land in the same tenant: the
`run_created` event, attributed to whatever environment the caller
authenticates as, and the queue message, pinned to a deployment. A
misconfigured caller can split them — writing the run to one environment
while addressing the message to a deployment in another. The consumer
finds no run under its own tenant, the backend's resilient start
(`run_started` creates the run when `run_created` was never seen) mints a
second copy of the same run id in the consumer's environment, and both
copies are real: the creator's sits pending forever while the other
executes.
The deployment id is not the discriminator — it matched end to end in the
incident that motivated this. The environment is. So carry it: add an
optional `World.getEnvironment()`, implement it in world-vercel from the
same resolution that produces the `x-vercel-environment` header, and stamp
it into the queue message's `runInput`.
The consuming deployment already knows its own environment, so it can
refuse the delivery itself with no server coordination — and refuse before
`run_started`, the write that would create the fork. The refusal acks the
message instead of throwing: the mismatch is baked into the message, so
every redelivery would reach the same verdict and throwing would hot-loop
until MAX_QUEUE_DELIVERIES.
Both sides must be known for the check to run, so worlds with a single
tenant (local, Postgres) and runs started by an older SDK behave exactly
as before. A companion diagnostic logs a deployment-id mismatch without
refusing, since deployment ids differ for benign reasons too.
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
@pranaygp
pranaygpforce-pushed the pgp/runinput-environment-guard branch from 3021bcb to 2308af2CompareJuly 31, 2026 00:01
@pranaygppranaygp changed the title feat(core): stamp creator environment into runInput and fail cross-environment resilient starts fastfeat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-sideJul 31, 2026

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed as the third layer of the incident stack (with #3243 and vercel/wait-for-deployment-action#6), and against the earlier investigation of this same race from June (workbench-express, same mechanism: prod URL + preview dpl_ from the changeset-release branch). This is the right hardening and the design choices hold up under scrutiny:

  • Refusing before run_started is the only placement that prevents the fork rather than reporting it — verified the guard sits ahead of the event write and ahead of all attempt-dependent branching, with tests pinning that ordering.
  • Ack-and-discard over throwing is correct: the verdict is baked into the message, and it matches how the handler already treats EntityConflictError/RunExpiredError.
  • Fail-safe skip matrix is genuinely fail-safe: I checked each row (no getEnvironment on local/postgres, absent field from older SDKs, undefined from missing env vars) and none can produce a false refusal. Keeping producer stamp and consumer header derived from one resolveClientEnvironment is the load-bearing detail, and the agreement test locks it.
  • Wire compat claims check out: run_started eventData is built field-by-field so environment never reaches the server, and the unknown-key-stripping regression test protects the old-consumer direction.
  • The deployment-pinning check staying diagnostic-only is right, given dpl_local@<version> churn.

On CI: the Biome failure is the same one inherited from main (organizeImports in files whose only change here is additive imports — the two flagged files fail identically on main), and the two e2e failures are flakes unrelated to the guard: the local-prod lane doesn't implement getEnvironment at all, and the Vercel lane failure is a clock-skew sleep assertion in a test whose run executed (i.e. the guard matched and passed).

Does this make #3243 / #6 irrelevant? No — worth being explicit since it's been asked. This PR removes the corruption (no more forked run IDs, no cross-tenant ack noise), but a misconfigured caller still ends up with a stuck-pending run and a red e2e lane — just a diagnosable one. #3243 is still needed to stop CI from producing the mismatched pair in the first place, and the long-term fix for the action remains the vercel/api one identified in the June investigation (publish the dashboard URL in the env-scoped GitHub Deployment status so ID resolution is tokenless and exact), which supersedes #6's token path.

Two non-blocking notes inline. Approving.

Comment threadpackages/world-vercel/src/utils.ts Outdated
if (projectConfig) {
return projectConfig.environment || 'production';
}
return process.env.VERCEL_ENV || undefined;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Non-blocking question (custom environments): for a deployment in a Vercel custom environment, VERCEL_ENV reports preview and the custom environment's name lives in VERCEL_TARGET_ENV. Meanwhile the proxy path will happily stamp an arbitrary string from projectConfig.environment (e.g. staging). If the backend keys tenants on the custom-env name but this helper returns preview on the in-deployment path (or vice versa), the guard could refuse a legitimate delivery — the one failure mode this PR is otherwise careful to make impossible.

Given the interface doc's MUST ("match the attribution the backend will actually apply"), it's worth verifying what workflow-server actually attributes for (a) an OIDC client in a custom environment and (b) a proxy client sending a custom-env x-vercel-environment, and either incorporating VERCEL_TARGET_ENV here or documenting that custom environments are out of scope. Non-blocking because such a setup already forks today — the guard can only make it louder, not worse — but a wrong answer here turns into a false refusal rather than a skipped check.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Verified against the actual minting code in vercel/api, and you were right to flag it — VERCEL_ENV alone was wrong for custom environments, in exactly the false-refusal direction:

  • Runtime OIDC path: the deployment token's claim is minted as environment: customEnvironment?.slug ?? envTarget (services/api-deployments/src/services/gen-oidc-token.ts caller in create-deployment.ts), so a custom-env deployment is attributed by its slug, not preview — while VERCEL_ENV in that same process says preview.
  • Proxy path: x-vercel-environment accepts production, preview, or a custom environment's slug or ID, and the minted claim is always matchingEnv.slug (services/api-workflow/src/index.ts).
  • The fix (82bd72e): the in-deployment branch now returns VERCEL_TARGET_ENV || VERCEL_ENV. VERCEL_TARGET_ENV is populated from exactly the same pair as the claim (customEnvironmentSlug || projectEnvTarget in packages/projects-transforms/gen-system-envs.ts), so it matches the tenant key by construction, for standard and custom environments alike; VERCEL_ENV stays as the fallback where VERCEL_TARGET_ENV isn't injected (e.g. vercel dev). Tests cover the custom-env case and the standard-env no-op.

One residual documented rather than solved: on the projectConfig path the proxy accepts a custom env ID but attributes the slug, so configuring the ID would stamp a value the consumer can't match. The helper's doc now says to configure the slug; canonicalizing an ID client-side would need an API call, which didn't seem worth it for that corner.

return false;
}

runLogger.error(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Non-blocking (operational visibility): the refusal is logged only on the consuming deployment's runtime logs — the environment the operator is probably not looking at, since from their side the symptom is a run stuck pending in the creator's environment with no error anywhere on it. That's still strictly better than the fork, and I agree nothing more is possible client-side (the consumer can't write to the creator's tenant). Worth a follow-up though: this is exactly the case for the "carry the creator's environment in runInput so resilient start can detect/refuse server-side" hardening — the server can see both tenants and could mark the creator's run failed or surface a health annotation instead of leaving it pending forever.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Agreed on all points — the consumer-side refusal makes the fork impossible but leaves the creator's run pending with the evidence in the other environment's logs. Tracking the server-side layer as follow-up work; its ingredients are already mapped out from this incident:

  • workflow-server#689 (closed as superseded by this PR) contains a working implementation of the resilient-start guard and can be revived as the reporting/annotation layer you describe — the server is indeed the only place that can see both tenants and mark the creator's copy failed instead of pending-forever.
  • Two prerequisites discovered along the way: the v4 events wire rebuilds eventData from field allowlists (parseV4EventMeta/buildEventData), so runInput.environment needs explicit plumbing on both sides before the server can see it; and covering old SDKs at run_created time needs the deployment's target exposed from vercel/api's get-deployment-info endpoint (the Cosmos deployment doc it already loads carries it).

Until then, the creator-side symptom is at least the pre-existing one (stuck pending with "unhealthy deployment" health checks) rather than a silent fork, and the consumer-side error log names both environments and the runId for anyone who goes looking.

…ent-guard
* origin/main:
ci: stop deploying changeset-release/main, run its e2e against production (#3243)
fix(world-local): bound stalled queue deliveries (#3255)
Sort imports in runtime.ts and step-executor.ts (#3241)
…_ENV
For a deployment in a Vercel custom environment, the OIDC token's
environment claim is the custom environment's slug (the platform mints
`customEnvironment?.slug ?? envTarget`) while VERCEL_ENV reports
'preview' — so keying the cross-environment guard on VERCEL_ENV could
false-refuse a legitimate delivery, e.g. a CLI client attributed to
'staging' starting a run on the staging deployment. VERCEL_TARGET_ENV
is populated from exactly the same slug-or-target pair as the claim, so
prefer it, keeping VERCEL_ENV as the fallback for contexts that don't
inject it. Also sorts runtime.ts imports per the Biome rule that landed
on main in #3241.
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
@pranaygp
pranaygp merged commit ee944d2 into mainJul 31, 2026
176 of 178 checks passed
@pranaygp
pranaygp deleted the pgp/runinput-environment-guard branch July 31, 2026 22:53
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for ee944d2 (AI decision).

This adds new public API surface and wire format rather than fixing a defect in existing stable code: an optional World.getEnvironment() hook, a new optional environment field on RunInputSchema, a world-vercel implementation, and a new consumer-side guard that discards deliveries — all carrying minor changesets across three packages. Although motivated by a real incident, the fork was caused by caller misconfiguration (WORKFLOW_VERCEL_ENV pointing at a different environment than VERCEL_DEPLOYMENT_ID), and the change is a new defensive capability plus a new diagnostic, which is feature work by the backport criteria. If the maintenance line needs this protection, it can be forced through via workflow_dispatch after a human weighs the new API surface and behavior change on stable.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

ee944d2476daca81b89ba545b522385a7902ec03

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@pranaygp@TooTallNate
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side by pranaygp · Pull Request #3244 · vercel/workflow · GitHub
Skip to content

feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side - #3244

Merged
pranaygp merged 3 commits into
mainfrom
pgp/runinput-environment-guard
Jul 31, 2026
Merged

feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side#3244
pranaygp merged 3 commits into
mainfrom
pgp/runinput-environment-guard

Conversation

@pranaygp

@pranaygppranaygp commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

The bug

start() makes two writes that have to land in the same tenant:

  1. the run_created event, attributed to whatever environment the caller authenticates as, and
  2. the queue message, pinned to a deployment.

A misconfigured caller can split them — writing the run to one environment while addressing the message to a deployment in another. What happens next is not a failure, it is a fork:

  • the consuming deployment looks for the run under its own tenant and doesn't find it;
  • the backend's resilient start (run_started creates the run when run_created was never seen) mints a second copy of the same run id in the consumer's environment;
  • both copies are real. The creator's sits pending forever, the consumer's executes, and subsequent cross-tenant queue acks can't find their messages.

This happened on 2026-07-30: a CI harness ran with WORKFLOW_VERCEL_ENV=production while VERCEL_DEPLOYMENT_ID pointed at a preview deployment, and wrun_41KYTFGXEC0GPWVN8C0J0KG9ZK ended up existing in both environments of the same project, with two distinct run_created events 1.18s apart.

The deployment id is not the discriminator — it matched end to end in that incident. The environment is, and nothing on the wire carried it.

The fix

Carry the environment, then check it on the consumer.

  • @workflow/world — optional World.getEnvironment(): string | undefined (sync, side-effect free, styled after createRunId), and an optional environment on RunInputSchema.
  • @workflow/world-vercel — implements it. Both auth paths, one source of truth: with projectConfig (CLI/CI via the api.vercel.com proxy) the backend attributes the write to the x-vercel-environment header, so getEnvironment() and that header now derive from one shared resolveClientEnvironment() helper and cannot drift; without projectConfig (inside a deployment, OIDC) it reads VERCEL_ENV, which the platform mints the token's environment claim from.
  • @workflow/corestart() stamps the value into the queue message's runInput, and the queue handler refuses a delivery whose creator environment disagrees with its own.

Why the rejection is client-side

The consuming process already knows its own environment, so it needs nothing from the server to catch this. More importantly it can refuse before run_started — the write that would create the fork. Any check that happens after that write is too late; refusing before it means the resilient start never creates anything, and the creator's run is left pending in its own environment where a human can find it.

Why it acks instead of throwing

The mismatch is baked into this message: it is pinned to this deployment, and the creator's environment is a field in the payload. Every redelivery would reach the identical verdict, so throwing would hot-loop the handler until MAX_QUEUE_DELIVERIES and end with a misleading max-deliveries failure. Returning normally acks and discards, which is how the handler already treats deliveries whose verdict cannot change (EntityConflictError, RunExpiredError on the run_started path both log and return).

The error log names both environments, the run id, the pinned deployment, and the likely cause (WORKFLOW_VERCEL_ENV for CLI/CI clients, or the OIDC token's environment inside a deployment).

Fails safe, by construction

The check runs only when both sides are known, so nothing that works today changes behavior:

SituationrunInput.environmentworld.getEnvironment()Result
world-local / world-postgres consumeranynot implementedcheck skipped
run started by an older SDKabsentanycheck skipped
Vercel system env vars not exposedpresentundefinedcheck skipped
environments agreeproductionproductionexecutes normally
environments disagreeproductionpreviewrefused, logged, acked

undefined is deliberately preserved rather than defaulted: guessing 'production' in a bare Node process would fabricate a mismatch against a genuine preview deployment. A wrong answer here is worse than no answer.

Secondary: deployment-pinning diagnostic

Alongside it, when runInput.deploymentId and the handler's own getDeploymentId() are both known and differ, the handler logs an error with both ids and the run id — and continues.

Warn rather than refuse, because a differing deployment id is not by itself evidence of the fork. world-local derives its id from the installed package version (dpl_local@<version>), so upgrading the SDK mid-run changes it with nothing wrong; refusing on that signal would strand correct runs. The two checks live side by side and are individually tested, with the environment guard short-circuiting first so a cross-environment delivery produces one clear error rather than two.

Wire compatibility

Verified read-only against workflow-server and the SDK's own schemas.

  • New field, current server.run_started eventData is built field-by-field in runtime.ts, not spread from runInput, so environment never reaches the events API — it exists only in the queue payload. It would have been safe anyway: CreateEventSchemaV2.eventData is z.record(z.any()) and InitialRunAttributesSchema is .passthrough(), so an unknown key is accepted, not rejected.
  • New field, old SDK consumer.RunInputSchema, WorkflowInvokePayloadSchema, and QueuePayloadSchema are all plain z.object — zod strips unknown keys instead of rejecting them, so a message from a new producer is consumed normally by an older deployment mid-rollout (it just skips the check). Pinned by a regression test so nobody makes these strict later.

No deviation from the intended design was needed.

Tests

  • packages/world/src/queue.test.tsRunInputSchema round-trip, absent field, non-string rejection, unknown-key stripping.
  • packages/world-vercel/src/utils.test.tsresolveClientEnvironment matrix, plus an agreement test asserting it always equals the x-vercel-environment header for the same config, so the two can't drift.
  • packages/core/src/runtime/start.test.ts — stamps the environment, omits it when undefined, omits it when the world doesn't implement the hook.
  • packages/core/src/runtime/cross-environment-delivery.test.ts — mismatch produces no events at all (no run_started), acks with 204, logs both environments; refuses on a redelivery too; matching environments execute normally; each skip case (field absent, hook absent, hook returns undefined, re-enqueued message) executes normally; plus the deployment-pinning diagnostic and its ordering against the guard.

pnpm typecheck passes (41/41 packages). @workflow/core 1671 passed + 3 expected-fail, @workflow/world 84 passed, @workflow/world-vercel 322 passed.

pnpm lint reports 2 pre-existing errors on main (nested biome.json files declaring $schema 2.0.0 against CLI 2.4.4) — confirmed present on origin/main with these changes stashed, and untouched here.

🤖 Generated with Claude Code

@pranaygp
pranaygp requested a review from ijjk as a code ownerJuly 30, 2026 23:45
CopilotAI review requested due to automatic review settings July 30, 2026 23:45
@pranaygp
pranaygp requested a review from a team as a code ownerJuly 30, 2026 23:45
@changeset-bot

changeset-botBot commented Jul 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 82bd72e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
NameType
@workflow/coreMinor
@workflow/worldMinor
@workflow/world-vercelMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowMinor
@workflow/world-testingPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewJul 31, 2026 6:02pm
example-nextjs-workflow-webpackReadyReadyPreviewJul 31, 2026 6:02pm
example-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-astro-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-express-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-fastify-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-hono-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-nestjs-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-nitro-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-nuxt-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-sveltekit-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-tanstack-start-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-vite-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workflow-docsReadyReadyPreview, v0Jul 31, 2026 6:02pm
workflow-swc-playgroundReadyReadyPreviewJul 31, 2026 6:02pm
workflow-tarballsReadyReadyPreviewJul 31, 2026 6:02pm
workflow-webReadyReadyPreviewJul 31, 2026 6:02pm

@github-actions

github-actionsBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 82bd72e · Fri, 31 Jul 2026 18:22:36 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1317 (+401%) 🔻1402 🔴 (-2.3%)1439 🔴 (-11%)1539 🔴 (-23%) 💚30
TTFSstream1287 (+421%) 🔻1365 🔴 (-2.6%)1421 🔴 (-9.7%)1525 🔴 (-25%) 💚30
TTFShook + stream1582 (+294%) 🔻1690 🔴 (+30%) 🔻1721 🔴 (+27%) 🔻1837 🔴 (+25%) 🔻30
STSO1020 steps (inline)203 (+15%)503 (-2.1%)560 (-3.1%)796 (+1.9%)1016
STSO1020 steps (queue-hop)2365 (+59%) 🔻3496 (+25%) 🔻3496 (+25%) 🔻3496 (+25%) 🔻3
WO1020 steps434394 (+0.5%)434394 (+0.5%)434394 (+0.5%)434394 (+0.5%)1
SLstream latency130 (+48%) 🔻172 🔴 (+8.9%)192 🔴 (+12%)270 🔴 (+37%) 🔻30
SOstream overhead (text)128 (-8.6%)214 (±0%)243 (+7.5%)324 (-35%) 💚30
SOstream overhead (structured)147 (+9.7%)198 (-27%) 💚238 (-32%) 💚310 (-34%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 424581ms → this run 423919ms (Δ -662ms, 0%)

 150-200 ms ┃ main 10 this 0 -10
200-250 ms █████████┃██ main 84 this 67 -17
250-300 ms █████████████████┃ main 123 this 120 -3
300-350 ms ████████████████████┃ main 140 this 144 +4
350-400 ms █████████████████████░░┃ main 141 this 163 +22
400-450 ms ████████████████░░░┃ main 107 this 135 +28
450-500 ms █████████████████┃ main 120 this 125 +5
500-550 ms ███████████████████░┃ main 132 this 140 +8
550-600 ms █████████┃███ main 85 this 68 -17
600-650 ms ███┃█ main 33 this 25 -8
650-700 ms █┃█ main 20 this 13 -7
700-750 ms ┃ main 8 this 5 -3
750-800 ms ┃ main 7 this 3 -4
800-850 ms ┃ main 2 this 1 -1
850-900 ms ┃ main 1 this 2 +1
950-1000 ms ┃ main 0 this 2 +2
1000-1050 ms ┃ main 1 this 1 +0
1050-1100 ms ┃ main 1 this 0 -1
1100-1150 ms ┃ main 0 this 1 +1
1300-1350 ms ┃ main 1 this 0 -1
1600-1650 ms ┃ main 0 this 1 +1

1020 steps (queue-hop)

Cumulative STSO time: main 6430ms → this run 9083ms (Δ +2653ms, +41%)

1000-1500 ms ┃███████████ main 1 this 0 -1
2000-2500 ms ███████████┃ main 1 this 1 +0
2500-3000 ms ┃███████████ main 1 this 0 -1
3000-3500 ms ░░░░░░░░░░░░░░░░░░░░░░░┃ main 0 this 2 +2
📜 Previous results (1)

2308af2

Fri, 31 Jul 2026 00:25:05 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep261 (-74%) 💚1360 🔴 (+20%) 🔻1393 🔴 (+20%) 🔻1431 🔴 (+13%)30
TTFSstream232 (+13%)1365 🔴 (+24%) 🔻1394 🔴 (+23%) 🔻1435 🔴 (-12%)30
TTFShook + stream369 (-72%) 💚1606 🔴 (+12%)1627 🔴 (+11%)1888 🔴 (+15%) 🔻30
STSO1020 steps (inline)178 (-3.3%)492 (-5.2%)544 (-5.4%)737 (-8.8%)1016
STSO1020 steps (queue-hop)2271 (+12%)2910 (-38%) 💚2910 (-38%) 💚2910 (-38%) 💚3
WO1020 steps421233 (-6.6%)421233 (-6.6%)421233 (-6.6%)421233 (-6.6%)1
SLstream latency109 (-2.7%)182 🔴 (+9.6%)208 🔴 (±0%)435 🔴 (+12%)30
SOstream overhead (text)124 (-6.1%)258 🔴 (-9.8%)373 (-24%) 💚2528 🔴 (-8.2%)30
SOstream overhead (structured)110 (-17%) 💚207 (-26%) 💚256 (-34%) 💚344 (-78%) 💚30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7519011328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

CopilotAI left a comment

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.

Pull request overview

Adds a cross-environment “resilient start” guard by stamping the creator’s environment into queued runInput, wiring it through the v4 events meta path, and treating server rejections for environment mismatch as terminal (ack, don’t retry) to prevent the same wrun_ from being created in two different Vercel environments.

Changes:

  • Introduces World.getEnvironment?() and carries an optional environment field through RunInput and run_started event data.
  • Implements environment resolution in @workflow/world-vercel via a shared resolveClientEnvironment() used both for x-vercel-environment and getEnvironment(), and forwards environment through v4 frame meta.
  • Updates the core runtime to (a) stamp creator environment at start(), (b) log deployment pin mismatches diagnostically, and (c) ack queue messages on run_environment_mismatch rejections instead of retrying.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated no comments.

Show a summary per file
FileDescription
packages/world/src/queue.tsAdds optional environment field to RunInputSchema.
packages/world/src/queue.test.tsAdds schema coverage for environment round-trip and unknown-key stripping behavior.
packages/world/src/interfaces.tsAdds optional World.getEnvironment?() hook contract.
packages/world/src/events.tsExtends run_started event data with optional environment.
packages/world-vercel/src/utils.tsAdds resolveClientEnvironment() and reuses it for x-vercel-environment header generation.
packages/world-vercel/src/utils.test.tsTests environment resolution matrix and header agreement.
packages/world-vercel/src/index.tsImplements getEnvironment() for world-vercel using resolveClientEnvironment().
packages/world-vercel/src/events.tsRoutes environment through v4 meta splitting allowlist.
packages/world-vercel/src/events.test.tsTests splitEventDataForV4 includes/omits environment appropriately.
packages/world-vercel/src/events-v4.tsAdds environment to v4 frame meta and fixes error-code extraction to read error field.
packages/world-vercel/src/events-v4.test.tsTests error-field fallback and meta wire round-trip for environment.
packages/core/src/runtime/start.tsStamps world.getEnvironment?.() into queued runInput.environment.
packages/core/src/runtime/start.test.tsTests environment stamping/omission behavior.
packages/core/src/runtime/cross-environment-start.test.tsAdds end-to-end runtime handler tests for deployment pin diagnostics + cross-env 422 ack behavior (including turbo behavior).
packages/core/src/runtime.tsAdds deployment pin mismatch diagnostic + terminal handling for cross-env resilient start rejection.
packages/core/src/classify-error.tsAdds isCrossEnvironmentStartRejection and constant for the server error code.
packages/core/src/classify-error.test.tsAdds classification/negative tests for cross-environment rejection detection.
.changeset/world-vercel-get-environment.mdChangeset for @workflow/world-vercel additions/behavior changes.
.changeset/world-get-environment.mdChangeset for @workflow/world interface/schema additions.
.changeset/core-cross-environment-start-guard.mdChangeset for @workflow/core runtime behavior change.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…environment queue deliveries client-side
`start()` makes two writes that have to land in the same tenant: the
`run_created` event, attributed to whatever environment the caller
authenticates as, and the queue message, pinned to a deployment. A
misconfigured caller can split them — writing the run to one environment
while addressing the message to a deployment in another. The consumer
finds no run under its own tenant, the backend's resilient start
(`run_started` creates the run when `run_created` was never seen) mints a
second copy of the same run id in the consumer's environment, and both
copies are real: the creator's sits pending forever while the other
executes.
The deployment id is not the discriminator — it matched end to end in the
incident that motivated this. The environment is. So carry it: add an
optional `World.getEnvironment()`, implement it in world-vercel from the
same resolution that produces the `x-vercel-environment` header, and stamp
it into the queue message's `runInput`.
The consuming deployment already knows its own environment, so it can
refuse the delivery itself with no server coordination — and refuse before
`run_started`, the write that would create the fork. The refusal acks the
message instead of throwing: the mismatch is baked into the message, so
every redelivery would reach the same verdict and throwing would hot-loop
until MAX_QUEUE_DELIVERIES.
Both sides must be known for the check to run, so worlds with a single
tenant (local, Postgres) and runs started by an older SDK behave exactly
as before. A companion diagnostic logs a deployment-id mismatch without
refusing, since deployment ids differ for benign reasons too.
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
@pranaygp
pranaygpforce-pushed the pgp/runinput-environment-guard branch from 3021bcb to 2308af2CompareJuly 31, 2026 00:01
@pranaygppranaygp changed the title feat(core): stamp creator environment into runInput and fail cross-environment resilient starts fastfeat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-sideJul 31, 2026

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed as the third layer of the incident stack (with #3243 and vercel/wait-for-deployment-action#6), and against the earlier investigation of this same race from June (workbench-express, same mechanism: prod URL + preview dpl_ from the changeset-release branch). This is the right hardening and the design choices hold up under scrutiny:

  • Refusing before run_started is the only placement that prevents the fork rather than reporting it — verified the guard sits ahead of the event write and ahead of all attempt-dependent branching, with tests pinning that ordering.
  • Ack-and-discard over throwing is correct: the verdict is baked into the message, and it matches how the handler already treats EntityConflictError/RunExpiredError.
  • Fail-safe skip matrix is genuinely fail-safe: I checked each row (no getEnvironment on local/postgres, absent field from older SDKs, undefined from missing env vars) and none can produce a false refusal. Keeping producer stamp and consumer header derived from one resolveClientEnvironment is the load-bearing detail, and the agreement test locks it.
  • Wire compat claims check out: run_started eventData is built field-by-field so environment never reaches the server, and the unknown-key-stripping regression test protects the old-consumer direction.
  • The deployment-pinning check staying diagnostic-only is right, given dpl_local@<version> churn.

On CI: the Biome failure is the same one inherited from main (organizeImports in files whose only change here is additive imports — the two flagged files fail identically on main), and the two e2e failures are flakes unrelated to the guard: the local-prod lane doesn't implement getEnvironment at all, and the Vercel lane failure is a clock-skew sleep assertion in a test whose run executed (i.e. the guard matched and passed).

Does this make #3243 / #6 irrelevant? No — worth being explicit since it's been asked. This PR removes the corruption (no more forked run IDs, no cross-tenant ack noise), but a misconfigured caller still ends up with a stuck-pending run and a red e2e lane — just a diagnosable one. #3243 is still needed to stop CI from producing the mismatched pair in the first place, and the long-term fix for the action remains the vercel/api one identified in the June investigation (publish the dashboard URL in the env-scoped GitHub Deployment status so ID resolution is tokenless and exact), which supersedes #6's token path.

Two non-blocking notes inline. Approving.

Comment threadpackages/world-vercel/src/utils.ts Outdated
if (projectConfig) {
return projectConfig.environment || 'production';
}
return process.env.VERCEL_ENV || undefined;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Non-blocking question (custom environments): for a deployment in a Vercel custom environment, VERCEL_ENV reports preview and the custom environment's name lives in VERCEL_TARGET_ENV. Meanwhile the proxy path will happily stamp an arbitrary string from projectConfig.environment (e.g. staging). If the backend keys tenants on the custom-env name but this helper returns preview on the in-deployment path (or vice versa), the guard could refuse a legitimate delivery — the one failure mode this PR is otherwise careful to make impossible.

Given the interface doc's MUST ("match the attribution the backend will actually apply"), it's worth verifying what workflow-server actually attributes for (a) an OIDC client in a custom environment and (b) a proxy client sending a custom-env x-vercel-environment, and either incorporating VERCEL_TARGET_ENV here or documenting that custom environments are out of scope. Non-blocking because such a setup already forks today — the guard can only make it louder, not worse — but a wrong answer here turns into a false refusal rather than a skipped check.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Verified against the actual minting code in vercel/api, and you were right to flag it — VERCEL_ENV alone was wrong for custom environments, in exactly the false-refusal direction:

  • Runtime OIDC path: the deployment token's claim is minted as environment: customEnvironment?.slug ?? envTarget (services/api-deployments/src/services/gen-oidc-token.ts caller in create-deployment.ts), so a custom-env deployment is attributed by its slug, not preview — while VERCEL_ENV in that same process says preview.
  • Proxy path: x-vercel-environment accepts production, preview, or a custom environment's slug or ID, and the minted claim is always matchingEnv.slug (services/api-workflow/src/index.ts).
  • The fix (82bd72e): the in-deployment branch now returns VERCEL_TARGET_ENV || VERCEL_ENV. VERCEL_TARGET_ENV is populated from exactly the same pair as the claim (customEnvironmentSlug || projectEnvTarget in packages/projects-transforms/gen-system-envs.ts), so it matches the tenant key by construction, for standard and custom environments alike; VERCEL_ENV stays as the fallback where VERCEL_TARGET_ENV isn't injected (e.g. vercel dev). Tests cover the custom-env case and the standard-env no-op.

One residual documented rather than solved: on the projectConfig path the proxy accepts a custom env ID but attributes the slug, so configuring the ID would stamp a value the consumer can't match. The helper's doc now says to configure the slug; canonicalizing an ID client-side would need an API call, which didn't seem worth it for that corner.

return false;
}

runLogger.error(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Non-blocking (operational visibility): the refusal is logged only on the consuming deployment's runtime logs — the environment the operator is probably not looking at, since from their side the symptom is a run stuck pending in the creator's environment with no error anywhere on it. That's still strictly better than the fork, and I agree nothing more is possible client-side (the consumer can't write to the creator's tenant). Worth a follow-up though: this is exactly the case for the "carry the creator's environment in runInput so resilient start can detect/refuse server-side" hardening — the server can see both tenants and could mark the creator's run failed or surface a health annotation instead of leaving it pending forever.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Agreed on all points — the consumer-side refusal makes the fork impossible but leaves the creator's run pending with the evidence in the other environment's logs. Tracking the server-side layer as follow-up work; its ingredients are already mapped out from this incident:

  • workflow-server#689 (closed as superseded by this PR) contains a working implementation of the resilient-start guard and can be revived as the reporting/annotation layer you describe — the server is indeed the only place that can see both tenants and mark the creator's copy failed instead of pending-forever.
  • Two prerequisites discovered along the way: the v4 events wire rebuilds eventData from field allowlists (parseV4EventMeta/buildEventData), so runInput.environment needs explicit plumbing on both sides before the server can see it; and covering old SDKs at run_created time needs the deployment's target exposed from vercel/api's get-deployment-info endpoint (the Cosmos deployment doc it already loads carries it).

Until then, the creator-side symptom is at least the pre-existing one (stuck pending with "unhealthy deployment" health checks) rather than a silent fork, and the consumer-side error log names both environments and the runId for anyone who goes looking.

…ent-guard
* origin/main:
ci: stop deploying changeset-release/main, run its e2e against production (#3243)
fix(world-local): bound stalled queue deliveries (#3255)
Sort imports in runtime.ts and step-executor.ts (#3241)
…_ENV
For a deployment in a Vercel custom environment, the OIDC token's
environment claim is the custom environment's slug (the platform mints
`customEnvironment?.slug ?? envTarget`) while VERCEL_ENV reports
'preview' — so keying the cross-environment guard on VERCEL_ENV could
false-refuse a legitimate delivery, e.g. a CLI client attributed to
'staging' starting a run on the staging deployment. VERCEL_TARGET_ENV
is populated from exactly the same slug-or-target pair as the claim, so
prefer it, keeping VERCEL_ENV as the fallback for contexts that don't
inject it. Also sorts runtime.ts imports per the Biome rule that landed
on main in #3241.
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
@pranaygp
pranaygp merged commit ee944d2 into mainJul 31, 2026
176 of 178 checks passed
@pranaygp
pranaygp deleted the pgp/runinput-environment-guard branch July 31, 2026 22:53
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for ee944d2 (AI decision).

This adds new public API surface and wire format rather than fixing a defect in existing stable code: an optional World.getEnvironment() hook, a new optional environment field on RunInputSchema, a world-vercel implementation, and a new consumer-side guard that discards deliveries — all carrying minor changesets across three packages. Although motivated by a real incident, the fork was caused by caller misconfiguration (WORKFLOW_VERCEL_ENV pointing at a different environment than VERCEL_DEPLOYMENT_ID), and the change is a new defensive capability plus a new diagnostic, which is feature work by the backport criteria. If the maintenance line needs this protection, it can be forced through via workflow_dispatch after a human weighs the new API surface and behavior change on stable.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

ee944d2476daca81b89ba545b522385a7902ec03

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@pranaygp@TooTallNate
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side by pranaygp · Pull Request #3244 · vercel/workflow · GitHub
Skip to content

feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side - #3244

Merged
pranaygp merged 3 commits into
mainfrom
pgp/runinput-environment-guard
Jul 31, 2026
Merged

feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side#3244
pranaygp merged 3 commits into
mainfrom
pgp/runinput-environment-guard

Conversation

@pranaygp

@pranaygppranaygp commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

The bug

start() makes two writes that have to land in the same tenant:

  1. the run_created event, attributed to whatever environment the caller authenticates as, and
  2. the queue message, pinned to a deployment.

A misconfigured caller can split them — writing the run to one environment while addressing the message to a deployment in another. What happens next is not a failure, it is a fork:

  • the consuming deployment looks for the run under its own tenant and doesn't find it;
  • the backend's resilient start (run_started creates the run when run_created was never seen) mints a second copy of the same run id in the consumer's environment;
  • both copies are real. The creator's sits pending forever, the consumer's executes, and subsequent cross-tenant queue acks can't find their messages.

This happened on 2026-07-30: a CI harness ran with WORKFLOW_VERCEL_ENV=production while VERCEL_DEPLOYMENT_ID pointed at a preview deployment, and wrun_41KYTFGXEC0GPWVN8C0J0KG9ZK ended up existing in both environments of the same project, with two distinct run_created events 1.18s apart.

The deployment id is not the discriminator — it matched end to end in that incident. The environment is, and nothing on the wire carried it.

The fix

Carry the environment, then check it on the consumer.

  • @workflow/world — optional World.getEnvironment(): string | undefined (sync, side-effect free, styled after createRunId), and an optional environment on RunInputSchema.
  • @workflow/world-vercel — implements it. Both auth paths, one source of truth: with projectConfig (CLI/CI via the api.vercel.com proxy) the backend attributes the write to the x-vercel-environment header, so getEnvironment() and that header now derive from one shared resolveClientEnvironment() helper and cannot drift; without projectConfig (inside a deployment, OIDC) it reads VERCEL_ENV, which the platform mints the token's environment claim from.
  • @workflow/corestart() stamps the value into the queue message's runInput, and the queue handler refuses a delivery whose creator environment disagrees with its own.

Why the rejection is client-side

The consuming process already knows its own environment, so it needs nothing from the server to catch this. More importantly it can refuse before run_started — the write that would create the fork. Any check that happens after that write is too late; refusing before it means the resilient start never creates anything, and the creator's run is left pending in its own environment where a human can find it.

Why it acks instead of throwing

The mismatch is baked into this message: it is pinned to this deployment, and the creator's environment is a field in the payload. Every redelivery would reach the identical verdict, so throwing would hot-loop the handler until MAX_QUEUE_DELIVERIES and end with a misleading max-deliveries failure. Returning normally acks and discards, which is how the handler already treats deliveries whose verdict cannot change (EntityConflictError, RunExpiredError on the run_started path both log and return).

The error log names both environments, the run id, the pinned deployment, and the likely cause (WORKFLOW_VERCEL_ENV for CLI/CI clients, or the OIDC token's environment inside a deployment).

Fails safe, by construction

The check runs only when both sides are known, so nothing that works today changes behavior:

SituationrunInput.environmentworld.getEnvironment()Result
world-local / world-postgres consumeranynot implementedcheck skipped
run started by an older SDKabsentanycheck skipped
Vercel system env vars not exposedpresentundefinedcheck skipped
environments agreeproductionproductionexecutes normally
environments disagreeproductionpreviewrefused, logged, acked

undefined is deliberately preserved rather than defaulted: guessing 'production' in a bare Node process would fabricate a mismatch against a genuine preview deployment. A wrong answer here is worse than no answer.

Secondary: deployment-pinning diagnostic

Alongside it, when runInput.deploymentId and the handler's own getDeploymentId() are both known and differ, the handler logs an error with both ids and the run id — and continues.

Warn rather than refuse, because a differing deployment id is not by itself evidence of the fork. world-local derives its id from the installed package version (dpl_local@<version>), so upgrading the SDK mid-run changes it with nothing wrong; refusing on that signal would strand correct runs. The two checks live side by side and are individually tested, with the environment guard short-circuiting first so a cross-environment delivery produces one clear error rather than two.

Wire compatibility

Verified read-only against workflow-server and the SDK's own schemas.

  • New field, current server.run_started eventData is built field-by-field in runtime.ts, not spread from runInput, so environment never reaches the events API — it exists only in the queue payload. It would have been safe anyway: CreateEventSchemaV2.eventData is z.record(z.any()) and InitialRunAttributesSchema is .passthrough(), so an unknown key is accepted, not rejected.
  • New field, old SDK consumer.RunInputSchema, WorkflowInvokePayloadSchema, and QueuePayloadSchema are all plain z.object — zod strips unknown keys instead of rejecting them, so a message from a new producer is consumed normally by an older deployment mid-rollout (it just skips the check). Pinned by a regression test so nobody makes these strict later.

No deviation from the intended design was needed.

Tests

  • packages/world/src/queue.test.tsRunInputSchema round-trip, absent field, non-string rejection, unknown-key stripping.
  • packages/world-vercel/src/utils.test.tsresolveClientEnvironment matrix, plus an agreement test asserting it always equals the x-vercel-environment header for the same config, so the two can't drift.
  • packages/core/src/runtime/start.test.ts — stamps the environment, omits it when undefined, omits it when the world doesn't implement the hook.
  • packages/core/src/runtime/cross-environment-delivery.test.ts — mismatch produces no events at all (no run_started), acks with 204, logs both environments; refuses on a redelivery too; matching environments execute normally; each skip case (field absent, hook absent, hook returns undefined, re-enqueued message) executes normally; plus the deployment-pinning diagnostic and its ordering against the guard.

pnpm typecheck passes (41/41 packages). @workflow/core 1671 passed + 3 expected-fail, @workflow/world 84 passed, @workflow/world-vercel 322 passed.

pnpm lint reports 2 pre-existing errors on main (nested biome.json files declaring $schema 2.0.0 against CLI 2.4.4) — confirmed present on origin/main with these changes stashed, and untouched here.

🤖 Generated with Claude Code

@pranaygp
pranaygp requested a review from ijjk as a code ownerJuly 30, 2026 23:45
CopilotAI review requested due to automatic review settings July 30, 2026 23:45
@pranaygp
pranaygp requested a review from a team as a code ownerJuly 30, 2026 23:45
@changeset-bot

changeset-botBot commented Jul 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 82bd72e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
NameType
@workflow/coreMinor
@workflow/worldMinor
@workflow/world-vercelMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowMinor
@workflow/world-testingPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewJul 31, 2026 6:02pm
example-nextjs-workflow-webpackReadyReadyPreviewJul 31, 2026 6:02pm
example-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-astro-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-express-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-fastify-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-hono-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-nestjs-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-nitro-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-nuxt-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-sveltekit-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-tanstack-start-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-vite-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workflow-docsReadyReadyPreview, v0Jul 31, 2026 6:02pm
workflow-swc-playgroundReadyReadyPreviewJul 31, 2026 6:02pm
workflow-tarballsReadyReadyPreviewJul 31, 2026 6:02pm
workflow-webReadyReadyPreviewJul 31, 2026 6:02pm

@github-actions

github-actionsBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 82bd72e · Fri, 31 Jul 2026 18:22:36 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1317 (+401%) 🔻1402 🔴 (-2.3%)1439 🔴 (-11%)1539 🔴 (-23%) 💚30
TTFSstream1287 (+421%) 🔻1365 🔴 (-2.6%)1421 🔴 (-9.7%)1525 🔴 (-25%) 💚30
TTFShook + stream1582 (+294%) 🔻1690 🔴 (+30%) 🔻1721 🔴 (+27%) 🔻1837 🔴 (+25%) 🔻30
STSO1020 steps (inline)203 (+15%)503 (-2.1%)560 (-3.1%)796 (+1.9%)1016
STSO1020 steps (queue-hop)2365 (+59%) 🔻3496 (+25%) 🔻3496 (+25%) 🔻3496 (+25%) 🔻3
WO1020 steps434394 (+0.5%)434394 (+0.5%)434394 (+0.5%)434394 (+0.5%)1
SLstream latency130 (+48%) 🔻172 🔴 (+8.9%)192 🔴 (+12%)270 🔴 (+37%) 🔻30
SOstream overhead (text)128 (-8.6%)214 (±0%)243 (+7.5%)324 (-35%) 💚30
SOstream overhead (structured)147 (+9.7%)198 (-27%) 💚238 (-32%) 💚310 (-34%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 424581ms → this run 423919ms (Δ -662ms, 0%)

 150-200 ms ┃ main 10 this 0 -10
200-250 ms █████████┃██ main 84 this 67 -17
250-300 ms █████████████████┃ main 123 this 120 -3
300-350 ms ████████████████████┃ main 140 this 144 +4
350-400 ms █████████████████████░░┃ main 141 this 163 +22
400-450 ms ████████████████░░░┃ main 107 this 135 +28
450-500 ms █████████████████┃ main 120 this 125 +5
500-550 ms ███████████████████░┃ main 132 this 140 +8
550-600 ms █████████┃███ main 85 this 68 -17
600-650 ms ███┃█ main 33 this 25 -8
650-700 ms █┃█ main 20 this 13 -7
700-750 ms ┃ main 8 this 5 -3
750-800 ms ┃ main 7 this 3 -4
800-850 ms ┃ main 2 this 1 -1
850-900 ms ┃ main 1 this 2 +1
950-1000 ms ┃ main 0 this 2 +2
1000-1050 ms ┃ main 1 this 1 +0
1050-1100 ms ┃ main 1 this 0 -1
1100-1150 ms ┃ main 0 this 1 +1
1300-1350 ms ┃ main 1 this 0 -1
1600-1650 ms ┃ main 0 this 1 +1

1020 steps (queue-hop)

Cumulative STSO time: main 6430ms → this run 9083ms (Δ +2653ms, +41%)

1000-1500 ms ┃███████████ main 1 this 0 -1
2000-2500 ms ███████████┃ main 1 this 1 +0
2500-3000 ms ┃███████████ main 1 this 0 -1
3000-3500 ms ░░░░░░░░░░░░░░░░░░░░░░░┃ main 0 this 2 +2
📜 Previous results (1)

2308af2

Fri, 31 Jul 2026 00:25:05 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep261 (-74%) 💚1360 🔴 (+20%) 🔻1393 🔴 (+20%) 🔻1431 🔴 (+13%)30
TTFSstream232 (+13%)1365 🔴 (+24%) 🔻1394 🔴 (+23%) 🔻1435 🔴 (-12%)30
TTFShook + stream369 (-72%) 💚1606 🔴 (+12%)1627 🔴 (+11%)1888 🔴 (+15%) 🔻30
STSO1020 steps (inline)178 (-3.3%)492 (-5.2%)544 (-5.4%)737 (-8.8%)1016
STSO1020 steps (queue-hop)2271 (+12%)2910 (-38%) 💚2910 (-38%) 💚2910 (-38%) 💚3
WO1020 steps421233 (-6.6%)421233 (-6.6%)421233 (-6.6%)421233 (-6.6%)1
SLstream latency109 (-2.7%)182 🔴 (+9.6%)208 🔴 (±0%)435 🔴 (+12%)30
SOstream overhead (text)124 (-6.1%)258 🔴 (-9.8%)373 (-24%) 💚2528 🔴 (-8.2%)30
SOstream overhead (structured)110 (-17%) 💚207 (-26%) 💚256 (-34%) 💚344 (-78%) 💚30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7519011328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

CopilotAI left a comment

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.

Pull request overview

Adds a cross-environment “resilient start” guard by stamping the creator’s environment into queued runInput, wiring it through the v4 events meta path, and treating server rejections for environment mismatch as terminal (ack, don’t retry) to prevent the same wrun_ from being created in two different Vercel environments.

Changes:

  • Introduces World.getEnvironment?() and carries an optional environment field through RunInput and run_started event data.
  • Implements environment resolution in @workflow/world-vercel via a shared resolveClientEnvironment() used both for x-vercel-environment and getEnvironment(), and forwards environment through v4 frame meta.
  • Updates the core runtime to (a) stamp creator environment at start(), (b) log deployment pin mismatches diagnostically, and (c) ack queue messages on run_environment_mismatch rejections instead of retrying.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated no comments.

Show a summary per file
FileDescription
packages/world/src/queue.tsAdds optional environment field to RunInputSchema.
packages/world/src/queue.test.tsAdds schema coverage for environment round-trip and unknown-key stripping behavior.
packages/world/src/interfaces.tsAdds optional World.getEnvironment?() hook contract.
packages/world/src/events.tsExtends run_started event data with optional environment.
packages/world-vercel/src/utils.tsAdds resolveClientEnvironment() and reuses it for x-vercel-environment header generation.
packages/world-vercel/src/utils.test.tsTests environment resolution matrix and header agreement.
packages/world-vercel/src/index.tsImplements getEnvironment() for world-vercel using resolveClientEnvironment().
packages/world-vercel/src/events.tsRoutes environment through v4 meta splitting allowlist.
packages/world-vercel/src/events.test.tsTests splitEventDataForV4 includes/omits environment appropriately.
packages/world-vercel/src/events-v4.tsAdds environment to v4 frame meta and fixes error-code extraction to read error field.
packages/world-vercel/src/events-v4.test.tsTests error-field fallback and meta wire round-trip for environment.
packages/core/src/runtime/start.tsStamps world.getEnvironment?.() into queued runInput.environment.
packages/core/src/runtime/start.test.tsTests environment stamping/omission behavior.
packages/core/src/runtime/cross-environment-start.test.tsAdds end-to-end runtime handler tests for deployment pin diagnostics + cross-env 422 ack behavior (including turbo behavior).
packages/core/src/runtime.tsAdds deployment pin mismatch diagnostic + terminal handling for cross-env resilient start rejection.
packages/core/src/classify-error.tsAdds isCrossEnvironmentStartRejection and constant for the server error code.
packages/core/src/classify-error.test.tsAdds classification/negative tests for cross-environment rejection detection.
.changeset/world-vercel-get-environment.mdChangeset for @workflow/world-vercel additions/behavior changes.
.changeset/world-get-environment.mdChangeset for @workflow/world interface/schema additions.
.changeset/core-cross-environment-start-guard.mdChangeset for @workflow/core runtime behavior change.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…environment queue deliveries client-side
`start()` makes two writes that have to land in the same tenant: the
`run_created` event, attributed to whatever environment the caller
authenticates as, and the queue message, pinned to a deployment. A
misconfigured caller can split them — writing the run to one environment
while addressing the message to a deployment in another. The consumer
finds no run under its own tenant, the backend's resilient start
(`run_started` creates the run when `run_created` was never seen) mints a
second copy of the same run id in the consumer's environment, and both
copies are real: the creator's sits pending forever while the other
executes.
The deployment id is not the discriminator — it matched end to end in the
incident that motivated this. The environment is. So carry it: add an
optional `World.getEnvironment()`, implement it in world-vercel from the
same resolution that produces the `x-vercel-environment` header, and stamp
it into the queue message's `runInput`.
The consuming deployment already knows its own environment, so it can
refuse the delivery itself with no server coordination — and refuse before
`run_started`, the write that would create the fork. The refusal acks the
message instead of throwing: the mismatch is baked into the message, so
every redelivery would reach the same verdict and throwing would hot-loop
until MAX_QUEUE_DELIVERIES.
Both sides must be known for the check to run, so worlds with a single
tenant (local, Postgres) and runs started by an older SDK behave exactly
as before. A companion diagnostic logs a deployment-id mismatch without
refusing, since deployment ids differ for benign reasons too.
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
@pranaygp
pranaygpforce-pushed the pgp/runinput-environment-guard branch from 3021bcb to 2308af2CompareJuly 31, 2026 00:01
@pranaygppranaygp changed the title feat(core): stamp creator environment into runInput and fail cross-environment resilient starts fastfeat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-sideJul 31, 2026

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed as the third layer of the incident stack (with #3243 and vercel/wait-for-deployment-action#6), and against the earlier investigation of this same race from June (workbench-express, same mechanism: prod URL + preview dpl_ from the changeset-release branch). This is the right hardening and the design choices hold up under scrutiny:

  • Refusing before run_started is the only placement that prevents the fork rather than reporting it — verified the guard sits ahead of the event write and ahead of all attempt-dependent branching, with tests pinning that ordering.
  • Ack-and-discard over throwing is correct: the verdict is baked into the message, and it matches how the handler already treats EntityConflictError/RunExpiredError.
  • Fail-safe skip matrix is genuinely fail-safe: I checked each row (no getEnvironment on local/postgres, absent field from older SDKs, undefined from missing env vars) and none can produce a false refusal. Keeping producer stamp and consumer header derived from one resolveClientEnvironment is the load-bearing detail, and the agreement test locks it.
  • Wire compat claims check out: run_started eventData is built field-by-field so environment never reaches the server, and the unknown-key-stripping regression test protects the old-consumer direction.
  • The deployment-pinning check staying diagnostic-only is right, given dpl_local@<version> churn.

On CI: the Biome failure is the same one inherited from main (organizeImports in files whose only change here is additive imports — the two flagged files fail identically on main), and the two e2e failures are flakes unrelated to the guard: the local-prod lane doesn't implement getEnvironment at all, and the Vercel lane failure is a clock-skew sleep assertion in a test whose run executed (i.e. the guard matched and passed).

Does this make #3243 / #6 irrelevant? No — worth being explicit since it's been asked. This PR removes the corruption (no more forked run IDs, no cross-tenant ack noise), but a misconfigured caller still ends up with a stuck-pending run and a red e2e lane — just a diagnosable one. #3243 is still needed to stop CI from producing the mismatched pair in the first place, and the long-term fix for the action remains the vercel/api one identified in the June investigation (publish the dashboard URL in the env-scoped GitHub Deployment status so ID resolution is tokenless and exact), which supersedes #6's token path.

Two non-blocking notes inline. Approving.

Comment threadpackages/world-vercel/src/utils.ts Outdated
if (projectConfig) {
return projectConfig.environment || 'production';
}
return process.env.VERCEL_ENV || undefined;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Non-blocking question (custom environments): for a deployment in a Vercel custom environment, VERCEL_ENV reports preview and the custom environment's name lives in VERCEL_TARGET_ENV. Meanwhile the proxy path will happily stamp an arbitrary string from projectConfig.environment (e.g. staging). If the backend keys tenants on the custom-env name but this helper returns preview on the in-deployment path (or vice versa), the guard could refuse a legitimate delivery — the one failure mode this PR is otherwise careful to make impossible.

Given the interface doc's MUST ("match the attribution the backend will actually apply"), it's worth verifying what workflow-server actually attributes for (a) an OIDC client in a custom environment and (b) a proxy client sending a custom-env x-vercel-environment, and either incorporating VERCEL_TARGET_ENV here or documenting that custom environments are out of scope. Non-blocking because such a setup already forks today — the guard can only make it louder, not worse — but a wrong answer here turns into a false refusal rather than a skipped check.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Verified against the actual minting code in vercel/api, and you were right to flag it — VERCEL_ENV alone was wrong for custom environments, in exactly the false-refusal direction:

  • Runtime OIDC path: the deployment token's claim is minted as environment: customEnvironment?.slug ?? envTarget (services/api-deployments/src/services/gen-oidc-token.ts caller in create-deployment.ts), so a custom-env deployment is attributed by its slug, not preview — while VERCEL_ENV in that same process says preview.
  • Proxy path: x-vercel-environment accepts production, preview, or a custom environment's slug or ID, and the minted claim is always matchingEnv.slug (services/api-workflow/src/index.ts).
  • The fix (82bd72e): the in-deployment branch now returns VERCEL_TARGET_ENV || VERCEL_ENV. VERCEL_TARGET_ENV is populated from exactly the same pair as the claim (customEnvironmentSlug || projectEnvTarget in packages/projects-transforms/gen-system-envs.ts), so it matches the tenant key by construction, for standard and custom environments alike; VERCEL_ENV stays as the fallback where VERCEL_TARGET_ENV isn't injected (e.g. vercel dev). Tests cover the custom-env case and the standard-env no-op.

One residual documented rather than solved: on the projectConfig path the proxy accepts a custom env ID but attributes the slug, so configuring the ID would stamp a value the consumer can't match. The helper's doc now says to configure the slug; canonicalizing an ID client-side would need an API call, which didn't seem worth it for that corner.

return false;
}

runLogger.error(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Non-blocking (operational visibility): the refusal is logged only on the consuming deployment's runtime logs — the environment the operator is probably not looking at, since from their side the symptom is a run stuck pending in the creator's environment with no error anywhere on it. That's still strictly better than the fork, and I agree nothing more is possible client-side (the consumer can't write to the creator's tenant). Worth a follow-up though: this is exactly the case for the "carry the creator's environment in runInput so resilient start can detect/refuse server-side" hardening — the server can see both tenants and could mark the creator's run failed or surface a health annotation instead of leaving it pending forever.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Agreed on all points — the consumer-side refusal makes the fork impossible but leaves the creator's run pending with the evidence in the other environment's logs. Tracking the server-side layer as follow-up work; its ingredients are already mapped out from this incident:

  • workflow-server#689 (closed as superseded by this PR) contains a working implementation of the resilient-start guard and can be revived as the reporting/annotation layer you describe — the server is indeed the only place that can see both tenants and mark the creator's copy failed instead of pending-forever.
  • Two prerequisites discovered along the way: the v4 events wire rebuilds eventData from field allowlists (parseV4EventMeta/buildEventData), so runInput.environment needs explicit plumbing on both sides before the server can see it; and covering old SDKs at run_created time needs the deployment's target exposed from vercel/api's get-deployment-info endpoint (the Cosmos deployment doc it already loads carries it).

Until then, the creator-side symptom is at least the pre-existing one (stuck pending with "unhealthy deployment" health checks) rather than a silent fork, and the consumer-side error log names both environments and the runId for anyone who goes looking.

…ent-guard
* origin/main:
ci: stop deploying changeset-release/main, run its e2e against production (#3243)
fix(world-local): bound stalled queue deliveries (#3255)
Sort imports in runtime.ts and step-executor.ts (#3241)
…_ENV
For a deployment in a Vercel custom environment, the OIDC token's
environment claim is the custom environment's slug (the platform mints
`customEnvironment?.slug ?? envTarget`) while VERCEL_ENV reports
'preview' — so keying the cross-environment guard on VERCEL_ENV could
false-refuse a legitimate delivery, e.g. a CLI client attributed to
'staging' starting a run on the staging deployment. VERCEL_TARGET_ENV
is populated from exactly the same slug-or-target pair as the claim, so
prefer it, keeping VERCEL_ENV as the fallback for contexts that don't
inject it. Also sorts runtime.ts imports per the Biome rule that landed
on main in #3241.
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
@pranaygp
pranaygp merged commit ee944d2 into mainJul 31, 2026
176 of 178 checks passed
@pranaygp
pranaygp deleted the pgp/runinput-environment-guard branch July 31, 2026 22:53
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for ee944d2 (AI decision).

This adds new public API surface and wire format rather than fixing a defect in existing stable code: an optional World.getEnvironment() hook, a new optional environment field on RunInputSchema, a world-vercel implementation, and a new consumer-side guard that discards deliveries — all carrying minor changesets across three packages. Although motivated by a real incident, the fork was caused by caller misconfiguration (WORKFLOW_VERCEL_ENV pointing at a different environment than VERCEL_DEPLOYMENT_ID), and the change is a new defensive capability plus a new diagnostic, which is feature work by the backport criteria. If the maintenance line needs this protection, it can be forced through via workflow_dispatch after a human weighs the new API surface and behavior change on stable.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

ee944d2476daca81b89ba545b522385a7902ec03

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@pranaygp@TooTallNate
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side by pranaygp · Pull Request #3244 · vercel/workflow · GitHub
Skip to content

feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side - #3244

Merged
pranaygp merged 3 commits into
mainfrom
pgp/runinput-environment-guard
Jul 31, 2026
Merged

feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side#3244
pranaygp merged 3 commits into
mainfrom
pgp/runinput-environment-guard

Conversation

@pranaygp

@pranaygppranaygp commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

The bug

start() makes two writes that have to land in the same tenant:

  1. the run_created event, attributed to whatever environment the caller authenticates as, and
  2. the queue message, pinned to a deployment.

A misconfigured caller can split them — writing the run to one environment while addressing the message to a deployment in another. What happens next is not a failure, it is a fork:

  • the consuming deployment looks for the run under its own tenant and doesn't find it;
  • the backend's resilient start (run_started creates the run when run_created was never seen) mints a second copy of the same run id in the consumer's environment;
  • both copies are real. The creator's sits pending forever, the consumer's executes, and subsequent cross-tenant queue acks can't find their messages.

This happened on 2026-07-30: a CI harness ran with WORKFLOW_VERCEL_ENV=production while VERCEL_DEPLOYMENT_ID pointed at a preview deployment, and wrun_41KYTFGXEC0GPWVN8C0J0KG9ZK ended up existing in both environments of the same project, with two distinct run_created events 1.18s apart.

The deployment id is not the discriminator — it matched end to end in that incident. The environment is, and nothing on the wire carried it.

The fix

Carry the environment, then check it on the consumer.

  • @workflow/world — optional World.getEnvironment(): string | undefined (sync, side-effect free, styled after createRunId), and an optional environment on RunInputSchema.
  • @workflow/world-vercel — implements it. Both auth paths, one source of truth: with projectConfig (CLI/CI via the api.vercel.com proxy) the backend attributes the write to the x-vercel-environment header, so getEnvironment() and that header now derive from one shared resolveClientEnvironment() helper and cannot drift; without projectConfig (inside a deployment, OIDC) it reads VERCEL_ENV, which the platform mints the token's environment claim from.
  • @workflow/corestart() stamps the value into the queue message's runInput, and the queue handler refuses a delivery whose creator environment disagrees with its own.

Why the rejection is client-side

The consuming process already knows its own environment, so it needs nothing from the server to catch this. More importantly it can refuse before run_started — the write that would create the fork. Any check that happens after that write is too late; refusing before it means the resilient start never creates anything, and the creator's run is left pending in its own environment where a human can find it.

Why it acks instead of throwing

The mismatch is baked into this message: it is pinned to this deployment, and the creator's environment is a field in the payload. Every redelivery would reach the identical verdict, so throwing would hot-loop the handler until MAX_QUEUE_DELIVERIES and end with a misleading max-deliveries failure. Returning normally acks and discards, which is how the handler already treats deliveries whose verdict cannot change (EntityConflictError, RunExpiredError on the run_started path both log and return).

The error log names both environments, the run id, the pinned deployment, and the likely cause (WORKFLOW_VERCEL_ENV for CLI/CI clients, or the OIDC token's environment inside a deployment).

Fails safe, by construction

The check runs only when both sides are known, so nothing that works today changes behavior:

SituationrunInput.environmentworld.getEnvironment()Result
world-local / world-postgres consumeranynot implementedcheck skipped
run started by an older SDKabsentanycheck skipped
Vercel system env vars not exposedpresentundefinedcheck skipped
environments agreeproductionproductionexecutes normally
environments disagreeproductionpreviewrefused, logged, acked

undefined is deliberately preserved rather than defaulted: guessing 'production' in a bare Node process would fabricate a mismatch against a genuine preview deployment. A wrong answer here is worse than no answer.

Secondary: deployment-pinning diagnostic

Alongside it, when runInput.deploymentId and the handler's own getDeploymentId() are both known and differ, the handler logs an error with both ids and the run id — and continues.

Warn rather than refuse, because a differing deployment id is not by itself evidence of the fork. world-local derives its id from the installed package version (dpl_local@<version>), so upgrading the SDK mid-run changes it with nothing wrong; refusing on that signal would strand correct runs. The two checks live side by side and are individually tested, with the environment guard short-circuiting first so a cross-environment delivery produces one clear error rather than two.

Wire compatibility

Verified read-only against workflow-server and the SDK's own schemas.

  • New field, current server.run_started eventData is built field-by-field in runtime.ts, not spread from runInput, so environment never reaches the events API — it exists only in the queue payload. It would have been safe anyway: CreateEventSchemaV2.eventData is z.record(z.any()) and InitialRunAttributesSchema is .passthrough(), so an unknown key is accepted, not rejected.
  • New field, old SDK consumer.RunInputSchema, WorkflowInvokePayloadSchema, and QueuePayloadSchema are all plain z.object — zod strips unknown keys instead of rejecting them, so a message from a new producer is consumed normally by an older deployment mid-rollout (it just skips the check). Pinned by a regression test so nobody makes these strict later.

No deviation from the intended design was needed.

Tests

  • packages/world/src/queue.test.tsRunInputSchema round-trip, absent field, non-string rejection, unknown-key stripping.
  • packages/world-vercel/src/utils.test.tsresolveClientEnvironment matrix, plus an agreement test asserting it always equals the x-vercel-environment header for the same config, so the two can't drift.
  • packages/core/src/runtime/start.test.ts — stamps the environment, omits it when undefined, omits it when the world doesn't implement the hook.
  • packages/core/src/runtime/cross-environment-delivery.test.ts — mismatch produces no events at all (no run_started), acks with 204, logs both environments; refuses on a redelivery too; matching environments execute normally; each skip case (field absent, hook absent, hook returns undefined, re-enqueued message) executes normally; plus the deployment-pinning diagnostic and its ordering against the guard.

pnpm typecheck passes (41/41 packages). @workflow/core 1671 passed + 3 expected-fail, @workflow/world 84 passed, @workflow/world-vercel 322 passed.

pnpm lint reports 2 pre-existing errors on main (nested biome.json files declaring $schema 2.0.0 against CLI 2.4.4) — confirmed present on origin/main with these changes stashed, and untouched here.

🤖 Generated with Claude Code

@pranaygp
pranaygp requested a review from ijjk as a code ownerJuly 30, 2026 23:45
CopilotAI review requested due to automatic review settings July 30, 2026 23:45
@pranaygp
pranaygp requested a review from a team as a code ownerJuly 30, 2026 23:45
@changeset-bot

changeset-botBot commented Jul 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 82bd72e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
NameType
@workflow/coreMinor
@workflow/worldMinor
@workflow/world-vercelMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowMinor
@workflow/world-testingPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewJul 31, 2026 6:02pm
example-nextjs-workflow-webpackReadyReadyPreviewJul 31, 2026 6:02pm
example-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-astro-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-express-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-fastify-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-hono-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-nestjs-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-nitro-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-nuxt-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-sveltekit-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-tanstack-start-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workbench-vite-workflowReadyReadyPreviewJul 31, 2026 6:02pm
workflow-docsReadyReadyPreview, v0Jul 31, 2026 6:02pm
workflow-swc-playgroundReadyReadyPreviewJul 31, 2026 6:02pm
workflow-tarballsReadyReadyPreviewJul 31, 2026 6:02pm
workflow-webReadyReadyPreviewJul 31, 2026 6:02pm

@github-actions

github-actionsBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 82bd72e · Fri, 31 Jul 2026 18:22:36 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1317 (+401%) 🔻1402 🔴 (-2.3%)1439 🔴 (-11%)1539 🔴 (-23%) 💚30
TTFSstream1287 (+421%) 🔻1365 🔴 (-2.6%)1421 🔴 (-9.7%)1525 🔴 (-25%) 💚30
TTFShook + stream1582 (+294%) 🔻1690 🔴 (+30%) 🔻1721 🔴 (+27%) 🔻1837 🔴 (+25%) 🔻30
STSO1020 steps (inline)203 (+15%)503 (-2.1%)560 (-3.1%)796 (+1.9%)1016
STSO1020 steps (queue-hop)2365 (+59%) 🔻3496 (+25%) 🔻3496 (+25%) 🔻3496 (+25%) 🔻3
WO1020 steps434394 (+0.5%)434394 (+0.5%)434394 (+0.5%)434394 (+0.5%)1
SLstream latency130 (+48%) 🔻172 🔴 (+8.9%)192 🔴 (+12%)270 🔴 (+37%) 🔻30
SOstream overhead (text)128 (-8.6%)214 (±0%)243 (+7.5%)324 (-35%) 💚30
SOstream overhead (structured)147 (+9.7%)198 (-27%) 💚238 (-32%) 💚310 (-34%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 424581ms → this run 423919ms (Δ -662ms, 0%)

 150-200 ms ┃ main 10 this 0 -10
200-250 ms █████████┃██ main 84 this 67 -17
250-300 ms █████████████████┃ main 123 this 120 -3
300-350 ms ████████████████████┃ main 140 this 144 +4
350-400 ms █████████████████████░░┃ main 141 this 163 +22
400-450 ms ████████████████░░░┃ main 107 this 135 +28
450-500 ms █████████████████┃ main 120 this 125 +5
500-550 ms ███████████████████░┃ main 132 this 140 +8
550-600 ms █████████┃███ main 85 this 68 -17
600-650 ms ███┃█ main 33 this 25 -8
650-700 ms █┃█ main 20 this 13 -7
700-750 ms ┃ main 8 this 5 -3
750-800 ms ┃ main 7 this 3 -4
800-850 ms ┃ main 2 this 1 -1
850-900 ms ┃ main 1 this 2 +1
950-1000 ms ┃ main 0 this 2 +2
1000-1050 ms ┃ main 1 this 1 +0
1050-1100 ms ┃ main 1 this 0 -1
1100-1150 ms ┃ main 0 this 1 +1
1300-1350 ms ┃ main 1 this 0 -1
1600-1650 ms ┃ main 0 this 1 +1

1020 steps (queue-hop)

Cumulative STSO time: main 6430ms → this run 9083ms (Δ +2653ms, +41%)

1000-1500 ms ┃███████████ main 1 this 0 -1
2000-2500 ms ███████████┃ main 1 this 1 +0
2500-3000 ms ┃███████████ main 1 this 0 -1
3000-3500 ms ░░░░░░░░░░░░░░░░░░░░░░░┃ main 0 this 2 +2
📜 Previous results (1)

2308af2

Fri, 31 Jul 2026 00:25:05 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep261 (-74%) 💚1360 🔴 (+20%) 🔻1393 🔴 (+20%) 🔻1431 🔴 (+13%)30
TTFSstream232 (+13%)1365 🔴 (+24%) 🔻1394 🔴 (+23%) 🔻1435 🔴 (-12%)30
TTFShook + stream369 (-72%) 💚1606 🔴 (+12%)1627 🔴 (+11%)1888 🔴 (+15%) 🔻30
STSO1020 steps (inline)178 (-3.3%)492 (-5.2%)544 (-5.4%)737 (-8.8%)1016
STSO1020 steps (queue-hop)2271 (+12%)2910 (-38%) 💚2910 (-38%) 💚2910 (-38%) 💚3
WO1020 steps421233 (-6.6%)421233 (-6.6%)421233 (-6.6%)421233 (-6.6%)1
SLstream latency109 (-2.7%)182 🔴 (+9.6%)208 🔴 (±0%)435 🔴 (+12%)30
SOstream overhead (text)124 (-6.1%)258 🔴 (-9.8%)373 (-24%) 💚2528 🔴 (-8.2%)30
SOstream overhead (structured)110 (-17%) 💚207 (-26%) 💚256 (-34%) 💚344 (-78%) 💚30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
✅ 💻 Local Development162102271848
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7519011328651
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

CopilotAI left a comment

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.

Pull request overview

Adds a cross-environment “resilient start” guard by stamping the creator’s environment into queued runInput, wiring it through the v4 events meta path, and treating server rejections for environment mismatch as terminal (ack, don’t retry) to prevent the same wrun_ from being created in two different Vercel environments.

Changes:

  • Introduces World.getEnvironment?() and carries an optional environment field through RunInput and run_started event data.
  • Implements environment resolution in @workflow/world-vercel via a shared resolveClientEnvironment() used both for x-vercel-environment and getEnvironment(), and forwards environment through v4 frame meta.
  • Updates the core runtime to (a) stamp creator environment at start(), (b) log deployment pin mismatches diagnostically, and (c) ack queue messages on run_environment_mismatch rejections instead of retrying.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated no comments.

Show a summary per file
FileDescription
packages/world/src/queue.tsAdds optional environment field to RunInputSchema.
packages/world/src/queue.test.tsAdds schema coverage for environment round-trip and unknown-key stripping behavior.
packages/world/src/interfaces.tsAdds optional World.getEnvironment?() hook contract.
packages/world/src/events.tsExtends run_started event data with optional environment.
packages/world-vercel/src/utils.tsAdds resolveClientEnvironment() and reuses it for x-vercel-environment header generation.
packages/world-vercel/src/utils.test.tsTests environment resolution matrix and header agreement.
packages/world-vercel/src/index.tsImplements getEnvironment() for world-vercel using resolveClientEnvironment().
packages/world-vercel/src/events.tsRoutes environment through v4 meta splitting allowlist.
packages/world-vercel/src/events.test.tsTests splitEventDataForV4 includes/omits environment appropriately.
packages/world-vercel/src/events-v4.tsAdds environment to v4 frame meta and fixes error-code extraction to read error field.
packages/world-vercel/src/events-v4.test.tsTests error-field fallback and meta wire round-trip for environment.
packages/core/src/runtime/start.tsStamps world.getEnvironment?.() into queued runInput.environment.
packages/core/src/runtime/start.test.tsTests environment stamping/omission behavior.
packages/core/src/runtime/cross-environment-start.test.tsAdds end-to-end runtime handler tests for deployment pin diagnostics + cross-env 422 ack behavior (including turbo behavior).
packages/core/src/runtime.tsAdds deployment pin mismatch diagnostic + terminal handling for cross-env resilient start rejection.
packages/core/src/classify-error.tsAdds isCrossEnvironmentStartRejection and constant for the server error code.
packages/core/src/classify-error.test.tsAdds classification/negative tests for cross-environment rejection detection.
.changeset/world-vercel-get-environment.mdChangeset for @workflow/world-vercel additions/behavior changes.
.changeset/world-get-environment.mdChangeset for @workflow/world interface/schema additions.
.changeset/core-cross-environment-start-guard.mdChangeset for @workflow/core runtime behavior change.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…environment queue deliveries client-side
`start()` makes two writes that have to land in the same tenant: the
`run_created` event, attributed to whatever environment the caller
authenticates as, and the queue message, pinned to a deployment. A
misconfigured caller can split them — writing the run to one environment
while addressing the message to a deployment in another. The consumer
finds no run under its own tenant, the backend's resilient start
(`run_started` creates the run when `run_created` was never seen) mints a
second copy of the same run id in the consumer's environment, and both
copies are real: the creator's sits pending forever while the other
executes.
The deployment id is not the discriminator — it matched end to end in the
incident that motivated this. The environment is. So carry it: add an
optional `World.getEnvironment()`, implement it in world-vercel from the
same resolution that produces the `x-vercel-environment` header, and stamp
it into the queue message's `runInput`.
The consuming deployment already knows its own environment, so it can
refuse the delivery itself with no server coordination — and refuse before
`run_started`, the write that would create the fork. The refusal acks the
message instead of throwing: the mismatch is baked into the message, so
every redelivery would reach the same verdict and throwing would hot-loop
until MAX_QUEUE_DELIVERIES.
Both sides must be known for the check to run, so worlds with a single
tenant (local, Postgres) and runs started by an older SDK behave exactly
as before. A companion diagnostic logs a deployment-id mismatch without
refusing, since deployment ids differ for benign reasons too.
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
@pranaygp
pranaygpforce-pushed the pgp/runinput-environment-guard branch from 3021bcb to 2308af2CompareJuly 31, 2026 00:01
@pranaygppranaygp changed the title feat(core): stamp creator environment into runInput and fail cross-environment resilient starts fastfeat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-sideJul 31, 2026

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed as the third layer of the incident stack (with #3243 and vercel/wait-for-deployment-action#6), and against the earlier investigation of this same race from June (workbench-express, same mechanism: prod URL + preview dpl_ from the changeset-release branch). This is the right hardening and the design choices hold up under scrutiny:

  • Refusing before run_started is the only placement that prevents the fork rather than reporting it — verified the guard sits ahead of the event write and ahead of all attempt-dependent branching, with tests pinning that ordering.
  • Ack-and-discard over throwing is correct: the verdict is baked into the message, and it matches how the handler already treats EntityConflictError/RunExpiredError.
  • Fail-safe skip matrix is genuinely fail-safe: I checked each row (no getEnvironment on local/postgres, absent field from older SDKs, undefined from missing env vars) and none can produce a false refusal. Keeping producer stamp and consumer header derived from one resolveClientEnvironment is the load-bearing detail, and the agreement test locks it.
  • Wire compat claims check out: run_started eventData is built field-by-field so environment never reaches the server, and the unknown-key-stripping regression test protects the old-consumer direction.
  • The deployment-pinning check staying diagnostic-only is right, given dpl_local@<version> churn.

On CI: the Biome failure is the same one inherited from main (organizeImports in files whose only change here is additive imports — the two flagged files fail identically on main), and the two e2e failures are flakes unrelated to the guard: the local-prod lane doesn't implement getEnvironment at all, and the Vercel lane failure is a clock-skew sleep assertion in a test whose run executed (i.e. the guard matched and passed).

Does this make #3243 / #6 irrelevant? No — worth being explicit since it's been asked. This PR removes the corruption (no more forked run IDs, no cross-tenant ack noise), but a misconfigured caller still ends up with a stuck-pending run and a red e2e lane — just a diagnosable one. #3243 is still needed to stop CI from producing the mismatched pair in the first place, and the long-term fix for the action remains the vercel/api one identified in the June investigation (publish the dashboard URL in the env-scoped GitHub Deployment status so ID resolution is tokenless and exact), which supersedes #6's token path.

Two non-blocking notes inline. Approving.

Comment threadpackages/world-vercel/src/utils.ts Outdated
if (projectConfig) {
return projectConfig.environment || 'production';
}
return process.env.VERCEL_ENV || undefined;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Non-blocking question (custom environments): for a deployment in a Vercel custom environment, VERCEL_ENV reports preview and the custom environment's name lives in VERCEL_TARGET_ENV. Meanwhile the proxy path will happily stamp an arbitrary string from projectConfig.environment (e.g. staging). If the backend keys tenants on the custom-env name but this helper returns preview on the in-deployment path (or vice versa), the guard could refuse a legitimate delivery — the one failure mode this PR is otherwise careful to make impossible.

Given the interface doc's MUST ("match the attribution the backend will actually apply"), it's worth verifying what workflow-server actually attributes for (a) an OIDC client in a custom environment and (b) a proxy client sending a custom-env x-vercel-environment, and either incorporating VERCEL_TARGET_ENV here or documenting that custom environments are out of scope. Non-blocking because such a setup already forks today — the guard can only make it louder, not worse — but a wrong answer here turns into a false refusal rather than a skipped check.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Verified against the actual minting code in vercel/api, and you were right to flag it — VERCEL_ENV alone was wrong for custom environments, in exactly the false-refusal direction:

  • Runtime OIDC path: the deployment token's claim is minted as environment: customEnvironment?.slug ?? envTarget (services/api-deployments/src/services/gen-oidc-token.ts caller in create-deployment.ts), so a custom-env deployment is attributed by its slug, not preview — while VERCEL_ENV in that same process says preview.
  • Proxy path: x-vercel-environment accepts production, preview, or a custom environment's slug or ID, and the minted claim is always matchingEnv.slug (services/api-workflow/src/index.ts).
  • The fix (82bd72e): the in-deployment branch now returns VERCEL_TARGET_ENV || VERCEL_ENV. VERCEL_TARGET_ENV is populated from exactly the same pair as the claim (customEnvironmentSlug || projectEnvTarget in packages/projects-transforms/gen-system-envs.ts), so it matches the tenant key by construction, for standard and custom environments alike; VERCEL_ENV stays as the fallback where VERCEL_TARGET_ENV isn't injected (e.g. vercel dev). Tests cover the custom-env case and the standard-env no-op.

One residual documented rather than solved: on the projectConfig path the proxy accepts a custom env ID but attributes the slug, so configuring the ID would stamp a value the consumer can't match. The helper's doc now says to configure the slug; canonicalizing an ID client-side would need an API call, which didn't seem worth it for that corner.

return false;
}

runLogger.error(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Non-blocking (operational visibility): the refusal is logged only on the consuming deployment's runtime logs — the environment the operator is probably not looking at, since from their side the symptom is a run stuck pending in the creator's environment with no error anywhere on it. That's still strictly better than the fork, and I agree nothing more is possible client-side (the consumer can't write to the creator's tenant). Worth a follow-up though: this is exactly the case for the "carry the creator's environment in runInput so resilient start can detect/refuse server-side" hardening — the server can see both tenants and could mark the creator's run failed or surface a health annotation instead of leaving it pending forever.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Agreed on all points — the consumer-side refusal makes the fork impossible but leaves the creator's run pending with the evidence in the other environment's logs. Tracking the server-side layer as follow-up work; its ingredients are already mapped out from this incident:

  • workflow-server#689 (closed as superseded by this PR) contains a working implementation of the resilient-start guard and can be revived as the reporting/annotation layer you describe — the server is indeed the only place that can see both tenants and mark the creator's copy failed instead of pending-forever.
  • Two prerequisites discovered along the way: the v4 events wire rebuilds eventData from field allowlists (parseV4EventMeta/buildEventData), so runInput.environment needs explicit plumbing on both sides before the server can see it; and covering old SDKs at run_created time needs the deployment's target exposed from vercel/api's get-deployment-info endpoint (the Cosmos deployment doc it already loads carries it).

Until then, the creator-side symptom is at least the pre-existing one (stuck pending with "unhealthy deployment" health checks) rather than a silent fork, and the consumer-side error log names both environments and the runId for anyone who goes looking.

…ent-guard
* origin/main:
ci: stop deploying changeset-release/main, run its e2e against production (#3243)
fix(world-local): bound stalled queue deliveries (#3255)
Sort imports in runtime.ts and step-executor.ts (#3241)
…_ENV
For a deployment in a Vercel custom environment, the OIDC token's
environment claim is the custom environment's slug (the platform mints
`customEnvironment?.slug ?? envTarget`) while VERCEL_ENV reports
'preview' — so keying the cross-environment guard on VERCEL_ENV could
false-refuse a legitimate delivery, e.g. a CLI client attributed to
'staging' starting a run on the staging deployment. VERCEL_TARGET_ENV
is populated from exactly the same slug-or-target pair as the claim, so
prefer it, keeping VERCEL_ENV as the fallback for contexts that don't
inject it. Also sorts runtime.ts imports per the Biome rule that landed
on main in #3241.
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
@pranaygp
pranaygp merged commit ee944d2 into mainJul 31, 2026
176 of 178 checks passed
@pranaygp
pranaygp deleted the pgp/runinput-environment-guard branch July 31, 2026 22:53
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for ee944d2 (AI decision).

This adds new public API surface and wire format rather than fixing a defect in existing stable code: an optional World.getEnvironment() hook, a new optional environment field on RunInputSchema, a world-vercel implementation, and a new consumer-side guard that discards deliveries — all carrying minor changesets across three packages. Although motivated by a real incident, the fork was caused by caller misconfiguration (WORKFLOW_VERCEL_ENV pointing at a different environment than VERCEL_DEPLOYMENT_ID), and the change is a new defensive capability plus a new diagnostic, which is feature work by the backport criteria. If the maintenance line needs this protection, it can be forced through via workflow_dispatch after a human weighs the new API surface and behavior change on stable.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

ee944d2476daca81b89ba545b522385a7902ec03

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@pranaygp@TooTallNate