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

@pranaygp pranaygp 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:

Situation runInput.environment world.getEnvironment() Result
world-local / world-postgres consumer any not implemented check skipped
run started by an older SDK absent any check skipped
Vercel system env vars not exposed present undefined check skipped
environments agree production production executes normally
environments disagree production preview refused, 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 owner July 30, 2026 23:45
Copilot AI review requested due to automatic review settings July 30, 2026 23:45
@pranaygp
pranaygp requested a review from a team as a code owner July 30, 2026 23:45
@changeset-bot

changeset-bot Bot 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
Name Type
@workflow/core Minor
@workflow/world Minor
@workflow/world-vercel Minor
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
workflow Minor
@workflow/world-testing Patch
@workflow/world-local Patch
@workflow/world-postgres Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/nuxt Patch

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

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview Jul 31, 2026 6:02pm
example-nextjs-workflow-webpack Ready Ready Preview Jul 31, 2026 6:02pm
example-workflow Ready Ready Preview Jul 31, 2026 6:02pm
workbench-astro-workflow Ready Ready Preview Jul 31, 2026 6:02pm
workbench-express-workflow Ready Ready Preview Jul 31, 2026 6:02pm
workbench-fastify-workflow Ready Ready Preview Jul 31, 2026 6:02pm
workbench-hono-workflow Ready Ready Preview Jul 31, 2026 6:02pm
workbench-nestjs-workflow Ready Ready Preview Jul 31, 2026 6:02pm
workbench-nitro-workflow Ready Ready Preview Jul 31, 2026 6:02pm
workbench-nuxt-workflow Ready Ready Preview Jul 31, 2026 6:02pm
workbench-sveltekit-workflow Ready Ready Preview Jul 31, 2026 6:02pm
workbench-tanstack-start-workflow Ready Ready Preview Jul 31, 2026 6:02pm
workbench-vite-workflow Ready Ready Preview Jul 31, 2026 6:02pm
workflow-docs Ready Ready Preview, v0 Jul 31, 2026 6:02pm
workflow-swc-playground Ready Ready Preview Jul 31, 2026 6:02pm
workflow-tarballs Ready Ready Preview Jul 31, 2026 6:02pm
workflow-web Ready Ready Preview Jul 31, 2026 6:02pm

@github-actions

github-actions Bot 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

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1317 (+401%) 🔻 1402 🔴 (-2.3%) 1439 🔴 (-11%) 1539 🔴 (-23%) 💚 30
TTFS stream 1287 (+421%) 🔻 1365 🔴 (-2.6%) 1421 🔴 (-9.7%) 1525 🔴 (-25%) 💚 30
TTFS hook + stream 1582 (+294%) 🔻 1690 🔴 (+30%) 🔻 1721 🔴 (+27%) 🔻 1837 🔴 (+25%) 🔻 30
STSO 1020 steps (inline) 203 (+15%) 503 (-2.1%) 560 (-3.1%) 796 (+1.9%) 1016
STSO 1020 steps (queue-hop) 2365 (+59%) 🔻 3496 (+25%) 🔻 3496 (+25%) 🔻 3496 (+25%) 🔻 3
WO 1020 steps 434394 (+0.5%) 434394 (+0.5%) 434394 (+0.5%) 434394 (+0.5%) 1
SL stream latency 130 (+48%) 🔻 172 🔴 (+8.9%) 192 🔴 (+12%) 270 🔴 (+37%) 🔻 30
SO stream overhead (text) 128 (-8.6%) 214 (±0%) 243 (+7.5%) 324 (-35%) 💚 30
SO stream 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

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 261 (-74%) 💚 1360 🔴 (+20%) 🔻 1393 🔴 (+20%) 🔻 1431 🔴 (+13%) 30
TTFS stream 232 (+13%) 1365 🔴 (+24%) 🔻 1394 🔴 (+23%) 🔻 1435 🔴 (-12%) 30
TTFS hook + stream 369 (-72%) 💚 1606 🔴 (+12%) 1627 🔴 (+11%) 1888 🔴 (+15%) 🔻 30
STSO 1020 steps (inline) 178 (-3.3%) 492 (-5.2%) 544 (-5.4%) 737 (-8.8%) 1016
STSO 1020 steps (queue-hop) 2271 (+12%) 2910 (-38%) 💚 2910 (-38%) 💚 2910 (-38%) 💚 3
WO 1020 steps 421233 (-6.6%) 421233 (-6.6%) 421233 (-6.6%) 421233 (-6.6%) 1
SL stream latency 109 (-2.7%) 182 🔴 (+9.6%) 208 🔴 (±0%) 435 🔴 (+12%) 30
SO stream overhead (text) 124 (-6.1%) 258 🔴 (-9.8%) 373 (-24%) 💚 2528 🔴 (-8.2%) 30
SO stream 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-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 1455 0 239 1694
✅ 💻 Local Development 1621 0 227 1848
✅ 📦 Local Production 1621 0 227 1848
✅ 🐘 Local Postgres 1621 0 227 1848
✅ 🪟 Windows 154 0 0 154
✅ 📋 Other 1020 0 212 1232
✅ vercel-multi-region 27 0 0 27
Total 7519 0 1132 8651
Details by Category

✅ ▲ Vercel Production

App Passed Failed Skipped
✅ astro 126 0 28
✅ example 126 0 28
✅ express 126 0 28
✅ fastify 126 0 28
✅ hono 126 0 28
✅ nextjs-turbopack 151 0 3
✅ nextjs-webpack 151 0 3
✅ nitro 126 0 28
✅ nuxt 126 0 28
✅ sveltekit 145 0 9
✅ vite 126 0 28

✅ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

✅ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack 154 0 0

✅ 📋 Other

App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 128 0 26
✅ e2e-local-dev-tanstack-start- 128 0 26
✅ e2e-local-postgres-nest-stable 128 0 26
✅ e2e-local-postgres-tanstack-start- 128 0 26
✅ e2e-local-prod-nest-stable 128 0 26
✅ e2e-local-prod-tanstack-start- 128 0 26
✅ e2e-vercel-prod-nest 126 0 28
✅ e2e-vercel-prod-tanstack-start 126 0 28

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

Copilot AI 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
File Description
packages/world/src/queue.ts Adds optional environment field to RunInputSchema.
packages/world/src/queue.test.ts Adds schema coverage for environment round-trip and unknown-key stripping behavior.
packages/world/src/interfaces.ts Adds optional World.getEnvironment?() hook contract.
packages/world/src/events.ts Extends run_started event data with optional environment.
packages/world-vercel/src/utils.ts Adds resolveClientEnvironment() and reuses it for x-vercel-environment header generation.
packages/world-vercel/src/utils.test.ts Tests environment resolution matrix and header agreement.
packages/world-vercel/src/index.ts Implements getEnvironment() for world-vercel using resolveClientEnvironment().
packages/world-vercel/src/events.ts Routes environment through v4 meta splitting allowlist.
packages/world-vercel/src/events.test.ts Tests splitEventDataForV4 includes/omits environment appropriately.
packages/world-vercel/src/events-v4.ts Adds environment to v4 frame meta and fixes error-code extraction to read error field.
packages/world-vercel/src/events-v4.test.ts Tests error-field fallback and meta wire round-trip for environment.
packages/core/src/runtime/start.ts Stamps world.getEnvironment?.() into queued runInput.environment.
packages/core/src/runtime/start.test.ts Tests environment stamping/omission behavior.
packages/core/src/runtime/cross-environment-start.test.ts Adds end-to-end runtime handler tests for deployment pin diagnostics + cross-env 422 ack behavior (including turbo behavior).
packages/core/src/runtime.ts Adds deployment pin mismatch diagnostic + terminal handling for cross-env resilient start rejection.
packages/core/src/classify-error.ts Adds isCrossEnvironmentStartRejection and constant for the server error code.
packages/core/src/classify-error.test.ts Adds classification/negative tests for cross-environment rejection detection.
.changeset/world-vercel-get-environment.md Changeset for @workflow/world-vercel additions/behavior changes.
.changeset/world-get-environment.md Changeset for @workflow/world interface/schema additions.
.changeset/core-cross-environment-start-guard.md Changeset 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
pranaygp force-pushed the pgp/runinput-environment-guard branch from 3021bcb to 2308af2 Compare July 31, 2026 00:01
@pranaygp pranaygp changed the title feat(core): stamp creator environment into runInput and fail cross-environment resilient starts fast feat(core): stamp creator environment into runInput and reject cross-environment queue deliveries client-side Jul 31, 2026

@TooTallNate TooTallNate 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 thread packages/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
Contributor Author

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
Contributor Author

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.

pranaygp added 2 commits July 31, 2026 10:14
…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 main Jul 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 free to 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