Skip to content

feat: return the run public key from the capability probe#3099

Open
TooTallNate wants to merge 2 commits into
nate/encp-forwarded-streamsfrom
nate/encp-start-probe-key
Open

feat: return the run public key from the capability probe#3099
TooTallNate wants to merge 2 commits into
nate/encp-forwarded-streamsfrom
nate/encp-start-probe-key

Conversation

@TooTallNate

@TooTallNate TooTallNate commented Jul 24, 2026

Copy link
Copy Markdown
Member

PR 7 of 7. Stacked on #3098; review the stack in order, starting from #3093.

The last lookup

After #3096 and #3098, cross-deployment resumeHook() and forwarded stream writes no longer hit the key API. Cross-deployment start() still did — the run doesn't exist yet, so there's no run entity to read a public key from.

But start() already blocks on a capability probe for every cross-deployment call, and the probe responder executes inside the target deployment, where the run's key material is local. So the public key can ride back on a response the caller is already awaiting.

Why this beats keeping the request

  • The wait is already being paid. Folding the key into the existing response removes the request outright rather than relocating it.
  • A public key is exactly what this channel can carry. The probe response stream is deliberately unauthenticated — that would disqualify shipping the symmetric key over it, but a public key isn't secret. The constraint costs nothing.
  • It reduces privilege. The caller ends up able to seal the workflow arguments but not read them back. Fetching the symmetric key granted full read access to a run it merely launched.

Ordering change

runId is now minted before the probe instead of just after. createRunId() reads only opts, which is fully resolved at that point, so the move has no other dependency. A test asserts the id sent to the probe is the one actually created, so the ordering can't silently regress.

Everything is best-effort

The probe is already failure-tolerant (2s timeout, errors swallowed) and is skipped entirely for same-deployment starts and for worlds without a streams API. When no key comes back — old target, timeout, encryption disabled, or a malformed value — start() falls back to the existing lookup plus symmetric encryption.

Key derivation failures inside the responder are caught and logged so the probe still reports health and capabilities, which callers rely on for reasons unrelated to encryption.

⚠️ Trust-model note for reviewers

This widens the recipient-substitution surface slightly. The key-lookup API is strongly authenticated and audit-logged; the probe response stream is not. An attacker able to write to that stream — which requires tenant-scoped stream write access — could substitute a public key and read arguments subsequently sealed to it.

That is the same class and roughly the same prerequisite as substituting the key on the run entity (which #3095 already introduces), but it is a second instance of it, and worth weighing explicitly if we later decide public-key attestation is warranted.

Tests

Responder side: key returned when the probe names a run, omitted when it doesn't (with no derivation attempted), omitted when encryption is off, and health still reported when derivation throws.

Caller side: the probe is asked about the run that actually gets created, the arguments are sealed with the probed key and no lookup happens, the sealed arguments actually open with the keys the target re-derives, and both fallbacks (no key, malformed key) work.

Core suite 1610 passing; world 78; workspace typecheck clean; zero new lint diagnostics.

@TooTallNate
TooTallNate requested a review from ijjk as a code owner July 24, 2026 21:21
Copilot AI review requested due to automatic review settings July 24, 2026 21:21
@TooTallNate
TooTallNate requested a review from a team as a code owner July 24, 2026 21:21
@changeset-bot

changeset-bot Bot commented Jul 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5475251

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/nuxt Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite 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

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@vercel

vercel Bot commented Jul 24, 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, Comment Jul 26, 2026 7:06am
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jul 26, 2026 7:06am
example-workflow Ready Ready Preview, Comment Jul 26, 2026 7:06am
workbench-astro-workflow Ready Ready Preview, Comment Jul 26, 2026 7:06am
workbench-express-workflow Ready Ready Preview, Comment Jul 26, 2026 7:06am
workbench-fastify-workflow Ready Ready Preview, Comment Jul 26, 2026 7:06am
workbench-hono-workflow Ready Ready Preview, Comment Jul 26, 2026 7:06am
workbench-nestjs-workflow Ready Ready Preview, Comment Jul 26, 2026 7:06am
workbench-nitro-workflow Ready Ready Preview, Comment Jul 26, 2026 7:06am
workbench-nuxt-workflow Ready Ready Preview, Comment Jul 26, 2026 7:06am
workbench-sveltekit-workflow Ready Ready Preview, Comment Jul 26, 2026 7:06am
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jul 26, 2026 7:06am
workbench-vite-workflow Ready Ready Preview, Comment Jul 26, 2026 7:06am
workflow-swc-playground Ready Ready Preview, Comment Jul 26, 2026 7:06am
workflow-tarballs Ready Ready Preview, Comment Jul 26, 2026 7:06am
workflow-web Ready Ready Preview, Comment Jul 26, 2026 7:06am
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
workflow-docs Skipped Skipped Jul 26, 2026 7:06am

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

▲ Vercel Production (1 failed)

hono (1 failed):

📦 Local Production (1 failed)

nextjs-webpack-stable (1 failed):

  • webhookWorkflow | wrun_41KYEKXG8Y0GKQ7RXP6JRBQF8P

E2E Test Summary

Summary
Passed Failed Skipped Total
❌ ▲ Vercel Production 1454 1 239 1694
✅ 💻 Local Development 1621 0 227 1848
❌ 📦 Local Production 1620 1 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 7517 2 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 125 1 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 153 1 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

@TooTallNate
TooTallNate force-pushed the nate/encp-forwarded-streams branch from 0747839 to ec659f4 Compare July 24, 2026 22:36
@TooTallNate
TooTallNate force-pushed the nate/encp-start-probe-key branch from 8de4685 to bf07d76 Compare July 24, 2026 22:36

@vercel vercel Bot 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.

Additional Suggestion:

Orphaned generated file quickjs-assets.generated.ts imports the uninstalled quickjs-wasi module, causing tsc to fail with TS2307 and breaking every @workflow/core build.

Fix on Vercel

@TooTallNate

Copy link
Copy Markdown
Member Author

Found and fixed a bug in this PR during manual production verification — worth a careful look since it broke more than the new feature.

Symptom

A cross-deployment start() never completed: the capability probe never answered and start() timed out. Reproduced deterministically against a real deployment — probes without a runId answered healthy in ~2s, probes with a runId timed out every time (12s/15s/20s/25s budgets, fresh run ids each attempt).

The target deployment's flow route logged:

[workflow] Queue handler failed for message "..." on delivery attempt 1; retrying in 1s:
  WorkflowWorldError: workflow run wrun_01KYC22FBN7DW8KBMZGXWGS7N5 not found
  url: 'https://vercel-workflow.com/api/v4/runs/wrun_01KYC22FBN7DW8KBMZGXWGS7N5/events/run_started'

The probe was being executed as a workflow invoke.

Cause

QueuePayloadSchema is an ordered z.union, and z.object strips keys the matching member doesn't declare:

export const QueuePayloadSchema = z.union([
  WorkflowInvokePayloadSchema,   // { runId: string, ... }  <-- matched first
  StepInvokePayloadSchema,
  HealthCheckPayloadSchema,
]);

world-vercel's createQueueHandler parses every delivered message through this union. Adding an optional runId to HealthCheckPayloadSchema made a probe payload also satisfy WorkflowInvokePayloadSchema, whose only required field is runId — so the invoke member matched first and __healthCheck / correlationId were silently dropped:

{ __healthCheck: true, correlationId: 'c', runId: 'wrun_ABC' }  ->  { runId: 'wrun_ABC' }

The runtime dispatches on __healthCheck before falling through to the invoke schema, so the probe became "replay this run" → POST run_started for a run that doesn't exist yet → 404 → handler throws → VQS retries forever → probe never answers.

This is not limited to the new key feature. Any cross-deployment start() probes with a runId, so the pre-existing capability detection (framedByteStreams, compression) regressed too.

Fix (d16e185)

  • Order HealthCheckPayloadSchema first in the union. Safe in the other direction: it requires __healthCheck: true, which no invoke or step payload ever carries.
  • Reorder getPhysicalQueueName to match health checks before the runId branch, so under WORKFLOW_SEQUENTIAL_REPLAYS=1 a probe keeps its per-probe topic instead of queueing behind the very run it is preparing. (Latent before this PR — unreachable until health checks started carrying a runId.)
  • Regression tests in both packages; both verified non-vacuous (they fail when the ordering is reverted).
  • Added @workflow/world-vercel to the changeset.

Note on the union ordering

Ordering fixes the collision, but the union stays structurally ambiguous — any future optional field on HealthCheckPayloadSchema that overlaps another member could reintroduce this class of bug silently. A discriminated union (or a dedicated non-overlapping key) would make it unrepresentable. Happy to do that here if reviewers prefer; I kept this change minimal.

@TooTallNate

TooTallNate commented Jul 25, 2026

Copy link
Copy Markdown
Member Author

Verified in production ✅

Deployed d16e185 to two preview deployments and re-ran the case that previously failed 100% of the time.

Probe with a runId (was: timed out on every attempt):

attempt result latency returned key
1 (cold) healthy 2237ms ozGYxJP2piL8HTY0EukzPVQsyjo8x4L5ZA1xsFWllmA=
2 healthy 1010ms QJI1dJ/7ZK8npKmpxjvCYPso0tS92pqjwTS3uAajd0E=
3 healthy 373ms gMgQ58NL62zwOMsMgOutDywLlMRKtBgptMQSbo9DZ3k=

Distinct per-run keys, derived locally inside the target deployment.

Cross-deployment start() — caller dpl_3HZYQL7… → target dpl_5R9cAuVp…, succeeded in 2295ms as wrun_41KYC3MJB520SRRZ23FQ4NBDJS. Reading the run row's inputRef._data straight out of DynamoDB:

magic prefix = b'encp'   (65 6e 63 70)
total        = 95 bytes

So the workflow arguments are genuinely sealed with the sealed-box envelope rather than the encr symmetric fallback — this is the payoff for the whole stack, observed end to end.

Worth calling out: this path needs no key lookup at all. The probe supplies the key, so start() does not depend on encryptionPublicKey being present on the run row. (The resumeHook() path in #3096 does depend on it, and correctly falls back to encr when it is absent.)

One follow-up concern, not addressed here

CROSS_DEPLOYMENT_CAPABILITY_PROBE_TIMEOUT_MS is 2000ms, but the cold target answered in 2237ms — over budget. Warm probes are 373–1010ms, so it fits comfortably once the target is warm, but a cold target can blow the budget and silently drop start() back to encr. Functionally safe (that's the designed fallback), but it means the sealed path may engage only intermittently in real traffic, which undercuts the benefit. I'd rather tune that in its own PR than widen this one — let me know if you'd prefer it here.

@TooTallNate

TooTallNate commented Jul 25, 2026

Copy link
Copy Markdown
Member Author

Note: I originally landed a fix on this branch for a bug that originates in #3095. It has since been moved down to #3095 where it belongs, so this PR is back to two commits. Full write-up is in #3095.

Short version: buildPostFrameMeta in events-v4.ts never forwarded encryptionPublicKey onto the wire, and because events.ts applies the meta with a spread (...meta) TypeScript's excess-property check didn't fire. So the run's public key was computed and then silently dropped client-side — the server never received it, never stored it, and every cross-run writer fell back to encr. Every symptom pointed at the server side rather than at the SDK.

With that fixed, the whole stack now works end to end in production, including the resumeHook() path that #3096 targets:

run entity.encryptionPublicKey     = 4jWP17aASciRa+hftatSP8/21Qy+byelU0WGkFKjC2w=
hook_received.payload._data prefix = b'encp'   (cross-deployment resumeHook)
run status                         = completed
returnValue.received               = {"sealed":"from-deployment-B","n":42}

Two things reviewers should know about the restack:

  • The whole stack is now rebased onto latest main, which picks up [world-testing] Fix Unit Tests flake after adding event-limit test #3055 and clears the world-testing flake that was failing CI here.
  • main has since removed StepInvokePayloadSchema from QueuePayloadSchema (the combined flow+step work). The union in this PR therefore has two members rather than three, and the test that asserted step-invoke payloads still resolve was dropped along with the schema — not an intentional narrowing on my part.

This removes the last key-lookup request from the cross-deployment hot
paths. `start()` already blocks on a capability probe for every
cross-deployment call, and the probe responder executes *inside the target
deployment*, where the run's key material is available locally. So the
public key can ride back on a response the caller is already awaiting, at
no additional latency, and the `run-key` API request disappears.

Three properties make this better than keeping the request:

- The wait is already being paid. Folding the key into the existing
  response removes the request outright rather than relocating it.
- A public key is exactly what this channel can carry. The probe response
  stream is deliberately unauthenticated, which would disqualify shipping
  the symmetric key over it — but a public key is not secret.
- It reduces privilege. The caller ends up able to seal the workflow
  arguments but not read them back; fetching the symmetric key granted
  full read access to a run it merely launched.

`runId` is now minted before the probe rather than just after it.
`createRunId()` reads only `opts`, which is fully resolved by that point,
so the move has no other dependency — and a test asserts the id sent to
the probe is the one actually created.

Everything is best-effort. The probe is already failure-tolerant (2s
timeout, errors swallowed) and is skipped entirely for same-deployment
starts and for worlds without a streams API. When no key comes back — old
target, timeout, encryption disabled, or a malformed value — `start()`
falls back to the existing lookup plus symmetric encryption. Key
derivation failures inside the responder are caught and logged so the
probe still reports health and capabilities, which callers depend on for
reasons unrelated to encryption.
`QueuePayloadSchema` is an ordered union and `z.object` strips keys the
matching member doesn't declare. Adding an optional `runId` to
`HealthCheckPayloadSchema` made a probe payload also satisfy
`WorkflowInvokePayloadSchema`, whose only required field is `runId`. Because
the invoke member came first, world-vercel's queue handler parsed a
runId-bearing probe down to `{ runId }`, dropping `__healthCheck` and
`correlationId`.

The runtime dispatches on `__healthCheck` before falling through to the
invoke schema, so the probe was reinterpreted as "replay this run": it POSTed
`run_started` for a run that does not exist yet, 404'd, failed the handler,
and retried indefinitely. The probe never answered and the cross-deployment
`start()` timed out — which also regressed the pre-existing capability
detection, not just the new key lookup.

Order the health-check member first; it requires `__healthCheck: true`, which
no invoke or step payload carries, so invoke and step payloads still resolve
to their own members.

Also reorder `getPhysicalQueueName` to match health checks before the runId
branch, so under `WORKFLOW_SEQUENTIAL_REPLAYS=1` a probe keeps its per-probe
topic instead of queueing behind the run it is preparing.

@karthikscale3 karthikscale3 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.

ai review: Approved. The current head correctly mints the final run ID before the cross-deployment probe, derives the public key inside the target deployment, validates the returned key before selecting the write-only sealed path, and preserves the existing symmetric fallback. The runId-bearing health-check union/routing regression is fixed and covered by tests. The remaining webhook E2E timeouts are unrelated: the local case is same-deployment (no probe), and the Vercel run completed while the response-side test wait timed out.

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