Skip to content

feat(core): seal hook payloads to the target run's public key - #3096

Merged
TooTallNate merged 2 commits into
mainfrom
nate/encp-resume-hook-seal
Jul 28, 2026
Merged

feat(core): seal hook payloads to the target run's public key#3096
TooTallNate merged 2 commits into
mainfrom
nate/encp-resume-hook-seal

Conversation

@TooTallNate

@TooTallNate TooTallNate commented Jul 24, 2026

Copy link
Copy Markdown
Member

PR 4 of 7 — the payoff. Stacked on #3095; review the stack in order, starting from #3093.

Impact

Cross-deployment resumeHook() no longer calls getEncryptionKeyForRun, which on Vercel means a ~350ms run-key API round trip — Cosmos reads, a 50–75KB encrypted_env.json fetch from S3, up to three KMS decrypts — to recover 32 bytes. When the target run publishes a public key, the resumer seals to it using only the run entity it already fetched.

More than half of resumeHook() calls miss the same-deployment fast path, so this is the dominant term in hook-resumption latency. For Eve, hook resumption is what an agent turn waits on.

Also removes the sfo1 single-region dependency, the S3/KMS load, and the 2000 req/min/owner rate-limit exposure from the resumption hot path.

It also reduces privilege

Fetching the symmetric key grants read access to everything in the run. Sealing grants only the ability to write one payload to it. A resumer is now cryptographically unable to read the run it resumes — upgrading the previous honor-system importKey(raw, ['encrypt']) restriction to a real guarantee.

⚠️ The read path was the hidden requirement

Sealing is useless if the run can't open the result — and every reader previously resolved a bare symmetric CryptoKey, which by construction cannot open encp. Shipping the seal path alone would have wedged the first sealed hook payload with RuntimeDecryptionError.

Key resolution now yields the full capability (symmetric key + X25519 keypair) at all four read sites: memoizeEncryptionKey, the Run class, runs.ts, and getHookByTokenWithKey. Holder types widen from CryptoKey to PayloadKey — a pure widening. This is most of the diff, and it's why this PR is bigger than "make resumeHook seal".

Gating

Sealing is gated on presence of encryptionPublicKey, deliberately not on the capability version table the way encr and gzip are — see #3095 for why presence is the stronger attestation. resumeWebhook keeps using the symmetric key it already had to fetch (to hydrate hook metadata); sealing there would add an ECDH without saving a round trip.

Falls back to the symmetric path when the run has no public key (older SDKs), when the stored value is malformed, or when encryption is off.

🔍 Open decision: no AAD on sealed payloads

An earlier iteration of this design specified additional authenticated data of projectId|runId on every sealed payload. This PR seals with no AAD, because:

  • The content key is already derived over ephemeralPublicKey ‖ recipientPublicKey, and a recipient public key is unique per (deployment key × project × run). Replaying a sealed payload at a different run therefore already fails at key agreement.
  • AAD would add a second binding of the same property while introducing a real hazard: any writer/reader mismatch is an unrecoverable wedge, for zero security gain.

The aad parameter remains available in the sealed-box API for callers that want a KDF-independent binding, and the reasoning is documented in sealed-box.ts. Flagging it explicitly because it reverses an earlier design decision — it should be a review call, not mine.

Tests

8 tests on resumeHook covering: no key fetch when a public key is present, an end-to-end assertion that the sealed payload hydrates with the keys the owning deployment re-derives, all three fallback paths, presence-gating overriding an old reported core version, and the webhook override path. Plus 4 on memoizeEncryptionKey asserting the resolved key opens both schemes.

Full core suite (1596) passes; workspace typecheck clean; zero new lint diagnostics (verified against a stashed baseline).

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

changeset-bot Bot commented Jul 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 937917b

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

This PR includes changesets to release 16 packages
Name Type
@workflow/core 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/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

@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 28, 2026 12:46am
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jul 28, 2026 12:46am
example-workflow Ready Ready Preview, Comment Jul 28, 2026 12:46am
workbench-astro-workflow Ready Ready Preview, Comment Jul 28, 2026 12:46am
workbench-express-workflow Ready Ready Preview, Comment Jul 28, 2026 12:46am
workbench-fastify-workflow Ready Ready Preview, Comment Jul 28, 2026 12:46am
workbench-hono-workflow Ready Ready Preview, Comment Jul 28, 2026 12:46am
workbench-nestjs-workflow Ready Ready Preview, Comment Jul 28, 2026 12:46am
workbench-nitro-workflow Ready Ready Preview, Comment Jul 28, 2026 12:46am
workbench-nuxt-workflow Ready Ready Preview, Comment Jul 28, 2026 12:46am
workbench-sveltekit-workflow Ready Ready Preview, Comment Jul 28, 2026 12:46am
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jul 28, 2026 12:46am
workbench-vite-workflow Ready Ready Preview, Comment Jul 28, 2026 12:46am
workflow-docs Ready Ready Preview, Comment, Open in v0 Jul 28, 2026 12:46am
workflow-swc-playground Ready Ready Preview, Comment Jul 28, 2026 12:46am
workflow-tarballs Ready Ready Preview, Comment Jul 28, 2026 12:46am
workflow-web Ready Ready Preview, Comment Jul 28, 2026 12:46am

@github-actions

github-actions Bot commented Jul 24, 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

This PR updates @workflow/core to prefer sealing resumeHook() payloads to the target run’s published X25519 public key (when present), eliminating the cross-deployment run-key lookup while widening all relevant read sites to resolve the full payload-key capability so sealed payloads can be opened.

Changes:

  • resumeHook() now seals to WorkflowRun.encryptionPublicKey when available, falling back to the symmetric-key path when absent/malformed or when encryption is off.
  • Reader-side key resolution is widened from CryptoKey to PayloadKey/RunPayloadKeys so runs can open both encr and encp payloads.
  • Adds/updates tests to cover sealing, fallbacks, and end-to-end openability of sealed payloads.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/core/src/workflow.ts Widen runWorkflow encryption key parameter from CryptoKey to PayloadKey.
packages/core/src/step/context-storage.ts Widen step context encryptionKey type to PayloadKey.
packages/core/src/serialization/encryption.ts Add deriveRunPayloadKeys() to resolve full run capability (AES + keypair).
packages/core/src/serialization.ts Re-export sealed-box key variants/utilities from the public serialization surface.
packages/core/src/sealed-box.ts Document AAD rationale and add decodeRunPublicKey() helper for safe public-key parsing.
packages/core/src/runtime/step-executor.ts Widen step executor encryptionKey type to PayloadKey.
packages/core/src/runtime/runs.ts Use deriveRunPayloadKeys() when recreating runs so sealed payloads can be opened.
packages/core/src/runtime/run.ts Widen cached run key type and resolve via deriveRunPayloadKeys().
packages/core/src/runtime/resume-hook.ts Prefer sealing to published run public key; retain symmetric fallback + capability checks for encr.
packages/core/src/runtime/resume-hook.test.ts Add sealing/fallback tests and an end-to-end “seal then hydrate” assertion.
packages/core/src/runtime/helpers.ts Memoized per-run key accessor now resolves PayloadKey via deriveRunPayloadKeys().
packages/core/src/runtime/helpers.test.ts Add tests ensuring memoized keys can open both sealed and symmetric payloads and are cached.
packages/core/src/replay-payload-cache.ts Widen replay payload cache constructor key type to PayloadKey.
packages/core/src/private.ts Widen orchestrator context encryptionKey type to PayloadKey.
.changeset/encp-resume-hook-seal.md Changeset documenting sealing behavior and widened reader key capability.

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

Comment thread packages/core/src/runtime/helpers.ts
Comment thread packages/core/src/runtime/resume-hook.ts Outdated

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

Newly-committed generated file imports quickjs-wasi, a module not declared in packages/core/package.json nor installed, causing TS2307: Cannot find module 'quickjs-wasi' and breaking the TypeScript build across all deployments.

Fix on Vercel

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

workflow inspect --decrypt cannot open sealed (encp) hook payloads because the reader builds a bare symmetric CryptoKey (importKey) instead of the full RunPayloadKeys capability, so sealed cross-run hook payloads silently fall back to ciphertext placeholders.

Fix on Vercel

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 937917b · Tue, 28 Jul 2026 01:04:01 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 244 (-75%) 💚 1325 🔴 (+24%) 🔻 1338 🔴 (-1.0%) 1433 🔴 (-2.9%) 30
TTFS stream 249 (-74%) 💚 1347 🔴 (+29%) 🔻 1374 🔴 (+29%) 🔻 1410 🔴 (+25%) 🔻 30
TTFS hook + stream 474 (-60%) 💚 1567 🔴 (+19%) 🔻 1594 🔴 (+18%) 🔻 1867 🔴 (+23%) 🔻 30
STSO 1020 steps (1-20) 179 (+36%) 🔻 281 🔴 (+11%) 343 🔴 (+26%) 🔻 370 🔴 (+27%) 🔻 19
STSO 1020 steps (101-120) 184 (+2.2%) 281 🔴 (+8.1%) 438 🔴 (+30%) 🔻 458 🔴 (+20%) 🔻 19
STSO 1020 steps (1001-1020) 473 (+3.1%) 567 🔴 (+3.7%) 678 🔴 (+9.9%) 720 🔴 (+16%) 🔻 19
WO 1020 steps 412317 (+9.1%) 412317 (+9.1%) 412317 (+9.1%) 412317 (+9.1%) 1
SL stream latency 89 (+13%) 152 🔴 (+11%) 169 🔴 (+13%) 209 🔴 (-18%) 💚 30
SO stream overhead (text) 130 (+19%) 🔻 229 (+24%) 🔻 258 (+20%) 🔻 618 (+87%) 🔻 30
SO stream overhead (structured) 111 (+0.9%) 198 (+7.6%) 315 (±0%) 456 (+30%) 🔻 30
📜 Previous results (3)

54bab0e

Tue, 28 Jul 2026 00:06:34 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 282 (-62%) 💚 1361 🔴 (+20%) 🔻 1400 🔴 (+16%) 🔻 1578 🔴 (+15%) 🔻 30
TTFS stream 258 (-18%) 💚 1327 🔴 (+21%) 🔻 1395 🔴 (+22%) 🔻 1421 🔴 (+21%) 🔻 30
TTFS hook + stream 417 (-67%) 💚 1576 🔴 (+12%) 1666 🔴 (+14%) 2040 🔴 (+25%) 🔻 30
STSO 1020 steps (1-20) 165 (-2.9%) 294 🔴 (-2.6%) 440 🔴 (+25%) 🔻 480 🔴 (+27%) 🔻 19
STSO 1020 steps (101-120) 184 (-6.6%) 300 🔴 (-12%) 333 🔴 (-19%) 💚 359 🔴 (-28%) 💚 19
STSO 1020 steps (1001-1020) 488 (+1.0%) 613 🔴 (+6.8%) 708 🔴 (+9.6%) 1023 🔴 (+56%) 🔻 19
WO 1020 steps 428939 (-2.3%) 428939 (-2.3%) 428939 (-2.3%) 428939 (-2.3%) 1
SL stream latency 96 (+3.2%) 146 🔴 (-13%) 153 🔴 (-35%) 💚 171 🔴 (-32%) 💚 30
SO stream overhead (text) 126 (-3.8%) 252 🔴 (-13%) 298 (-16%) 💚 3575 🔴 (+749%) 🔻 30
SO stream overhead (structured) 117 (-13%) 290 🔴 (+12%) 396 (+42%) 🔻 496 (+20%) 🔻 30

54bab0e

Mon, 27 Jul 2026 22:14:33 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1289 (+426%) 🔻 1371 🔴 (+1.3%) 1389 🔴 (+1.0%) 1412 🔴 (-2.4%) 30
TTFS stream 1288 (+30%) 🔻 1371 🔴 (+2.0%) 1382 🔴 (+1.8%) 1457 🔴 (+1.0%) 30
TTFS hook + stream 1475 (+162%) 🔻 1630 🔴 (+25%) 🔻 1639 🔴 (+19%) 🔻 1689 🔴 (+12%) 30
STSO 1020 steps (1-20) 166 (-5.7%) 277 🔴 (±0%) 295 🔴 (-12%) 322 🔴 (-9.8%) 19
STSO 1020 steps (101-120) 189 (-4.5%) 250 🔴 (-22%) 💚 314 🔴 (-36%) 💚 606 🔴 (+16%) 🔻 19
STSO 1020 steps (1001-1020) 484 (+10%) 562 🔴 (-1.2%) 579 🔴 (-25%) 💚 610 🔴 (-32%) 💚 19
WO 1020 steps 395760 (-5.5%) 395760 (-5.5%) 395760 (-5.5%) 395760 (-5.5%) 1
SL stream latency 104 (+16%) 🔻 160 🔴 (-22%) 💚 176 🔴 (-25%) 💚 198 🔴 (-54%) 💚 30
SO stream overhead (text) 107 (-28%) 💚 172 (-41%) 💚 183 (-62%) 💚 251 (-88%) 💚 30
SO stream overhead (structured) 116 (-7.9%) 187 (-21%) 💚 232 (-14%) 358 (+3.5%) 30

8161db1

Mon, 27 Jul 2026 20:08:39 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS hook + stream 402 (-63%) 💚 1566 🔴 (+27%) 🔻 1649 🔴 (+31%) 🔻 2555 🔴 (+74%) 🔻 30
STSO 1020 steps (1-20) 178 (-1.7%) 313 🔴 (+13%) 343 🔴 (+13%) 349 🔴 (-11%) 19
STSO 1020 steps (101-120) 202 (+8.6%) 265 🔴 (-0.7%) 300 🔴 (±0%) 343 🔴 (-86%) 💚 19
STSO 1020 steps (1001-1020) 485 (+8.5%) 633 🔴 (+23%) 🔻 745 🔴 (+38%) 🔻 962 🔴 (+46%) 🔻 19
WO 1020 steps 406692 (+5.8%) 406692 (+5.8%) 406692 (+5.8%) 406692 (+5.8%) 1
SL stream latency 92 (+19%) 🔻 160 🔴 (+29%) 🔻 173 🔴 (+20%) 🔻 213 🔴 (-16%) 💚 30
SO stream overhead (text) 116 (+20%) 🔻 277 🔴 (+79%) 🔻 379 (+117%) 🔻 1429 🔴 (+622%) 🔻 30
SO stream overhead (structured) 135 (+34%) 🔻 250 (+49%) 🔻 310 (+75%) 🔻 709 (-54%) 💚 30
ℹ️ Metric definitions & methodology

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 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

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.

@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: The rebased head is otherwise clean and all checks pass, but the existing review finding remains blocking for this PR as an independently mergeable change: it begins emitting sealed (encp) hook payloads while workflow inspect --decrypt still constructs only a symmetric CryptoKey, so those payloads surface as ciphertext placeholders. The implementation fix exists in stacked PR #3097; please either land that fix with this change or ensure the two cannot merge/release separately.

@TooTallNate

Copy link
Copy Markdown
Member Author

@karthikscale3 you're right, and the ordering is now fixed: the o11y decrypt work lands before this PR.

It is in #3146 (base main), which supersedes #3097. Please merge #3146 first; this PR should not merge until it does. Once #3146 is in, I'll rebase this one onto main.

To make the reader side stand alone, deriveRunPayloadKeys moved into #3146. It's the capability the CLI/dashboard need to build the full key, and its own doc says to use it "anywhere a run reads its own event log", so it belongs on the reader side regardless. That makes #3146 independently correct — it can open encp payloads even before anything emits them — which is the property your review was asking for.

After #3146 merges, this PR's rebase will hit one expected conflict: it currently also adds deriveRunPayloadKeys, which will then be upstream. Resolution is to drop it here and keep main's.

One caveat worth flagging: #3097 is showing as "merged" in the UI but its code is not in main — I force-pushed its base to a commit containing its head, and GitHub auto-closed it as merged. That was my error, not a real merge. Details on #3097.

This is the payoff for the sealed-box work: cross-deployment
`resumeHook()` no longer calls `getEncryptionKeyForRun`, which on Vercel
means a ~350ms `run-key` API round trip (Cosmos reads, a 50–75KB
`encrypted_env.json` fetch from S3, up to three KMS decrypts) to recover
32 bytes. When the target run publishes a public key, the resumer seals to
it using only the run entity it already fetched.

More than half of `resumeHook()` calls miss the same-deployment fast path,
so this is the dominant term in hook-resumption latency — and for Eve,
hook resumption is what an agent turn waits on.

It also reduces privilege. Fetching the symmetric key grants read access
to everything in the run; sealing grants only the ability to write one
payload to it. A resumer is now cryptographically unable to read the run
it resumes.

Sealing is gated on the presence of `encryptionPublicKey`, deliberately
*not* on the capability version table the way `encr` and `gzip` are. A run
only carries a public key if the runtime that created it could also open a
sealed payload, and runs are pinned to their creating deployment — so
presence is a stronger attestation than comparing versions, and it stays
correct when `@workflow/core` and `@workflow/world-vercel` versions drift
independently (each single-axis version gate wedges a run under one drift
direction; presence wedges under neither).

`resumeWebhook` keeps using the symmetric key it already had to fetch in
order to hydrate hook metadata — sealing there would add an ECDH without
saving a round trip.

**Read path.** Sealing is useless if the run cannot open the result, and
every reader previously resolved a bare symmetric `CryptoKey`, which by
construction cannot open `encp`. Key resolution now yields the full
capability (symmetric key + X25519 keypair) at every read site:
`memoizeEncryptionKey`, the `Run` class, `runs.ts`, and
`getHookByTokenWithKey`. Without this the first sealed hook payload would
have wedged its run with `RuntimeDecryptionError`. Holder types widen from
`CryptoKey` to `PayloadKey`, which is a pure widening.

Falls back to the symmetric path when the run has no public key (older
SDKs), when the stored value is malformed, or when encryption is off — all
covered by tests, along with an end-to-end assertion that a sealed payload
actually hydrates with the keys the owning deployment re-derives.
- `memoizeEncryptionKey`'s JSDoc still described importing an AES-256
  `CryptoKey`. It now resolves a run's full capability (symmetric key plus
  X25519 keypair), and the reason matters: a run reading its own event log
  can meet sealed payloads another run wrote to it, and resolving only the
  symmetric key would leave those unopenable. Documented explicitly so the
  next reader does not "simplify" it back.
- A comment in `resumeHook` pointed at the `encr` capability gate as being
  "above" when it is in the fallback branch below. Reworded.
- Added the `decodeRunPublicKey` boundary test that belongs with the strict
  base64 work but needed this branch's code to exist.
@TooTallNate

Copy link
Copy Markdown
Member Author

@karthikscale3 ready for re-review — the ordering concern from your review is resolved.

#3146 (the o11y decrypt work) is merged, so workflow inspect --decrypt and the dashboard now build the run's full key capability and can open sealed (encp) payloads. This PR can therefore start emitting them without the window you flagged where they'd surface as ciphertext placeholders.

This branch is rebased onto that main (937917bac, 2 commits, mergeable). The rebase had exactly one conflict, as predicted: this PR previously added deriveRunPayloadKeys, which moved into #3146 to make the reader side stand alone. Resolved by dropping the copy here and keeping main's — verified the diff no longer re-adds the function.

Post-rebase verification: build clean, core 1591, world-vercel 299, cli 78 passing. Changeset re-checked too — this PR touches only @workflow/core, which is listed.

One heads-up unrelated to this PR: #3097 shows as "merged" in the UI but its code never reached main. I force-pushed its base branch to a commit that contained its head, and GitHub auto-closed it as merged. #3146 is the real landing of that work.

@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: Re-reviewed at 937917b after #3146 merged into the base. The observability readers now resolve the full run payload-key capability before this PR begins emitting sealed hook payloads, so the prior blocker is resolved. The rebased diff is clean and the refreshed E2E matrix passes.

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 0b1ca15 (AI decision).

This is feature work: PR 4 of a 7-PR stack that introduces sealed-box (encp) payload encryption, adding a new seal path to resumeHook() plus a new decodeRunPublicKey export, and it carries a minor changeset. It builds directly on main-only APIs from the earlier PRs in the stack (the encp primitive, the serialization envelope, and the encryptionPublicKey field on the run entity), and its motivation is latency optimization and privilege reduction rather than fixing a user-visible defect on stable.

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

0b1ca15dec3a4f56c32d33c5cf5add3ea208c54c

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