Skip to content

feat: publish each run's X25519 public key on the run entity - #3095

Merged
TooTallNate merged 3 commits into
mainfrom
nate/encp-run-public-key
Jul 27, 2026
Merged

feat: publish each run's X25519 public key on the run entity#3095
TooTallNate merged 3 commits into
mainfrom
nate/encp-run-public-key

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Jul 24, 2026

Copy link
Copy Markdown
Member

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

What

A cross-run writer needs the recipient run's public key to seal to it. Derive that key at start() and stamp it on the run, so a hook resumption or forwarded-stream writer finds it on a run fetch it was already making instead of spending ~350ms on run-key.

Derived from the per-run key material getEncryptionKeyForRun() already returns, so nothing about key acquisition changes.

Why storing it is safe

It is not secret. The matching private scalar is never stored anywhere — only re-derived on demand from the deployment's own env seed. So this does not violate the requirement that no key material live beside run metadata (that requirement is about secrets), and the server remains zero-knowledge: it stores and serves 32 public bytes it cannot use to decrypt anything.

Anyone with tenant-scoped read access to a run gains encryption capability for it, and nothing more.

Presence is the writer-side gate

A run only carries a public key if the runtime that created it could also open a sealed payload. That holds by construction: derivation and encp dispatch both live in @workflow/core, so any core that can stamp can also open. Runs are pinned to their creating deployment, so the capability this attests to is still accurate at resume time.

This matters because @workflow/core and @workflow/world-vercel are versioned independently and do drift in practice. A gate that consulted only one of them would wedge runs under one drift direction or the other:

  • If a deployment could stamp a key but its core could not dispatchencp, a writer would seal a payload the target cannot decode — run wedged. Locating derivation, stamping and dispatch all in @workflow/core makes this combination impossible to construct.
  • If a deployment's core supports encp but its world package never persisted the field, the run simply has no public key, and writers fall back to the symmetric path — degraded, not broken.

Presence therefore attests to both halves at once, which a version comparison cannot.

Resilient start

The field rides on run_createdand is mirrored onto the queued runInput, because the resilient-start path recreates the run from the queue message when the run_created write failed. Without that mirror, a run recovered that way would silently lose the ability to receive sealed writes.

Surface

PackageChange
@workflow/corederive + stamp at start(); browser/VM-safe base64 helpers
@workflow/worldoptional encryptionPublicKey on the run schema + run_created/run_started
@workflow/world-localmaterialize onto the run entity
@workflow/world-postgresencryption_public_key column + migration 0016
@workflow/world-vercelroute into the v4 frame meta block
@workflow/web-sharedhide the field in the attribute panel (internal plumbing)

Notes for reviewers

  • world-vercel's compile-time wire-contract guard caught the new field before it could be silently dropped on the v4 path, exactly as designed — it failed the build naming encryptionPublicKey. Nice.
  • Base64 helpers are hand-rolled because sealed-box.ts runs in the browser (o11y) and inside the workflow VM, where neither Buffer nor btoa can be assumed. They're cross-validated against Buffer in tests across a range of lengths.
  • base64ToBytes returns undefined on malformed input rather than throwing, so a corrupt stored key degrades to "no usable public key" → symmetric fallback, instead of crashing a resumption.
  • Tests include an end-to-end assertion that the published key actually opens a payload sealed to it, plus per-run isolation and the encryption-disabled case.

Postgres migration is additive and nullable. Full core suite (1585) passes; all world suites pass; workspace typecheck clean.

CopilotAI review requested due to automatic review settings July 24, 2026 20:56
@TooTallNate
TooTallNate requested review from a team and ijjk as code ownersJuly 24, 2026 20:56
@changeset-bot

changeset-botBot commented Jul 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e7c2128

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/world-postgresMinor
@workflow/web-sharedPatch
@workflow/worldMinor
@workflow/world-localMinor
@workflow/world-vercelMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/webPatch
workflowMinor
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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 24, 2026

Copy link
Copy Markdown
Contributor

@github-actions

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

This PR publishes each workflow run’s X25519 public key (encryptionPublicKey) onto the run entity at creation time, so cross-run writers can seal (encp) payloads to a run without an additional run-key fetch, while keeping private key material derivable-only (not stored).

Changes:

  • Derive the run’s X25519 public key from existing per-run key material during start() and include it on run_created and queued runInput (resilient start).
  • Plumb the optional encryptionPublicKey field through world schemas, Vercel v4 meta splitting, and local/postgres run materialization (plus a postgres migration).
  • Add base64 encode/decode helpers and tests; hide the field in the web attribute panel.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
packages/world/src/runs.tsAdds optional encryptionPublicKey to the run schema with rationale/semantics.
packages/world/src/events.tsAdds encryptionPublicKey to run_created and resilient-start run_started event schemas.
packages/world-vercel/src/events.tsRoutes encryptionPublicKey into the v4 meta block so it isn’t dropped on the wire.
packages/world-postgres/src/storage.tsPersists encryptionPublicKey from run_created into the run row.
packages/world-postgres/src/drizzle/schema.tsAdds encryptionPublicKey column mapping to the runs table schema.
packages/world-postgres/src/drizzle/migrations/meta/_journal.jsonRegisters migration 0016_add_encryption_public_key.
packages/world-postgres/src/drizzle/migrations/0016_add_encryption_public_key.sqlAdds encryption_public_key column to workflow_runs.
packages/world-local/src/storage/events-storage.tsPersists encryptionPublicKey onto the local run entity for run_created.
packages/web-shared/src/components/sidebar/attribute-panel.tsxHides encryptionPublicKey in the attribute panel UI.
packages/core/src/sealed-box.tsAdds VM/browser-safe base64 helpers used for publishing the run public key.
packages/core/src/sealed-box.test.tsTests base64 helpers against Buffer plus malformed input behavior.
packages/core/src/runtime/start.tsDerives/stamps encryptionPublicKey on run_created and queued runInput.
packages/core/src/runtime/start.test.tsAdds tests asserting stamping, usability (seal/open), isolation, and disabled-encryption behavior.
.changeset/encp-run-public-key-worlds.mdChangeset for world packages to persist/transport encryptionPublicKey.
.changeset/encp-run-public-key-web-shared.mdChangeset for hiding encryptionPublicKey in the web attribute panel.
.changeset/encp-run-public-key-postgres.mdChangeset for the postgres column/migration.
.changeset/encp-run-public-key-core.mdChangeset for core stamping at start().

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

Comment threadpackages/core/src/sealed-box.ts
Comment threadpackages/world-postgres/src/storage.ts
Comment threadpackages/world-local/src/storage/events-storage.ts

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

  1. world-local lifecycle handlers (run_started/completed/failed/cancelled) rebuild the run row without encryptionPublicKey, so the key stamped at run_created is dropped as soon as the run starts.
  1. The world-postgres resilient-start path (run_started recreating a run) drops encryptionPublicKey, so runs recovered this way permanently lack their public key and cannot receive sealed writes.
  1. The resilient-start path (run_started recreating a run when run_created was missed) drops encryptionPublicKey, so a run recovered this way permanently loses its ability to receive sealed writes on the world-local (and world-postgres) backends.

Fix on Vercel

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

Accidentally committed generated file quickjs-assets.generated.ts imports the undeclared module quickjs-wasi, causing TS2307: Cannot find module 'quickjs-wasi' and breaking @workflow/core's build.

Fix on Vercel

@TooTallNate

TooTallNate commented Jul 25, 2026

Copy link
Copy Markdown
MemberAuthor

Heads up — this PR's public key never actually reached the server. Found it during manual verification; the fix is now the third commit on this branch (34056d55f).

What was happening

The key was computed, put in the run_created event data, lifted into the v4 frame meta, spread into the request input… and then silently dropped before the request was sent.

packages/world-vercel/src/events-v4.ts builds the wire meta field-by-field, and buildPostFrameMeta never forwarded encryptionPublicKey. The field was also missing from the CreateEventV4Input interface entirely.

The reason nothing caught it: events.ts applies the meta with a spread:

const{ payload, meta }=splitEventDataForV4(data);awaitcreateWorkflowRunEventV4({runId: id,/* … */ payload, ...meta},config);

TypeScript doesn't apply excess-property checks to spreads, so an unforwarded field is not a type error. The exhaustiveness guard added in this PR covers world schema ↔ events.ts allowlist, but there is no guard for events.ts meta ↔ events-v4.ts wire. That's the same hand-maintained v4 mapping AGENTS.md already flags for having regressed trace-context propagation.

Why this was hard to see

Every symptom pointed at the server side rather than at us:

  • a deliberately oversized key was accepted rather than rejected — because the field never arrived
  • the key was never echoed back
  • encryptionPublicKey was absent from the run row in DynamoDB
  • resumeHook() always chose encr

I spent a while looking server-side. The problem was here: the SDK was dropping the field before it ever went out.

Fix

Add encryptionPublicKey to CreateEventV4Input, forward it in buildPostFrameMeta, cover both run_created and resilient-start run_started, and add a generic guard asserting every field the splitter puts in the meta reaches the wire — so the next omission in this mapping fails a test instead of silently degrading encryption. All three new tests fail without the one-line forward.

Verified in production

run_created.eventData.encryptionPublicKey = 4jWP17aASciRa+hftatSP8/21Qy+byelU0WGkFKjC2w=
run entity .encryptionPublicKey = 4jWP17aASciRa+hftatSP8/21Qy+byelU0WGkFKjC2w=

Then a genuinely cross-deployment resumeHook() (owner dpl_7HJcFLVE…, resumer dpl_9RYnfewA…):

hook_received.payload._data prefix = b'encp' (111 bytes) <- was `encr`
run status = completed
returnValue.received = {"sealed":"from-deployment-B","n":42}

The round trip is the important part: an encp prefix only proves the writer sealed, but the payload coming back out of the workflow proves the owner re-derived its scalar and opened it. (The run's own outputRef stays encr, which is correct — only cross-run writers seal.)

Where the fix landed

Originally I committed this on the stack tip, which would have meant merging #3095#3098 with encryption silently downgraded to encr. It now lives here instead, so every merge point in the stack is correct. The whole stack was rebased onto latest main at the same time.

@karthikscale3karthikscale3 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: Reviewed the run public-key publication path end to end. The key is derived only when encryption material exists and is propagated through run_created, queued runInput, resilient-start reconstruction, lifecycle rewrites, Postgres persistence/migration, and the Vercel v4 split/meta wire path. The three existing inline findings and the lifecycle/resilient-start bot findings are fixed on this head. The stale generated-file warning is not present and module-boundary checks pass. The matrix-wide Vercel production failures remain unrelated to this change.

@github-actions

github-actionsBot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit e7c2128 · Mon, 27 Jul 2026 21:52:33 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1302 (+30%) 🔻1391 🔴 (+23%) 🔻1405 🔴 (+18%) 🔻1488 🔴 (-7.5%)30
TTFSstream1289 (+325%) 🔻1348 🔴 (+17%) 🔻1380 🔴 (+15%) 🔻1428 🔴 (+6.5%)30
TTFShook + stream1162 (+157%) 🔻1630 🔴 (+14%)1689 🔴 (+14%)1971 🔴 (+5.2%)30
STSO1020 steps (1-20)167 (-9.7%)273 🔴 (-6.2%)321 🔴 (-5.3%)559 🔴 (+37%) 🔻19
STSO1020 steps (101-120)196 (+1.0%)278 🔴 (-4.5%)322 🔴 (-41%) 💚322 🔴 (-49%) 💚19
STSO1020 steps (1001-1020)465 (-13%)517 🔴 (-23%) 💚571 🔴 (-40%) 💚676 🔴 (-31%) 💚19
WO1020 steps401208 (-9.9%)401208 (-9.9%)401208 (-9.9%)401208 (-9.9%)1
SLstream latency107 (+7.0%)161 🔴 (-25%) 💚213 🔴 (-52%) 💚442 🔴 (-22%) 💚30
SOstream overhead (text)93 (-27%) 💚195 (-19%) 💚220 (-22%) 💚295 (-69%) 💚30
SOstream overhead (structured)122 (+2.5%)183 (-33%) 💚224 (-24%) 💚297 (-29%) 💚30
📜 Previous results (1)

57d0c1b

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFShook + stream399 (-63%) 💚1644 🔴 (+34%) 🔻1686 🔴 (+34%) 🔻2072 🔴 (+41%) 🔻30
STSO1020 steps (1-20)187 (+3.3%)297 🔴 (+7.6%)349 🔴 (+15%)409 🔴 (+3.8%)19
STSO1020 steps (101-120)190 (+2.2%)373 🔴 (+40%) 🔻464 🔴 (+54%) 🔻2155 🔴 (-11%)19
STSO1020 steps (1001-1020)515 (+15%) 🔻594 🔴 (+15%) 🔻712 🔴 (+32%) 🔻1307 🔴 (+99%) 🔻19
WO1020 steps433580 (+13%)433580 (+13%)433580 (+13%)433580 (+13%)1
SLstream latency99 (+29%) 🔻180 🔴 (+45%) 🔻191 🔴 (+33%) 🔻495 🔴 (+94%) 🔻30
SOstream overhead (text)125 (+29%) 🔻238 (+54%) 🔻273 (+56%) 🔻389 (+96%) 🔻30
SOstream overhead (structured)134 (+33%) 🔻221 (+32%) 🔻299 (+69%) 🔻426 (-72%) 💚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.

A cross-run writer needs the recipient run's public key to seal a payload
to it. Derive that key at `start()` and stamp it on the run, so a hook
resumption or a forwarded-stream writer can find it on a run fetch it was
already making instead of spending ~350ms on `run-key`.
The key is derived from the per-run key material `getEncryptionKeyForRun()`
already returns, so nothing about key acquisition changes. It is not
secret: the matching private scalar is never stored anywhere, only
re-derived on demand from the deployment's own env seed. Storing it beside
run metadata therefore does not weaken the run's confidentiality.
**Presence is the writer-side gate for sealed envelopes.** A run only
carries a public key if the runtime that created it could also open one —
which holds by construction, since derivation and `encp` dispatch both
live in `@workflow/core`, so any core that can stamp can also open. Runs
are pinned to their creating deployment, so the capability this attests to
is still accurate at resume time. Writers seal iff the field is set and
otherwise fall back to the symmetric path, which makes version skew
degrade gracefully instead of wedging a run.
The field rides on `run_created`, and is mirrored onto the queued
`runInput` so the resilient-start path (server recreates the run from the
queue message when the `run_created` write failed) doesn't silently
produce a run that can't receive sealed writes.
world-vercel's compile-time wire-contract guard caught the new field
before it could be silently dropped on the v4 path, exactly as designed —
routed into the frame meta block as plaintext metadata.
Also adds browser- and VM-safe base64 helpers to `sealed-box.ts`, since
neither `Buffer` nor `btoa` can be assumed in every context that module
runs in. `base64ToBytes` returns undefined on malformed input rather than
throwing, so a corrupt stored key degrades to "no usable public key" and
falls back to the symmetric path instead of crashing a resumption. Both
are cross-validated against `Buffer` in tests.
Two real bugs found in review, both in the local worlds. Neither surfaces
as an error — a run just silently stops accepting sealed cross-run writes
and falls back to the slow symmetric path forever.
**Resilient start dropped the key.** When a `run_started` arrives for a
run that was never created, world-local and world-postgres rebuild the run
from the queued message. Neither copied `encryptionPublicKey` onto the run
row or the synthetic `run_created` event they write. That is precisely the
scenario this field exists to survive. (The equivalent server-side path was
already handled.)
**world-local also wiped the key on every lifecycle transition.** Its
run_started / run_completed / run_failed / run_cancelled handlers rewrite
the whole run document field-by-field, so any field not explicitly listed
is dropped — meaning the key was lost on the *first* `run_started`, not
just on the resilient path. All four rebuild sites now carry it.
world-postgres is safe here by construction because it issues
column-scoped SQL UPDATEs rather than rewriting the row.
**base64 decoding is now strict.** The decoder accepted shapes that
cannot describe a whole number of bytes (`length % 4 === 1`) and ignored
anything after a mid-string `=`, returning a short array instead of
`undefined`. That is worse than throwing: a corrupt stored key looked
*present*, so callers sealed to garbage rather than taking the symmetric
fallback. Now rejects out-of-alphabet characters, bad lengths, misplaced
padding, and non-zero trailing bits — with a round-trip test over every
length 0–48 to make sure the strictness does not overshoot.
`splitEventDataForV4` lifted the run's public key into the frame meta and
`events.ts` spread that meta into `CreateEventV4Input`, but
`buildPostFrameMeta` — which copies meta onto the wire field by field — never
forwarded `encryptionPublicKey`, and the field was missing from
`CreateEventV4Input` entirely. Because the meta is applied with a spread,
TypeScript's excess-property check doesn't fire, so the key was computed, put
in the meta, and then silently dropped before the request was sent.
The server therefore never received the key, never stored it on the run
entity, and every cross-run writer fell back to the symmetric envelope. Every
symptom pointed away from the SDK: a deliberately oversized key was accepted
rather than rejected (the field never arrived), the key was absent from the run
row, and `resumeHook()` always chose `encr`.
Add the field to `CreateEventV4Input`, forward it in `buildPostFrameMeta`, and
cover it for both `run_created` and resilient-start `run_started`. Also add a
generic guard asserting that every field the splitter puts in the meta reaches
the wire, so the next omission in this hand-maintained mapping fails a test
instead of silently degrading encryption.
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for b4ba79e (AI decision).

This is feature work: it adds a new encryptionPublicKey field to the run schema, new exported base64 helpers, a new Postgres column plus migration 0016, and new v4 wire-contract plumbing — carrying minor changesets and building toward the stacked sealed-envelope (encp) feature series (#3093#3099). The fix-flavored commits inside it (public-key loss on resilient start, strict base64 decoding, missing field in buildPostFrameMeta) only correct defects in this same new, main-only capability, so they have nothing to fix on stable.

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

b4ba79ebc501248408474efdd6e353f1753d83e3

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

@TooTallNate@karthikscale3
, '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: publish each run's X25519 public key on the run entity by TooTallNate · Pull Request #3095 · vercel/workflow · GitHub
Skip to content

feat: publish each run's X25519 public key on the run entity - #3095

Merged
TooTallNate merged 3 commits into
mainfrom
nate/encp-run-public-key
Jul 27, 2026
Merged

feat: publish each run's X25519 public key on the run entity#3095
TooTallNate merged 3 commits into
mainfrom
nate/encp-run-public-key

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Jul 24, 2026

Copy link
Copy Markdown
Member

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

What

A cross-run writer needs the recipient run's public key to seal to it. Derive that key at start() and stamp it on the run, so a hook resumption or forwarded-stream writer finds it on a run fetch it was already making instead of spending ~350ms on run-key.

Derived from the per-run key material getEncryptionKeyForRun() already returns, so nothing about key acquisition changes.

Why storing it is safe

It is not secret. The matching private scalar is never stored anywhere — only re-derived on demand from the deployment's own env seed. So this does not violate the requirement that no key material live beside run metadata (that requirement is about secrets), and the server remains zero-knowledge: it stores and serves 32 public bytes it cannot use to decrypt anything.

Anyone with tenant-scoped read access to a run gains encryption capability for it, and nothing more.

Presence is the writer-side gate

A run only carries a public key if the runtime that created it could also open a sealed payload. That holds by construction: derivation and encp dispatch both live in @workflow/core, so any core that can stamp can also open. Runs are pinned to their creating deployment, so the capability this attests to is still accurate at resume time.

This matters because @workflow/core and @workflow/world-vercel are versioned independently and do drift in practice. A gate that consulted only one of them would wedge runs under one drift direction or the other:

  • If a deployment could stamp a key but its core could not dispatchencp, a writer would seal a payload the target cannot decode — run wedged. Locating derivation, stamping and dispatch all in @workflow/core makes this combination impossible to construct.
  • If a deployment's core supports encp but its world package never persisted the field, the run simply has no public key, and writers fall back to the symmetric path — degraded, not broken.

Presence therefore attests to both halves at once, which a version comparison cannot.

Resilient start

The field rides on run_createdand is mirrored onto the queued runInput, because the resilient-start path recreates the run from the queue message when the run_created write failed. Without that mirror, a run recovered that way would silently lose the ability to receive sealed writes.

Surface

PackageChange
@workflow/corederive + stamp at start(); browser/VM-safe base64 helpers
@workflow/worldoptional encryptionPublicKey on the run schema + run_created/run_started
@workflow/world-localmaterialize onto the run entity
@workflow/world-postgresencryption_public_key column + migration 0016
@workflow/world-vercelroute into the v4 frame meta block
@workflow/web-sharedhide the field in the attribute panel (internal plumbing)

Notes for reviewers

  • world-vercel's compile-time wire-contract guard caught the new field before it could be silently dropped on the v4 path, exactly as designed — it failed the build naming encryptionPublicKey. Nice.
  • Base64 helpers are hand-rolled because sealed-box.ts runs in the browser (o11y) and inside the workflow VM, where neither Buffer nor btoa can be assumed. They're cross-validated against Buffer in tests across a range of lengths.
  • base64ToBytes returns undefined on malformed input rather than throwing, so a corrupt stored key degrades to "no usable public key" → symmetric fallback, instead of crashing a resumption.
  • Tests include an end-to-end assertion that the published key actually opens a payload sealed to it, plus per-run isolation and the encryption-disabled case.

Postgres migration is additive and nullable. Full core suite (1585) passes; all world suites pass; workspace typecheck clean.

CopilotAI review requested due to automatic review settings July 24, 2026 20:56
@TooTallNate
TooTallNate requested review from a team and ijjk as code ownersJuly 24, 2026 20:56
@changeset-bot

changeset-botBot commented Jul 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e7c2128

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/world-postgresMinor
@workflow/web-sharedPatch
@workflow/worldMinor
@workflow/world-localMinor
@workflow/world-vercelMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/webPatch
workflowMinor
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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 24, 2026

Copy link
Copy Markdown
Contributor

@github-actions

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

This PR publishes each workflow run’s X25519 public key (encryptionPublicKey) onto the run entity at creation time, so cross-run writers can seal (encp) payloads to a run without an additional run-key fetch, while keeping private key material derivable-only (not stored).

Changes:

  • Derive the run’s X25519 public key from existing per-run key material during start() and include it on run_created and queued runInput (resilient start).
  • Plumb the optional encryptionPublicKey field through world schemas, Vercel v4 meta splitting, and local/postgres run materialization (plus a postgres migration).
  • Add base64 encode/decode helpers and tests; hide the field in the web attribute panel.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
packages/world/src/runs.tsAdds optional encryptionPublicKey to the run schema with rationale/semantics.
packages/world/src/events.tsAdds encryptionPublicKey to run_created and resilient-start run_started event schemas.
packages/world-vercel/src/events.tsRoutes encryptionPublicKey into the v4 meta block so it isn’t dropped on the wire.
packages/world-postgres/src/storage.tsPersists encryptionPublicKey from run_created into the run row.
packages/world-postgres/src/drizzle/schema.tsAdds encryptionPublicKey column mapping to the runs table schema.
packages/world-postgres/src/drizzle/migrations/meta/_journal.jsonRegisters migration 0016_add_encryption_public_key.
packages/world-postgres/src/drizzle/migrations/0016_add_encryption_public_key.sqlAdds encryption_public_key column to workflow_runs.
packages/world-local/src/storage/events-storage.tsPersists encryptionPublicKey onto the local run entity for run_created.
packages/web-shared/src/components/sidebar/attribute-panel.tsxHides encryptionPublicKey in the attribute panel UI.
packages/core/src/sealed-box.tsAdds VM/browser-safe base64 helpers used for publishing the run public key.
packages/core/src/sealed-box.test.tsTests base64 helpers against Buffer plus malformed input behavior.
packages/core/src/runtime/start.tsDerives/stamps encryptionPublicKey on run_created and queued runInput.
packages/core/src/runtime/start.test.tsAdds tests asserting stamping, usability (seal/open), isolation, and disabled-encryption behavior.
.changeset/encp-run-public-key-worlds.mdChangeset for world packages to persist/transport encryptionPublicKey.
.changeset/encp-run-public-key-web-shared.mdChangeset for hiding encryptionPublicKey in the web attribute panel.
.changeset/encp-run-public-key-postgres.mdChangeset for the postgres column/migration.
.changeset/encp-run-public-key-core.mdChangeset for core stamping at start().

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

Comment threadpackages/core/src/sealed-box.ts
Comment threadpackages/world-postgres/src/storage.ts
Comment threadpackages/world-local/src/storage/events-storage.ts

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

  1. world-local lifecycle handlers (run_started/completed/failed/cancelled) rebuild the run row without encryptionPublicKey, so the key stamped at run_created is dropped as soon as the run starts.
  1. The world-postgres resilient-start path (run_started recreating a run) drops encryptionPublicKey, so runs recovered this way permanently lack their public key and cannot receive sealed writes.
  1. The resilient-start path (run_started recreating a run when run_created was missed) drops encryptionPublicKey, so a run recovered this way permanently loses its ability to receive sealed writes on the world-local (and world-postgres) backends.

Fix on Vercel

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

Accidentally committed generated file quickjs-assets.generated.ts imports the undeclared module quickjs-wasi, causing TS2307: Cannot find module 'quickjs-wasi' and breaking @workflow/core's build.

Fix on Vercel

@TooTallNate

TooTallNate commented Jul 25, 2026

Copy link
Copy Markdown
MemberAuthor

Heads up — this PR's public key never actually reached the server. Found it during manual verification; the fix is now the third commit on this branch (34056d55f).

What was happening

The key was computed, put in the run_created event data, lifted into the v4 frame meta, spread into the request input… and then silently dropped before the request was sent.

packages/world-vercel/src/events-v4.ts builds the wire meta field-by-field, and buildPostFrameMeta never forwarded encryptionPublicKey. The field was also missing from the CreateEventV4Input interface entirely.

The reason nothing caught it: events.ts applies the meta with a spread:

const{ payload, meta }=splitEventDataForV4(data);awaitcreateWorkflowRunEventV4({runId: id,/* … */ payload, ...meta},config);

TypeScript doesn't apply excess-property checks to spreads, so an unforwarded field is not a type error. The exhaustiveness guard added in this PR covers world schema ↔ events.ts allowlist, but there is no guard for events.ts meta ↔ events-v4.ts wire. That's the same hand-maintained v4 mapping AGENTS.md already flags for having regressed trace-context propagation.

Why this was hard to see

Every symptom pointed at the server side rather than at us:

  • a deliberately oversized key was accepted rather than rejected — because the field never arrived
  • the key was never echoed back
  • encryptionPublicKey was absent from the run row in DynamoDB
  • resumeHook() always chose encr

I spent a while looking server-side. The problem was here: the SDK was dropping the field before it ever went out.

Fix

Add encryptionPublicKey to CreateEventV4Input, forward it in buildPostFrameMeta, cover both run_created and resilient-start run_started, and add a generic guard asserting every field the splitter puts in the meta reaches the wire — so the next omission in this mapping fails a test instead of silently degrading encryption. All three new tests fail without the one-line forward.

Verified in production

run_created.eventData.encryptionPublicKey = 4jWP17aASciRa+hftatSP8/21Qy+byelU0WGkFKjC2w=
run entity .encryptionPublicKey = 4jWP17aASciRa+hftatSP8/21Qy+byelU0WGkFKjC2w=

Then a genuinely cross-deployment resumeHook() (owner dpl_7HJcFLVE…, resumer dpl_9RYnfewA…):

hook_received.payload._data prefix = b'encp' (111 bytes) <- was `encr`
run status = completed
returnValue.received = {"sealed":"from-deployment-B","n":42}

The round trip is the important part: an encp prefix only proves the writer sealed, but the payload coming back out of the workflow proves the owner re-derived its scalar and opened it. (The run's own outputRef stays encr, which is correct — only cross-run writers seal.)

Where the fix landed

Originally I committed this on the stack tip, which would have meant merging #3095#3098 with encryption silently downgraded to encr. It now lives here instead, so every merge point in the stack is correct. The whole stack was rebased onto latest main at the same time.

@karthikscale3karthikscale3 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: Reviewed the run public-key publication path end to end. The key is derived only when encryption material exists and is propagated through run_created, queued runInput, resilient-start reconstruction, lifecycle rewrites, Postgres persistence/migration, and the Vercel v4 split/meta wire path. The three existing inline findings and the lifecycle/resilient-start bot findings are fixed on this head. The stale generated-file warning is not present and module-boundary checks pass. The matrix-wide Vercel production failures remain unrelated to this change.

@github-actions

github-actionsBot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit e7c2128 · Mon, 27 Jul 2026 21:52:33 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1302 (+30%) 🔻1391 🔴 (+23%) 🔻1405 🔴 (+18%) 🔻1488 🔴 (-7.5%)30
TTFSstream1289 (+325%) 🔻1348 🔴 (+17%) 🔻1380 🔴 (+15%) 🔻1428 🔴 (+6.5%)30
TTFShook + stream1162 (+157%) 🔻1630 🔴 (+14%)1689 🔴 (+14%)1971 🔴 (+5.2%)30
STSO1020 steps (1-20)167 (-9.7%)273 🔴 (-6.2%)321 🔴 (-5.3%)559 🔴 (+37%) 🔻19
STSO1020 steps (101-120)196 (+1.0%)278 🔴 (-4.5%)322 🔴 (-41%) 💚322 🔴 (-49%) 💚19
STSO1020 steps (1001-1020)465 (-13%)517 🔴 (-23%) 💚571 🔴 (-40%) 💚676 🔴 (-31%) 💚19
WO1020 steps401208 (-9.9%)401208 (-9.9%)401208 (-9.9%)401208 (-9.9%)1
SLstream latency107 (+7.0%)161 🔴 (-25%) 💚213 🔴 (-52%) 💚442 🔴 (-22%) 💚30
SOstream overhead (text)93 (-27%) 💚195 (-19%) 💚220 (-22%) 💚295 (-69%) 💚30
SOstream overhead (structured)122 (+2.5%)183 (-33%) 💚224 (-24%) 💚297 (-29%) 💚30
📜 Previous results (1)

57d0c1b

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFShook + stream399 (-63%) 💚1644 🔴 (+34%) 🔻1686 🔴 (+34%) 🔻2072 🔴 (+41%) 🔻30
STSO1020 steps (1-20)187 (+3.3%)297 🔴 (+7.6%)349 🔴 (+15%)409 🔴 (+3.8%)19
STSO1020 steps (101-120)190 (+2.2%)373 🔴 (+40%) 🔻464 🔴 (+54%) 🔻2155 🔴 (-11%)19
STSO1020 steps (1001-1020)515 (+15%) 🔻594 🔴 (+15%) 🔻712 🔴 (+32%) 🔻1307 🔴 (+99%) 🔻19
WO1020 steps433580 (+13%)433580 (+13%)433580 (+13%)433580 (+13%)1
SLstream latency99 (+29%) 🔻180 🔴 (+45%) 🔻191 🔴 (+33%) 🔻495 🔴 (+94%) 🔻30
SOstream overhead (text)125 (+29%) 🔻238 (+54%) 🔻273 (+56%) 🔻389 (+96%) 🔻30
SOstream overhead (structured)134 (+33%) 🔻221 (+32%) 🔻299 (+69%) 🔻426 (-72%) 💚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.

A cross-run writer needs the recipient run's public key to seal a payload
to it. Derive that key at `start()` and stamp it on the run, so a hook
resumption or a forwarded-stream writer can find it on a run fetch it was
already making instead of spending ~350ms on `run-key`.
The key is derived from the per-run key material `getEncryptionKeyForRun()`
already returns, so nothing about key acquisition changes. It is not
secret: the matching private scalar is never stored anywhere, only
re-derived on demand from the deployment's own env seed. Storing it beside
run metadata therefore does not weaken the run's confidentiality.
**Presence is the writer-side gate for sealed envelopes.** A run only
carries a public key if the runtime that created it could also open one —
which holds by construction, since derivation and `encp` dispatch both
live in `@workflow/core`, so any core that can stamp can also open. Runs
are pinned to their creating deployment, so the capability this attests to
is still accurate at resume time. Writers seal iff the field is set and
otherwise fall back to the symmetric path, which makes version skew
degrade gracefully instead of wedging a run.
The field rides on `run_created`, and is mirrored onto the queued
`runInput` so the resilient-start path (server recreates the run from the
queue message when the `run_created` write failed) doesn't silently
produce a run that can't receive sealed writes.
world-vercel's compile-time wire-contract guard caught the new field
before it could be silently dropped on the v4 path, exactly as designed —
routed into the frame meta block as plaintext metadata.
Also adds browser- and VM-safe base64 helpers to `sealed-box.ts`, since
neither `Buffer` nor `btoa` can be assumed in every context that module
runs in. `base64ToBytes` returns undefined on malformed input rather than
throwing, so a corrupt stored key degrades to "no usable public key" and
falls back to the symmetric path instead of crashing a resumption. Both
are cross-validated against `Buffer` in tests.
Two real bugs found in review, both in the local worlds. Neither surfaces
as an error — a run just silently stops accepting sealed cross-run writes
and falls back to the slow symmetric path forever.
**Resilient start dropped the key.** When a `run_started` arrives for a
run that was never created, world-local and world-postgres rebuild the run
from the queued message. Neither copied `encryptionPublicKey` onto the run
row or the synthetic `run_created` event they write. That is precisely the
scenario this field exists to survive. (The equivalent server-side path was
already handled.)
**world-local also wiped the key on every lifecycle transition.** Its
run_started / run_completed / run_failed / run_cancelled handlers rewrite
the whole run document field-by-field, so any field not explicitly listed
is dropped — meaning the key was lost on the *first* `run_started`, not
just on the resilient path. All four rebuild sites now carry it.
world-postgres is safe here by construction because it issues
column-scoped SQL UPDATEs rather than rewriting the row.
**base64 decoding is now strict.** The decoder accepted shapes that
cannot describe a whole number of bytes (`length % 4 === 1`) and ignored
anything after a mid-string `=`, returning a short array instead of
`undefined`. That is worse than throwing: a corrupt stored key looked
*present*, so callers sealed to garbage rather than taking the symmetric
fallback. Now rejects out-of-alphabet characters, bad lengths, misplaced
padding, and non-zero trailing bits — with a round-trip test over every
length 0–48 to make sure the strictness does not overshoot.
`splitEventDataForV4` lifted the run's public key into the frame meta and
`events.ts` spread that meta into `CreateEventV4Input`, but
`buildPostFrameMeta` — which copies meta onto the wire field by field — never
forwarded `encryptionPublicKey`, and the field was missing from
`CreateEventV4Input` entirely. Because the meta is applied with a spread,
TypeScript's excess-property check doesn't fire, so the key was computed, put
in the meta, and then silently dropped before the request was sent.
The server therefore never received the key, never stored it on the run
entity, and every cross-run writer fell back to the symmetric envelope. Every
symptom pointed away from the SDK: a deliberately oversized key was accepted
rather than rejected (the field never arrived), the key was absent from the run
row, and `resumeHook()` always chose `encr`.
Add the field to `CreateEventV4Input`, forward it in `buildPostFrameMeta`, and
cover it for both `run_created` and resilient-start `run_started`. Also add a
generic guard asserting that every field the splitter puts in the meta reaches
the wire, so the next omission in this hand-maintained mapping fails a test
instead of silently degrading encryption.
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for b4ba79e (AI decision).

This is feature work: it adds a new encryptionPublicKey field to the run schema, new exported base64 helpers, a new Postgres column plus migration 0016, and new v4 wire-contract plumbing — carrying minor changesets and building toward the stacked sealed-envelope (encp) feature series (#3093#3099). The fix-flavored commits inside it (public-key loss on resilient start, strict base64 decoding, missing field in buildPostFrameMeta) only correct defects in this same new, main-only capability, so they have nothing to fix on stable.

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

b4ba79ebc501248408474efdd6e353f1753d83e3

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

@TooTallNate@karthikscale3
, '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: publish each run's X25519 public key on the run entity by TooTallNate · Pull Request #3095 · vercel/workflow · GitHub
Skip to content

feat: publish each run's X25519 public key on the run entity - #3095

Merged
TooTallNate merged 3 commits into
mainfrom
nate/encp-run-public-key
Jul 27, 2026
Merged

feat: publish each run's X25519 public key on the run entity#3095
TooTallNate merged 3 commits into
mainfrom
nate/encp-run-public-key

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Jul 24, 2026

Copy link
Copy Markdown
Member

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

What

A cross-run writer needs the recipient run's public key to seal to it. Derive that key at start() and stamp it on the run, so a hook resumption or forwarded-stream writer finds it on a run fetch it was already making instead of spending ~350ms on run-key.

Derived from the per-run key material getEncryptionKeyForRun() already returns, so nothing about key acquisition changes.

Why storing it is safe

It is not secret. The matching private scalar is never stored anywhere — only re-derived on demand from the deployment's own env seed. So this does not violate the requirement that no key material live beside run metadata (that requirement is about secrets), and the server remains zero-knowledge: it stores and serves 32 public bytes it cannot use to decrypt anything.

Anyone with tenant-scoped read access to a run gains encryption capability for it, and nothing more.

Presence is the writer-side gate

A run only carries a public key if the runtime that created it could also open a sealed payload. That holds by construction: derivation and encp dispatch both live in @workflow/core, so any core that can stamp can also open. Runs are pinned to their creating deployment, so the capability this attests to is still accurate at resume time.

This matters because @workflow/core and @workflow/world-vercel are versioned independently and do drift in practice. A gate that consulted only one of them would wedge runs under one drift direction or the other:

  • If a deployment could stamp a key but its core could not dispatchencp, a writer would seal a payload the target cannot decode — run wedged. Locating derivation, stamping and dispatch all in @workflow/core makes this combination impossible to construct.
  • If a deployment's core supports encp but its world package never persisted the field, the run simply has no public key, and writers fall back to the symmetric path — degraded, not broken.

Presence therefore attests to both halves at once, which a version comparison cannot.

Resilient start

The field rides on run_createdand is mirrored onto the queued runInput, because the resilient-start path recreates the run from the queue message when the run_created write failed. Without that mirror, a run recovered that way would silently lose the ability to receive sealed writes.

Surface

PackageChange
@workflow/corederive + stamp at start(); browser/VM-safe base64 helpers
@workflow/worldoptional encryptionPublicKey on the run schema + run_created/run_started
@workflow/world-localmaterialize onto the run entity
@workflow/world-postgresencryption_public_key column + migration 0016
@workflow/world-vercelroute into the v4 frame meta block
@workflow/web-sharedhide the field in the attribute panel (internal plumbing)

Notes for reviewers

  • world-vercel's compile-time wire-contract guard caught the new field before it could be silently dropped on the v4 path, exactly as designed — it failed the build naming encryptionPublicKey. Nice.
  • Base64 helpers are hand-rolled because sealed-box.ts runs in the browser (o11y) and inside the workflow VM, where neither Buffer nor btoa can be assumed. They're cross-validated against Buffer in tests across a range of lengths.
  • base64ToBytes returns undefined on malformed input rather than throwing, so a corrupt stored key degrades to "no usable public key" → symmetric fallback, instead of crashing a resumption.
  • Tests include an end-to-end assertion that the published key actually opens a payload sealed to it, plus per-run isolation and the encryption-disabled case.

Postgres migration is additive and nullable. Full core suite (1585) passes; all world suites pass; workspace typecheck clean.

CopilotAI review requested due to automatic review settings July 24, 2026 20:56
@TooTallNate
TooTallNate requested review from a team and ijjk as code ownersJuly 24, 2026 20:56
@changeset-bot

changeset-botBot commented Jul 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e7c2128

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/world-postgresMinor
@workflow/web-sharedPatch
@workflow/worldMinor
@workflow/world-localMinor
@workflow/world-vercelMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/webPatch
workflowMinor
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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 24, 2026

Copy link
Copy Markdown
Contributor

@github-actions

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

This PR publishes each workflow run’s X25519 public key (encryptionPublicKey) onto the run entity at creation time, so cross-run writers can seal (encp) payloads to a run without an additional run-key fetch, while keeping private key material derivable-only (not stored).

Changes:

  • Derive the run’s X25519 public key from existing per-run key material during start() and include it on run_created and queued runInput (resilient start).
  • Plumb the optional encryptionPublicKey field through world schemas, Vercel v4 meta splitting, and local/postgres run materialization (plus a postgres migration).
  • Add base64 encode/decode helpers and tests; hide the field in the web attribute panel.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
packages/world/src/runs.tsAdds optional encryptionPublicKey to the run schema with rationale/semantics.
packages/world/src/events.tsAdds encryptionPublicKey to run_created and resilient-start run_started event schemas.
packages/world-vercel/src/events.tsRoutes encryptionPublicKey into the v4 meta block so it isn’t dropped on the wire.
packages/world-postgres/src/storage.tsPersists encryptionPublicKey from run_created into the run row.
packages/world-postgres/src/drizzle/schema.tsAdds encryptionPublicKey column mapping to the runs table schema.
packages/world-postgres/src/drizzle/migrations/meta/_journal.jsonRegisters migration 0016_add_encryption_public_key.
packages/world-postgres/src/drizzle/migrations/0016_add_encryption_public_key.sqlAdds encryption_public_key column to workflow_runs.
packages/world-local/src/storage/events-storage.tsPersists encryptionPublicKey onto the local run entity for run_created.
packages/web-shared/src/components/sidebar/attribute-panel.tsxHides encryptionPublicKey in the attribute panel UI.
packages/core/src/sealed-box.tsAdds VM/browser-safe base64 helpers used for publishing the run public key.
packages/core/src/sealed-box.test.tsTests base64 helpers against Buffer plus malformed input behavior.
packages/core/src/runtime/start.tsDerives/stamps encryptionPublicKey on run_created and queued runInput.
packages/core/src/runtime/start.test.tsAdds tests asserting stamping, usability (seal/open), isolation, and disabled-encryption behavior.
.changeset/encp-run-public-key-worlds.mdChangeset for world packages to persist/transport encryptionPublicKey.
.changeset/encp-run-public-key-web-shared.mdChangeset for hiding encryptionPublicKey in the web attribute panel.
.changeset/encp-run-public-key-postgres.mdChangeset for the postgres column/migration.
.changeset/encp-run-public-key-core.mdChangeset for core stamping at start().

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

Comment threadpackages/core/src/sealed-box.ts
Comment threadpackages/world-postgres/src/storage.ts
Comment threadpackages/world-local/src/storage/events-storage.ts

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

  1. world-local lifecycle handlers (run_started/completed/failed/cancelled) rebuild the run row without encryptionPublicKey, so the key stamped at run_created is dropped as soon as the run starts.
  1. The world-postgres resilient-start path (run_started recreating a run) drops encryptionPublicKey, so runs recovered this way permanently lack their public key and cannot receive sealed writes.
  1. The resilient-start path (run_started recreating a run when run_created was missed) drops encryptionPublicKey, so a run recovered this way permanently loses its ability to receive sealed writes on the world-local (and world-postgres) backends.

Fix on Vercel

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

Accidentally committed generated file quickjs-assets.generated.ts imports the undeclared module quickjs-wasi, causing TS2307: Cannot find module 'quickjs-wasi' and breaking @workflow/core's build.

Fix on Vercel

@TooTallNate

TooTallNate commented Jul 25, 2026

Copy link
Copy Markdown
MemberAuthor

Heads up — this PR's public key never actually reached the server. Found it during manual verification; the fix is now the third commit on this branch (34056d55f).

What was happening

The key was computed, put in the run_created event data, lifted into the v4 frame meta, spread into the request input… and then silently dropped before the request was sent.

packages/world-vercel/src/events-v4.ts builds the wire meta field-by-field, and buildPostFrameMeta never forwarded encryptionPublicKey. The field was also missing from the CreateEventV4Input interface entirely.

The reason nothing caught it: events.ts applies the meta with a spread:

const{ payload, meta }=splitEventDataForV4(data);awaitcreateWorkflowRunEventV4({runId: id,/* … */ payload, ...meta},config);

TypeScript doesn't apply excess-property checks to spreads, so an unforwarded field is not a type error. The exhaustiveness guard added in this PR covers world schema ↔ events.ts allowlist, but there is no guard for events.ts meta ↔ events-v4.ts wire. That's the same hand-maintained v4 mapping AGENTS.md already flags for having regressed trace-context propagation.

Why this was hard to see

Every symptom pointed at the server side rather than at us:

  • a deliberately oversized key was accepted rather than rejected — because the field never arrived
  • the key was never echoed back
  • encryptionPublicKey was absent from the run row in DynamoDB
  • resumeHook() always chose encr

I spent a while looking server-side. The problem was here: the SDK was dropping the field before it ever went out.

Fix

Add encryptionPublicKey to CreateEventV4Input, forward it in buildPostFrameMeta, cover both run_created and resilient-start run_started, and add a generic guard asserting every field the splitter puts in the meta reaches the wire — so the next omission in this mapping fails a test instead of silently degrading encryption. All three new tests fail without the one-line forward.

Verified in production

run_created.eventData.encryptionPublicKey = 4jWP17aASciRa+hftatSP8/21Qy+byelU0WGkFKjC2w=
run entity .encryptionPublicKey = 4jWP17aASciRa+hftatSP8/21Qy+byelU0WGkFKjC2w=

Then a genuinely cross-deployment resumeHook() (owner dpl_7HJcFLVE…, resumer dpl_9RYnfewA…):

hook_received.payload._data prefix = b'encp' (111 bytes) <- was `encr`
run status = completed
returnValue.received = {"sealed":"from-deployment-B","n":42}

The round trip is the important part: an encp prefix only proves the writer sealed, but the payload coming back out of the workflow proves the owner re-derived its scalar and opened it. (The run's own outputRef stays encr, which is correct — only cross-run writers seal.)

Where the fix landed

Originally I committed this on the stack tip, which would have meant merging #3095#3098 with encryption silently downgraded to encr. It now lives here instead, so every merge point in the stack is correct. The whole stack was rebased onto latest main at the same time.

@karthikscale3karthikscale3 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: Reviewed the run public-key publication path end to end. The key is derived only when encryption material exists and is propagated through run_created, queued runInput, resilient-start reconstruction, lifecycle rewrites, Postgres persistence/migration, and the Vercel v4 split/meta wire path. The three existing inline findings and the lifecycle/resilient-start bot findings are fixed on this head. The stale generated-file warning is not present and module-boundary checks pass. The matrix-wide Vercel production failures remain unrelated to this change.

@github-actions

github-actionsBot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit e7c2128 · Mon, 27 Jul 2026 21:52:33 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1302 (+30%) 🔻1391 🔴 (+23%) 🔻1405 🔴 (+18%) 🔻1488 🔴 (-7.5%)30
TTFSstream1289 (+325%) 🔻1348 🔴 (+17%) 🔻1380 🔴 (+15%) 🔻1428 🔴 (+6.5%)30
TTFShook + stream1162 (+157%) 🔻1630 🔴 (+14%)1689 🔴 (+14%)1971 🔴 (+5.2%)30
STSO1020 steps (1-20)167 (-9.7%)273 🔴 (-6.2%)321 🔴 (-5.3%)559 🔴 (+37%) 🔻19
STSO1020 steps (101-120)196 (+1.0%)278 🔴 (-4.5%)322 🔴 (-41%) 💚322 🔴 (-49%) 💚19
STSO1020 steps (1001-1020)465 (-13%)517 🔴 (-23%) 💚571 🔴 (-40%) 💚676 🔴 (-31%) 💚19
WO1020 steps401208 (-9.9%)401208 (-9.9%)401208 (-9.9%)401208 (-9.9%)1
SLstream latency107 (+7.0%)161 🔴 (-25%) 💚213 🔴 (-52%) 💚442 🔴 (-22%) 💚30
SOstream overhead (text)93 (-27%) 💚195 (-19%) 💚220 (-22%) 💚295 (-69%) 💚30
SOstream overhead (structured)122 (+2.5%)183 (-33%) 💚224 (-24%) 💚297 (-29%) 💚30
📜 Previous results (1)

57d0c1b

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFShook + stream399 (-63%) 💚1644 🔴 (+34%) 🔻1686 🔴 (+34%) 🔻2072 🔴 (+41%) 🔻30
STSO1020 steps (1-20)187 (+3.3%)297 🔴 (+7.6%)349 🔴 (+15%)409 🔴 (+3.8%)19
STSO1020 steps (101-120)190 (+2.2%)373 🔴 (+40%) 🔻464 🔴 (+54%) 🔻2155 🔴 (-11%)19
STSO1020 steps (1001-1020)515 (+15%) 🔻594 🔴 (+15%) 🔻712 🔴 (+32%) 🔻1307 🔴 (+99%) 🔻19
WO1020 steps433580 (+13%)433580 (+13%)433580 (+13%)433580 (+13%)1
SLstream latency99 (+29%) 🔻180 🔴 (+45%) 🔻191 🔴 (+33%) 🔻495 🔴 (+94%) 🔻30
SOstream overhead (text)125 (+29%) 🔻238 (+54%) 🔻273 (+56%) 🔻389 (+96%) 🔻30
SOstream overhead (structured)134 (+33%) 🔻221 (+32%) 🔻299 (+69%) 🔻426 (-72%) 💚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.

A cross-run writer needs the recipient run's public key to seal a payload
to it. Derive that key at `start()` and stamp it on the run, so a hook
resumption or a forwarded-stream writer can find it on a run fetch it was
already making instead of spending ~350ms on `run-key`.
The key is derived from the per-run key material `getEncryptionKeyForRun()`
already returns, so nothing about key acquisition changes. It is not
secret: the matching private scalar is never stored anywhere, only
re-derived on demand from the deployment's own env seed. Storing it beside
run metadata therefore does not weaken the run's confidentiality.
**Presence is the writer-side gate for sealed envelopes.** A run only
carries a public key if the runtime that created it could also open one —
which holds by construction, since derivation and `encp` dispatch both
live in `@workflow/core`, so any core that can stamp can also open. Runs
are pinned to their creating deployment, so the capability this attests to
is still accurate at resume time. Writers seal iff the field is set and
otherwise fall back to the symmetric path, which makes version skew
degrade gracefully instead of wedging a run.
The field rides on `run_created`, and is mirrored onto the queued
`runInput` so the resilient-start path (server recreates the run from the
queue message when the `run_created` write failed) doesn't silently
produce a run that can't receive sealed writes.
world-vercel's compile-time wire-contract guard caught the new field
before it could be silently dropped on the v4 path, exactly as designed —
routed into the frame meta block as plaintext metadata.
Also adds browser- and VM-safe base64 helpers to `sealed-box.ts`, since
neither `Buffer` nor `btoa` can be assumed in every context that module
runs in. `base64ToBytes` returns undefined on malformed input rather than
throwing, so a corrupt stored key degrades to "no usable public key" and
falls back to the symmetric path instead of crashing a resumption. Both
are cross-validated against `Buffer` in tests.
Two real bugs found in review, both in the local worlds. Neither surfaces
as an error — a run just silently stops accepting sealed cross-run writes
and falls back to the slow symmetric path forever.
**Resilient start dropped the key.** When a `run_started` arrives for a
run that was never created, world-local and world-postgres rebuild the run
from the queued message. Neither copied `encryptionPublicKey` onto the run
row or the synthetic `run_created` event they write. That is precisely the
scenario this field exists to survive. (The equivalent server-side path was
already handled.)
**world-local also wiped the key on every lifecycle transition.** Its
run_started / run_completed / run_failed / run_cancelled handlers rewrite
the whole run document field-by-field, so any field not explicitly listed
is dropped — meaning the key was lost on the *first* `run_started`, not
just on the resilient path. All four rebuild sites now carry it.
world-postgres is safe here by construction because it issues
column-scoped SQL UPDATEs rather than rewriting the row.
**base64 decoding is now strict.** The decoder accepted shapes that
cannot describe a whole number of bytes (`length % 4 === 1`) and ignored
anything after a mid-string `=`, returning a short array instead of
`undefined`. That is worse than throwing: a corrupt stored key looked
*present*, so callers sealed to garbage rather than taking the symmetric
fallback. Now rejects out-of-alphabet characters, bad lengths, misplaced
padding, and non-zero trailing bits — with a round-trip test over every
length 0–48 to make sure the strictness does not overshoot.
`splitEventDataForV4` lifted the run's public key into the frame meta and
`events.ts` spread that meta into `CreateEventV4Input`, but
`buildPostFrameMeta` — which copies meta onto the wire field by field — never
forwarded `encryptionPublicKey`, and the field was missing from
`CreateEventV4Input` entirely. Because the meta is applied with a spread,
TypeScript's excess-property check doesn't fire, so the key was computed, put
in the meta, and then silently dropped before the request was sent.
The server therefore never received the key, never stored it on the run
entity, and every cross-run writer fell back to the symmetric envelope. Every
symptom pointed away from the SDK: a deliberately oversized key was accepted
rather than rejected (the field never arrived), the key was absent from the run
row, and `resumeHook()` always chose `encr`.
Add the field to `CreateEventV4Input`, forward it in `buildPostFrameMeta`, and
cover it for both `run_created` and resilient-start `run_started`. Also add a
generic guard asserting that every field the splitter puts in the meta reaches
the wire, so the next omission in this hand-maintained mapping fails a test
instead of silently degrading encryption.
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for b4ba79e (AI decision).

This is feature work: it adds a new encryptionPublicKey field to the run schema, new exported base64 helpers, a new Postgres column plus migration 0016, and new v4 wire-contract plumbing — carrying minor changesets and building toward the stacked sealed-envelope (encp) feature series (#3093#3099). The fix-flavored commits inside it (public-key loss on resilient start, strict base64 decoding, missing field in buildPostFrameMeta) only correct defects in this same new, main-only capability, so they have nothing to fix on stable.

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

b4ba79ebc501248408474efdd6e353f1753d83e3

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

@TooTallNate@karthikscale3
, '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: publish each run's X25519 public key on the run entity by TooTallNate · Pull Request #3095 · vercel/workflow · GitHub
Skip to content

feat: publish each run's X25519 public key on the run entity - #3095

Merged
TooTallNate merged 3 commits into
mainfrom
nate/encp-run-public-key
Jul 27, 2026
Merged

feat: publish each run's X25519 public key on the run entity#3095
TooTallNate merged 3 commits into
mainfrom
nate/encp-run-public-key

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Jul 24, 2026

Copy link
Copy Markdown
Member

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

What

A cross-run writer needs the recipient run's public key to seal to it. Derive that key at start() and stamp it on the run, so a hook resumption or forwarded-stream writer finds it on a run fetch it was already making instead of spending ~350ms on run-key.

Derived from the per-run key material getEncryptionKeyForRun() already returns, so nothing about key acquisition changes.

Why storing it is safe

It is not secret. The matching private scalar is never stored anywhere — only re-derived on demand from the deployment's own env seed. So this does not violate the requirement that no key material live beside run metadata (that requirement is about secrets), and the server remains zero-knowledge: it stores and serves 32 public bytes it cannot use to decrypt anything.

Anyone with tenant-scoped read access to a run gains encryption capability for it, and nothing more.

Presence is the writer-side gate

A run only carries a public key if the runtime that created it could also open a sealed payload. That holds by construction: derivation and encp dispatch both live in @workflow/core, so any core that can stamp can also open. Runs are pinned to their creating deployment, so the capability this attests to is still accurate at resume time.

This matters because @workflow/core and @workflow/world-vercel are versioned independently and do drift in practice. A gate that consulted only one of them would wedge runs under one drift direction or the other:

  • If a deployment could stamp a key but its core could not dispatchencp, a writer would seal a payload the target cannot decode — run wedged. Locating derivation, stamping and dispatch all in @workflow/core makes this combination impossible to construct.
  • If a deployment's core supports encp but its world package never persisted the field, the run simply has no public key, and writers fall back to the symmetric path — degraded, not broken.

Presence therefore attests to both halves at once, which a version comparison cannot.

Resilient start

The field rides on run_createdand is mirrored onto the queued runInput, because the resilient-start path recreates the run from the queue message when the run_created write failed. Without that mirror, a run recovered that way would silently lose the ability to receive sealed writes.

Surface

PackageChange
@workflow/corederive + stamp at start(); browser/VM-safe base64 helpers
@workflow/worldoptional encryptionPublicKey on the run schema + run_created/run_started
@workflow/world-localmaterialize onto the run entity
@workflow/world-postgresencryption_public_key column + migration 0016
@workflow/world-vercelroute into the v4 frame meta block
@workflow/web-sharedhide the field in the attribute panel (internal plumbing)

Notes for reviewers

  • world-vercel's compile-time wire-contract guard caught the new field before it could be silently dropped on the v4 path, exactly as designed — it failed the build naming encryptionPublicKey. Nice.
  • Base64 helpers are hand-rolled because sealed-box.ts runs in the browser (o11y) and inside the workflow VM, where neither Buffer nor btoa can be assumed. They're cross-validated against Buffer in tests across a range of lengths.
  • base64ToBytes returns undefined on malformed input rather than throwing, so a corrupt stored key degrades to "no usable public key" → symmetric fallback, instead of crashing a resumption.
  • Tests include an end-to-end assertion that the published key actually opens a payload sealed to it, plus per-run isolation and the encryption-disabled case.

Postgres migration is additive and nullable. Full core suite (1585) passes; all world suites pass; workspace typecheck clean.

CopilotAI review requested due to automatic review settings July 24, 2026 20:56
@TooTallNate
TooTallNate requested review from a team and ijjk as code ownersJuly 24, 2026 20:56
@changeset-bot

changeset-botBot commented Jul 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e7c2128

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/world-postgresMinor
@workflow/web-sharedPatch
@workflow/worldMinor
@workflow/world-localMinor
@workflow/world-vercelMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/webPatch
workflowMinor
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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 24, 2026

Copy link
Copy Markdown
Contributor

@github-actions

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

This PR publishes each workflow run’s X25519 public key (encryptionPublicKey) onto the run entity at creation time, so cross-run writers can seal (encp) payloads to a run without an additional run-key fetch, while keeping private key material derivable-only (not stored).

Changes:

  • Derive the run’s X25519 public key from existing per-run key material during start() and include it on run_created and queued runInput (resilient start).
  • Plumb the optional encryptionPublicKey field through world schemas, Vercel v4 meta splitting, and local/postgres run materialization (plus a postgres migration).
  • Add base64 encode/decode helpers and tests; hide the field in the web attribute panel.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
packages/world/src/runs.tsAdds optional encryptionPublicKey to the run schema with rationale/semantics.
packages/world/src/events.tsAdds encryptionPublicKey to run_created and resilient-start run_started event schemas.
packages/world-vercel/src/events.tsRoutes encryptionPublicKey into the v4 meta block so it isn’t dropped on the wire.
packages/world-postgres/src/storage.tsPersists encryptionPublicKey from run_created into the run row.
packages/world-postgres/src/drizzle/schema.tsAdds encryptionPublicKey column mapping to the runs table schema.
packages/world-postgres/src/drizzle/migrations/meta/_journal.jsonRegisters migration 0016_add_encryption_public_key.
packages/world-postgres/src/drizzle/migrations/0016_add_encryption_public_key.sqlAdds encryption_public_key column to workflow_runs.
packages/world-local/src/storage/events-storage.tsPersists encryptionPublicKey onto the local run entity for run_created.
packages/web-shared/src/components/sidebar/attribute-panel.tsxHides encryptionPublicKey in the attribute panel UI.
packages/core/src/sealed-box.tsAdds VM/browser-safe base64 helpers used for publishing the run public key.
packages/core/src/sealed-box.test.tsTests base64 helpers against Buffer plus malformed input behavior.
packages/core/src/runtime/start.tsDerives/stamps encryptionPublicKey on run_created and queued runInput.
packages/core/src/runtime/start.test.tsAdds tests asserting stamping, usability (seal/open), isolation, and disabled-encryption behavior.
.changeset/encp-run-public-key-worlds.mdChangeset for world packages to persist/transport encryptionPublicKey.
.changeset/encp-run-public-key-web-shared.mdChangeset for hiding encryptionPublicKey in the web attribute panel.
.changeset/encp-run-public-key-postgres.mdChangeset for the postgres column/migration.
.changeset/encp-run-public-key-core.mdChangeset for core stamping at start().

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

Comment threadpackages/core/src/sealed-box.ts
Comment threadpackages/world-postgres/src/storage.ts
Comment threadpackages/world-local/src/storage/events-storage.ts

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

  1. world-local lifecycle handlers (run_started/completed/failed/cancelled) rebuild the run row without encryptionPublicKey, so the key stamped at run_created is dropped as soon as the run starts.
  1. The world-postgres resilient-start path (run_started recreating a run) drops encryptionPublicKey, so runs recovered this way permanently lack their public key and cannot receive sealed writes.
  1. The resilient-start path (run_started recreating a run when run_created was missed) drops encryptionPublicKey, so a run recovered this way permanently loses its ability to receive sealed writes on the world-local (and world-postgres) backends.

Fix on Vercel

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

Accidentally committed generated file quickjs-assets.generated.ts imports the undeclared module quickjs-wasi, causing TS2307: Cannot find module 'quickjs-wasi' and breaking @workflow/core's build.

Fix on Vercel

@TooTallNate

TooTallNate commented Jul 25, 2026

Copy link
Copy Markdown
MemberAuthor

Heads up — this PR's public key never actually reached the server. Found it during manual verification; the fix is now the third commit on this branch (34056d55f).

What was happening

The key was computed, put in the run_created event data, lifted into the v4 frame meta, spread into the request input… and then silently dropped before the request was sent.

packages/world-vercel/src/events-v4.ts builds the wire meta field-by-field, and buildPostFrameMeta never forwarded encryptionPublicKey. The field was also missing from the CreateEventV4Input interface entirely.

The reason nothing caught it: events.ts applies the meta with a spread:

const{ payload, meta }=splitEventDataForV4(data);awaitcreateWorkflowRunEventV4({runId: id,/* … */ payload, ...meta},config);

TypeScript doesn't apply excess-property checks to spreads, so an unforwarded field is not a type error. The exhaustiveness guard added in this PR covers world schema ↔ events.ts allowlist, but there is no guard for events.ts meta ↔ events-v4.ts wire. That's the same hand-maintained v4 mapping AGENTS.md already flags for having regressed trace-context propagation.

Why this was hard to see

Every symptom pointed at the server side rather than at us:

  • a deliberately oversized key was accepted rather than rejected — because the field never arrived
  • the key was never echoed back
  • encryptionPublicKey was absent from the run row in DynamoDB
  • resumeHook() always chose encr

I spent a while looking server-side. The problem was here: the SDK was dropping the field before it ever went out.

Fix

Add encryptionPublicKey to CreateEventV4Input, forward it in buildPostFrameMeta, cover both run_created and resilient-start run_started, and add a generic guard asserting every field the splitter puts in the meta reaches the wire — so the next omission in this mapping fails a test instead of silently degrading encryption. All three new tests fail without the one-line forward.

Verified in production

run_created.eventData.encryptionPublicKey = 4jWP17aASciRa+hftatSP8/21Qy+byelU0WGkFKjC2w=
run entity .encryptionPublicKey = 4jWP17aASciRa+hftatSP8/21Qy+byelU0WGkFKjC2w=

Then a genuinely cross-deployment resumeHook() (owner dpl_7HJcFLVE…, resumer dpl_9RYnfewA…):

hook_received.payload._data prefix = b'encp' (111 bytes) <- was `encr`
run status = completed
returnValue.received = {"sealed":"from-deployment-B","n":42}

The round trip is the important part: an encp prefix only proves the writer sealed, but the payload coming back out of the workflow proves the owner re-derived its scalar and opened it. (The run's own outputRef stays encr, which is correct — only cross-run writers seal.)

Where the fix landed

Originally I committed this on the stack tip, which would have meant merging #3095#3098 with encryption silently downgraded to encr. It now lives here instead, so every merge point in the stack is correct. The whole stack was rebased onto latest main at the same time.

@karthikscale3karthikscale3 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: Reviewed the run public-key publication path end to end. The key is derived only when encryption material exists and is propagated through run_created, queued runInput, resilient-start reconstruction, lifecycle rewrites, Postgres persistence/migration, and the Vercel v4 split/meta wire path. The three existing inline findings and the lifecycle/resilient-start bot findings are fixed on this head. The stale generated-file warning is not present and module-boundary checks pass. The matrix-wide Vercel production failures remain unrelated to this change.

@github-actions

github-actionsBot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit e7c2128 · Mon, 27 Jul 2026 21:52:33 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1302 (+30%) 🔻1391 🔴 (+23%) 🔻1405 🔴 (+18%) 🔻1488 🔴 (-7.5%)30
TTFSstream1289 (+325%) 🔻1348 🔴 (+17%) 🔻1380 🔴 (+15%) 🔻1428 🔴 (+6.5%)30
TTFShook + stream1162 (+157%) 🔻1630 🔴 (+14%)1689 🔴 (+14%)1971 🔴 (+5.2%)30
STSO1020 steps (1-20)167 (-9.7%)273 🔴 (-6.2%)321 🔴 (-5.3%)559 🔴 (+37%) 🔻19
STSO1020 steps (101-120)196 (+1.0%)278 🔴 (-4.5%)322 🔴 (-41%) 💚322 🔴 (-49%) 💚19
STSO1020 steps (1001-1020)465 (-13%)517 🔴 (-23%) 💚571 🔴 (-40%) 💚676 🔴 (-31%) 💚19
WO1020 steps401208 (-9.9%)401208 (-9.9%)401208 (-9.9%)401208 (-9.9%)1
SLstream latency107 (+7.0%)161 🔴 (-25%) 💚213 🔴 (-52%) 💚442 🔴 (-22%) 💚30
SOstream overhead (text)93 (-27%) 💚195 (-19%) 💚220 (-22%) 💚295 (-69%) 💚30
SOstream overhead (structured)122 (+2.5%)183 (-33%) 💚224 (-24%) 💚297 (-29%) 💚30
📜 Previous results (1)

57d0c1b

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFShook + stream399 (-63%) 💚1644 🔴 (+34%) 🔻1686 🔴 (+34%) 🔻2072 🔴 (+41%) 🔻30
STSO1020 steps (1-20)187 (+3.3%)297 🔴 (+7.6%)349 🔴 (+15%)409 🔴 (+3.8%)19
STSO1020 steps (101-120)190 (+2.2%)373 🔴 (+40%) 🔻464 🔴 (+54%) 🔻2155 🔴 (-11%)19
STSO1020 steps (1001-1020)515 (+15%) 🔻594 🔴 (+15%) 🔻712 🔴 (+32%) 🔻1307 🔴 (+99%) 🔻19
WO1020 steps433580 (+13%)433580 (+13%)433580 (+13%)433580 (+13%)1
SLstream latency99 (+29%) 🔻180 🔴 (+45%) 🔻191 🔴 (+33%) 🔻495 🔴 (+94%) 🔻30
SOstream overhead (text)125 (+29%) 🔻238 (+54%) 🔻273 (+56%) 🔻389 (+96%) 🔻30
SOstream overhead (structured)134 (+33%) 🔻221 (+32%) 🔻299 (+69%) 🔻426 (-72%) 💚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.

A cross-run writer needs the recipient run's public key to seal a payload
to it. Derive that key at `start()` and stamp it on the run, so a hook
resumption or a forwarded-stream writer can find it on a run fetch it was
already making instead of spending ~350ms on `run-key`.
The key is derived from the per-run key material `getEncryptionKeyForRun()`
already returns, so nothing about key acquisition changes. It is not
secret: the matching private scalar is never stored anywhere, only
re-derived on demand from the deployment's own env seed. Storing it beside
run metadata therefore does not weaken the run's confidentiality.
**Presence is the writer-side gate for sealed envelopes.** A run only
carries a public key if the runtime that created it could also open one —
which holds by construction, since derivation and `encp` dispatch both
live in `@workflow/core`, so any core that can stamp can also open. Runs
are pinned to their creating deployment, so the capability this attests to
is still accurate at resume time. Writers seal iff the field is set and
otherwise fall back to the symmetric path, which makes version skew
degrade gracefully instead of wedging a run.
The field rides on `run_created`, and is mirrored onto the queued
`runInput` so the resilient-start path (server recreates the run from the
queue message when the `run_created` write failed) doesn't silently
produce a run that can't receive sealed writes.
world-vercel's compile-time wire-contract guard caught the new field
before it could be silently dropped on the v4 path, exactly as designed —
routed into the frame meta block as plaintext metadata.
Also adds browser- and VM-safe base64 helpers to `sealed-box.ts`, since
neither `Buffer` nor `btoa` can be assumed in every context that module
runs in. `base64ToBytes` returns undefined on malformed input rather than
throwing, so a corrupt stored key degrades to "no usable public key" and
falls back to the symmetric path instead of crashing a resumption. Both
are cross-validated against `Buffer` in tests.
Two real bugs found in review, both in the local worlds. Neither surfaces
as an error — a run just silently stops accepting sealed cross-run writes
and falls back to the slow symmetric path forever.
**Resilient start dropped the key.** When a `run_started` arrives for a
run that was never created, world-local and world-postgres rebuild the run
from the queued message. Neither copied `encryptionPublicKey` onto the run
row or the synthetic `run_created` event they write. That is precisely the
scenario this field exists to survive. (The equivalent server-side path was
already handled.)
**world-local also wiped the key on every lifecycle transition.** Its
run_started / run_completed / run_failed / run_cancelled handlers rewrite
the whole run document field-by-field, so any field not explicitly listed
is dropped — meaning the key was lost on the *first* `run_started`, not
just on the resilient path. All four rebuild sites now carry it.
world-postgres is safe here by construction because it issues
column-scoped SQL UPDATEs rather than rewriting the row.
**base64 decoding is now strict.** The decoder accepted shapes that
cannot describe a whole number of bytes (`length % 4 === 1`) and ignored
anything after a mid-string `=`, returning a short array instead of
`undefined`. That is worse than throwing: a corrupt stored key looked
*present*, so callers sealed to garbage rather than taking the symmetric
fallback. Now rejects out-of-alphabet characters, bad lengths, misplaced
padding, and non-zero trailing bits — with a round-trip test over every
length 0–48 to make sure the strictness does not overshoot.
`splitEventDataForV4` lifted the run's public key into the frame meta and
`events.ts` spread that meta into `CreateEventV4Input`, but
`buildPostFrameMeta` — which copies meta onto the wire field by field — never
forwarded `encryptionPublicKey`, and the field was missing from
`CreateEventV4Input` entirely. Because the meta is applied with a spread,
TypeScript's excess-property check doesn't fire, so the key was computed, put
in the meta, and then silently dropped before the request was sent.
The server therefore never received the key, never stored it on the run
entity, and every cross-run writer fell back to the symmetric envelope. Every
symptom pointed away from the SDK: a deliberately oversized key was accepted
rather than rejected (the field never arrived), the key was absent from the run
row, and `resumeHook()` always chose `encr`.
Add the field to `CreateEventV4Input`, forward it in `buildPostFrameMeta`, and
cover it for both `run_created` and resilient-start `run_started`. Also add a
generic guard asserting that every field the splitter puts in the meta reaches
the wire, so the next omission in this hand-maintained mapping fails a test
instead of silently degrading encryption.
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for b4ba79e (AI decision).

This is feature work: it adds a new encryptionPublicKey field to the run schema, new exported base64 helpers, a new Postgres column plus migration 0016, and new v4 wire-contract plumbing — carrying minor changesets and building toward the stacked sealed-envelope (encp) feature series (#3093#3099). The fix-flavored commits inside it (public-key loss on resilient start, strict base64 decoding, missing field in buildPostFrameMeta) only correct defects in this same new, main-only capability, so they have nothing to fix on stable.

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

b4ba79ebc501248408474efdd6e353f1753d83e3

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

@TooTallNate@karthikscale3
, '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: publish each run's X25519 public key on the run entity by TooTallNate · Pull Request #3095 · vercel/workflow · GitHub
Skip to content

feat: publish each run's X25519 public key on the run entity - #3095

Merged
TooTallNate merged 3 commits into
mainfrom
nate/encp-run-public-key
Jul 27, 2026
Merged

feat: publish each run's X25519 public key on the run entity#3095
TooTallNate merged 3 commits into
mainfrom
nate/encp-run-public-key

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Jul 24, 2026

Copy link
Copy Markdown
Member

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

What

A cross-run writer needs the recipient run's public key to seal to it. Derive that key at start() and stamp it on the run, so a hook resumption or forwarded-stream writer finds it on a run fetch it was already making instead of spending ~350ms on run-key.

Derived from the per-run key material getEncryptionKeyForRun() already returns, so nothing about key acquisition changes.

Why storing it is safe

It is not secret. The matching private scalar is never stored anywhere — only re-derived on demand from the deployment's own env seed. So this does not violate the requirement that no key material live beside run metadata (that requirement is about secrets), and the server remains zero-knowledge: it stores and serves 32 public bytes it cannot use to decrypt anything.

Anyone with tenant-scoped read access to a run gains encryption capability for it, and nothing more.

Presence is the writer-side gate

A run only carries a public key if the runtime that created it could also open a sealed payload. That holds by construction: derivation and encp dispatch both live in @workflow/core, so any core that can stamp can also open. Runs are pinned to their creating deployment, so the capability this attests to is still accurate at resume time.

This matters because @workflow/core and @workflow/world-vercel are versioned independently and do drift in practice. A gate that consulted only one of them would wedge runs under one drift direction or the other:

  • If a deployment could stamp a key but its core could not dispatchencp, a writer would seal a payload the target cannot decode — run wedged. Locating derivation, stamping and dispatch all in @workflow/core makes this combination impossible to construct.
  • If a deployment's core supports encp but its world package never persisted the field, the run simply has no public key, and writers fall back to the symmetric path — degraded, not broken.

Presence therefore attests to both halves at once, which a version comparison cannot.

Resilient start

The field rides on run_createdand is mirrored onto the queued runInput, because the resilient-start path recreates the run from the queue message when the run_created write failed. Without that mirror, a run recovered that way would silently lose the ability to receive sealed writes.

Surface

PackageChange
@workflow/corederive + stamp at start(); browser/VM-safe base64 helpers
@workflow/worldoptional encryptionPublicKey on the run schema + run_created/run_started
@workflow/world-localmaterialize onto the run entity
@workflow/world-postgresencryption_public_key column + migration 0016
@workflow/world-vercelroute into the v4 frame meta block
@workflow/web-sharedhide the field in the attribute panel (internal plumbing)

Notes for reviewers

  • world-vercel's compile-time wire-contract guard caught the new field before it could be silently dropped on the v4 path, exactly as designed — it failed the build naming encryptionPublicKey. Nice.
  • Base64 helpers are hand-rolled because sealed-box.ts runs in the browser (o11y) and inside the workflow VM, where neither Buffer nor btoa can be assumed. They're cross-validated against Buffer in tests across a range of lengths.
  • base64ToBytes returns undefined on malformed input rather than throwing, so a corrupt stored key degrades to "no usable public key" → symmetric fallback, instead of crashing a resumption.
  • Tests include an end-to-end assertion that the published key actually opens a payload sealed to it, plus per-run isolation and the encryption-disabled case.

Postgres migration is additive and nullable. Full core suite (1585) passes; all world suites pass; workspace typecheck clean.

CopilotAI review requested due to automatic review settings July 24, 2026 20:56
@TooTallNate
TooTallNate requested review from a team and ijjk as code ownersJuly 24, 2026 20:56
@changeset-bot

changeset-botBot commented Jul 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e7c2128

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/world-postgresMinor
@workflow/web-sharedPatch
@workflow/worldMinor
@workflow/world-localMinor
@workflow/world-vercelMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/webPatch
workflowMinor
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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 24, 2026

Copy link
Copy Markdown
Contributor

@github-actions

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

This PR publishes each workflow run’s X25519 public key (encryptionPublicKey) onto the run entity at creation time, so cross-run writers can seal (encp) payloads to a run without an additional run-key fetch, while keeping private key material derivable-only (not stored).

Changes:

  • Derive the run’s X25519 public key from existing per-run key material during start() and include it on run_created and queued runInput (resilient start).
  • Plumb the optional encryptionPublicKey field through world schemas, Vercel v4 meta splitting, and local/postgres run materialization (plus a postgres migration).
  • Add base64 encode/decode helpers and tests; hide the field in the web attribute panel.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
packages/world/src/runs.tsAdds optional encryptionPublicKey to the run schema with rationale/semantics.
packages/world/src/events.tsAdds encryptionPublicKey to run_created and resilient-start run_started event schemas.
packages/world-vercel/src/events.tsRoutes encryptionPublicKey into the v4 meta block so it isn’t dropped on the wire.
packages/world-postgres/src/storage.tsPersists encryptionPublicKey from run_created into the run row.
packages/world-postgres/src/drizzle/schema.tsAdds encryptionPublicKey column mapping to the runs table schema.
packages/world-postgres/src/drizzle/migrations/meta/_journal.jsonRegisters migration 0016_add_encryption_public_key.
packages/world-postgres/src/drizzle/migrations/0016_add_encryption_public_key.sqlAdds encryption_public_key column to workflow_runs.
packages/world-local/src/storage/events-storage.tsPersists encryptionPublicKey onto the local run entity for run_created.
packages/web-shared/src/components/sidebar/attribute-panel.tsxHides encryptionPublicKey in the attribute panel UI.
packages/core/src/sealed-box.tsAdds VM/browser-safe base64 helpers used for publishing the run public key.
packages/core/src/sealed-box.test.tsTests base64 helpers against Buffer plus malformed input behavior.
packages/core/src/runtime/start.tsDerives/stamps encryptionPublicKey on run_created and queued runInput.
packages/core/src/runtime/start.test.tsAdds tests asserting stamping, usability (seal/open), isolation, and disabled-encryption behavior.
.changeset/encp-run-public-key-worlds.mdChangeset for world packages to persist/transport encryptionPublicKey.
.changeset/encp-run-public-key-web-shared.mdChangeset for hiding encryptionPublicKey in the web attribute panel.
.changeset/encp-run-public-key-postgres.mdChangeset for the postgres column/migration.
.changeset/encp-run-public-key-core.mdChangeset for core stamping at start().

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

Comment threadpackages/core/src/sealed-box.ts
Comment threadpackages/world-postgres/src/storage.ts
Comment threadpackages/world-local/src/storage/events-storage.ts

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

  1. world-local lifecycle handlers (run_started/completed/failed/cancelled) rebuild the run row without encryptionPublicKey, so the key stamped at run_created is dropped as soon as the run starts.
  1. The world-postgres resilient-start path (run_started recreating a run) drops encryptionPublicKey, so runs recovered this way permanently lack their public key and cannot receive sealed writes.
  1. The resilient-start path (run_started recreating a run when run_created was missed) drops encryptionPublicKey, so a run recovered this way permanently loses its ability to receive sealed writes on the world-local (and world-postgres) backends.

Fix on Vercel

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

Accidentally committed generated file quickjs-assets.generated.ts imports the undeclared module quickjs-wasi, causing TS2307: Cannot find module 'quickjs-wasi' and breaking @workflow/core's build.

Fix on Vercel

@TooTallNate

TooTallNate commented Jul 25, 2026

Copy link
Copy Markdown
MemberAuthor

Heads up — this PR's public key never actually reached the server. Found it during manual verification; the fix is now the third commit on this branch (34056d55f).

What was happening

The key was computed, put in the run_created event data, lifted into the v4 frame meta, spread into the request input… and then silently dropped before the request was sent.

packages/world-vercel/src/events-v4.ts builds the wire meta field-by-field, and buildPostFrameMeta never forwarded encryptionPublicKey. The field was also missing from the CreateEventV4Input interface entirely.

The reason nothing caught it: events.ts applies the meta with a spread:

const{ payload, meta }=splitEventDataForV4(data);awaitcreateWorkflowRunEventV4({runId: id,/* … */ payload, ...meta},config);

TypeScript doesn't apply excess-property checks to spreads, so an unforwarded field is not a type error. The exhaustiveness guard added in this PR covers world schema ↔ events.ts allowlist, but there is no guard for events.ts meta ↔ events-v4.ts wire. That's the same hand-maintained v4 mapping AGENTS.md already flags for having regressed trace-context propagation.

Why this was hard to see

Every symptom pointed at the server side rather than at us:

  • a deliberately oversized key was accepted rather than rejected — because the field never arrived
  • the key was never echoed back
  • encryptionPublicKey was absent from the run row in DynamoDB
  • resumeHook() always chose encr

I spent a while looking server-side. The problem was here: the SDK was dropping the field before it ever went out.

Fix

Add encryptionPublicKey to CreateEventV4Input, forward it in buildPostFrameMeta, cover both run_created and resilient-start run_started, and add a generic guard asserting every field the splitter puts in the meta reaches the wire — so the next omission in this mapping fails a test instead of silently degrading encryption. All three new tests fail without the one-line forward.

Verified in production

run_created.eventData.encryptionPublicKey = 4jWP17aASciRa+hftatSP8/21Qy+byelU0WGkFKjC2w=
run entity .encryptionPublicKey = 4jWP17aASciRa+hftatSP8/21Qy+byelU0WGkFKjC2w=

Then a genuinely cross-deployment resumeHook() (owner dpl_7HJcFLVE…, resumer dpl_9RYnfewA…):

hook_received.payload._data prefix = b'encp' (111 bytes) <- was `encr`
run status = completed
returnValue.received = {"sealed":"from-deployment-B","n":42}

The round trip is the important part: an encp prefix only proves the writer sealed, but the payload coming back out of the workflow proves the owner re-derived its scalar and opened it. (The run's own outputRef stays encr, which is correct — only cross-run writers seal.)

Where the fix landed

Originally I committed this on the stack tip, which would have meant merging #3095#3098 with encryption silently downgraded to encr. It now lives here instead, so every merge point in the stack is correct. The whole stack was rebased onto latest main at the same time.

@karthikscale3karthikscale3 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: Reviewed the run public-key publication path end to end. The key is derived only when encryption material exists and is propagated through run_created, queued runInput, resilient-start reconstruction, lifecycle rewrites, Postgres persistence/migration, and the Vercel v4 split/meta wire path. The three existing inline findings and the lifecycle/resilient-start bot findings are fixed on this head. The stale generated-file warning is not present and module-boundary checks pass. The matrix-wide Vercel production failures remain unrelated to this change.

@github-actions

github-actionsBot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit e7c2128 · Mon, 27 Jul 2026 21:52:33 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1302 (+30%) 🔻1391 🔴 (+23%) 🔻1405 🔴 (+18%) 🔻1488 🔴 (-7.5%)30
TTFSstream1289 (+325%) 🔻1348 🔴 (+17%) 🔻1380 🔴 (+15%) 🔻1428 🔴 (+6.5%)30
TTFShook + stream1162 (+157%) 🔻1630 🔴 (+14%)1689 🔴 (+14%)1971 🔴 (+5.2%)30
STSO1020 steps (1-20)167 (-9.7%)273 🔴 (-6.2%)321 🔴 (-5.3%)559 🔴 (+37%) 🔻19
STSO1020 steps (101-120)196 (+1.0%)278 🔴 (-4.5%)322 🔴 (-41%) 💚322 🔴 (-49%) 💚19
STSO1020 steps (1001-1020)465 (-13%)517 🔴 (-23%) 💚571 🔴 (-40%) 💚676 🔴 (-31%) 💚19
WO1020 steps401208 (-9.9%)401208 (-9.9%)401208 (-9.9%)401208 (-9.9%)1
SLstream latency107 (+7.0%)161 🔴 (-25%) 💚213 🔴 (-52%) 💚442 🔴 (-22%) 💚30
SOstream overhead (text)93 (-27%) 💚195 (-19%) 💚220 (-22%) 💚295 (-69%) 💚30
SOstream overhead (structured)122 (+2.5%)183 (-33%) 💚224 (-24%) 💚297 (-29%) 💚30
📜 Previous results (1)

57d0c1b

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFShook + stream399 (-63%) 💚1644 🔴 (+34%) 🔻1686 🔴 (+34%) 🔻2072 🔴 (+41%) 🔻30
STSO1020 steps (1-20)187 (+3.3%)297 🔴 (+7.6%)349 🔴 (+15%)409 🔴 (+3.8%)19
STSO1020 steps (101-120)190 (+2.2%)373 🔴 (+40%) 🔻464 🔴 (+54%) 🔻2155 🔴 (-11%)19
STSO1020 steps (1001-1020)515 (+15%) 🔻594 🔴 (+15%) 🔻712 🔴 (+32%) 🔻1307 🔴 (+99%) 🔻19
WO1020 steps433580 (+13%)433580 (+13%)433580 (+13%)433580 (+13%)1
SLstream latency99 (+29%) 🔻180 🔴 (+45%) 🔻191 🔴 (+33%) 🔻495 🔴 (+94%) 🔻30
SOstream overhead (text)125 (+29%) 🔻238 (+54%) 🔻273 (+56%) 🔻389 (+96%) 🔻30
SOstream overhead (structured)134 (+33%) 🔻221 (+32%) 🔻299 (+69%) 🔻426 (-72%) 💚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.

A cross-run writer needs the recipient run's public key to seal a payload
to it. Derive that key at `start()` and stamp it on the run, so a hook
resumption or a forwarded-stream writer can find it on a run fetch it was
already making instead of spending ~350ms on `run-key`.
The key is derived from the per-run key material `getEncryptionKeyForRun()`
already returns, so nothing about key acquisition changes. It is not
secret: the matching private scalar is never stored anywhere, only
re-derived on demand from the deployment's own env seed. Storing it beside
run metadata therefore does not weaken the run's confidentiality.
**Presence is the writer-side gate for sealed envelopes.** A run only
carries a public key if the runtime that created it could also open one —
which holds by construction, since derivation and `encp` dispatch both
live in `@workflow/core`, so any core that can stamp can also open. Runs
are pinned to their creating deployment, so the capability this attests to
is still accurate at resume time. Writers seal iff the field is set and
otherwise fall back to the symmetric path, which makes version skew
degrade gracefully instead of wedging a run.
The field rides on `run_created`, and is mirrored onto the queued
`runInput` so the resilient-start path (server recreates the run from the
queue message when the `run_created` write failed) doesn't silently
produce a run that can't receive sealed writes.
world-vercel's compile-time wire-contract guard caught the new field
before it could be silently dropped on the v4 path, exactly as designed —
routed into the frame meta block as plaintext metadata.
Also adds browser- and VM-safe base64 helpers to `sealed-box.ts`, since
neither `Buffer` nor `btoa` can be assumed in every context that module
runs in. `base64ToBytes` returns undefined on malformed input rather than
throwing, so a corrupt stored key degrades to "no usable public key" and
falls back to the symmetric path instead of crashing a resumption. Both
are cross-validated against `Buffer` in tests.
Two real bugs found in review, both in the local worlds. Neither surfaces
as an error — a run just silently stops accepting sealed cross-run writes
and falls back to the slow symmetric path forever.
**Resilient start dropped the key.** When a `run_started` arrives for a
run that was never created, world-local and world-postgres rebuild the run
from the queued message. Neither copied `encryptionPublicKey` onto the run
row or the synthetic `run_created` event they write. That is precisely the
scenario this field exists to survive. (The equivalent server-side path was
already handled.)
**world-local also wiped the key on every lifecycle transition.** Its
run_started / run_completed / run_failed / run_cancelled handlers rewrite
the whole run document field-by-field, so any field not explicitly listed
is dropped — meaning the key was lost on the *first* `run_started`, not
just on the resilient path. All four rebuild sites now carry it.
world-postgres is safe here by construction because it issues
column-scoped SQL UPDATEs rather than rewriting the row.
**base64 decoding is now strict.** The decoder accepted shapes that
cannot describe a whole number of bytes (`length % 4 === 1`) and ignored
anything after a mid-string `=`, returning a short array instead of
`undefined`. That is worse than throwing: a corrupt stored key looked
*present*, so callers sealed to garbage rather than taking the symmetric
fallback. Now rejects out-of-alphabet characters, bad lengths, misplaced
padding, and non-zero trailing bits — with a round-trip test over every
length 0–48 to make sure the strictness does not overshoot.
`splitEventDataForV4` lifted the run's public key into the frame meta and
`events.ts` spread that meta into `CreateEventV4Input`, but
`buildPostFrameMeta` — which copies meta onto the wire field by field — never
forwarded `encryptionPublicKey`, and the field was missing from
`CreateEventV4Input` entirely. Because the meta is applied with a spread,
TypeScript's excess-property check doesn't fire, so the key was computed, put
in the meta, and then silently dropped before the request was sent.
The server therefore never received the key, never stored it on the run
entity, and every cross-run writer fell back to the symmetric envelope. Every
symptom pointed away from the SDK: a deliberately oversized key was accepted
rather than rejected (the field never arrived), the key was absent from the run
row, and `resumeHook()` always chose `encr`.
Add the field to `CreateEventV4Input`, forward it in `buildPostFrameMeta`, and
cover it for both `run_created` and resilient-start `run_started`. Also add a
generic guard asserting that every field the splitter puts in the meta reaches
the wire, so the next omission in this hand-maintained mapping fails a test
instead of silently degrading encryption.
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for b4ba79e (AI decision).

This is feature work: it adds a new encryptionPublicKey field to the run schema, new exported base64 helpers, a new Postgres column plus migration 0016, and new v4 wire-contract plumbing — carrying minor changesets and building toward the stacked sealed-envelope (encp) feature series (#3093#3099). The fix-flavored commits inside it (public-key loss on resilient start, strict base64 decoding, missing field in buildPostFrameMeta) only correct defects in this same new, main-only capability, so they have nothing to fix on stable.

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

b4ba79ebc501248408474efdd6e353f1753d83e3

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

@TooTallNate@karthikscale3
, '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: publish each run's X25519 public key on the run entity by TooTallNate · Pull Request #3095 · vercel/workflow · GitHub
Skip to content

feat: publish each run's X25519 public key on the run entity - #3095

Merged
TooTallNate merged 3 commits into
mainfrom
nate/encp-run-public-key
Jul 27, 2026
Merged

feat: publish each run's X25519 public key on the run entity#3095
TooTallNate merged 3 commits into
mainfrom
nate/encp-run-public-key

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Jul 24, 2026

Copy link
Copy Markdown
Member

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

What

A cross-run writer needs the recipient run's public key to seal to it. Derive that key at start() and stamp it on the run, so a hook resumption or forwarded-stream writer finds it on a run fetch it was already making instead of spending ~350ms on run-key.

Derived from the per-run key material getEncryptionKeyForRun() already returns, so nothing about key acquisition changes.

Why storing it is safe

It is not secret. The matching private scalar is never stored anywhere — only re-derived on demand from the deployment's own env seed. So this does not violate the requirement that no key material live beside run metadata (that requirement is about secrets), and the server remains zero-knowledge: it stores and serves 32 public bytes it cannot use to decrypt anything.

Anyone with tenant-scoped read access to a run gains encryption capability for it, and nothing more.

Presence is the writer-side gate

A run only carries a public key if the runtime that created it could also open a sealed payload. That holds by construction: derivation and encp dispatch both live in @workflow/core, so any core that can stamp can also open. Runs are pinned to their creating deployment, so the capability this attests to is still accurate at resume time.

This matters because @workflow/core and @workflow/world-vercel are versioned independently and do drift in practice. A gate that consulted only one of them would wedge runs under one drift direction or the other:

  • If a deployment could stamp a key but its core could not dispatchencp, a writer would seal a payload the target cannot decode — run wedged. Locating derivation, stamping and dispatch all in @workflow/core makes this combination impossible to construct.
  • If a deployment's core supports encp but its world package never persisted the field, the run simply has no public key, and writers fall back to the symmetric path — degraded, not broken.

Presence therefore attests to both halves at once, which a version comparison cannot.

Resilient start

The field rides on run_createdand is mirrored onto the queued runInput, because the resilient-start path recreates the run from the queue message when the run_created write failed. Without that mirror, a run recovered that way would silently lose the ability to receive sealed writes.

Surface

PackageChange
@workflow/corederive + stamp at start(); browser/VM-safe base64 helpers
@workflow/worldoptional encryptionPublicKey on the run schema + run_created/run_started
@workflow/world-localmaterialize onto the run entity
@workflow/world-postgresencryption_public_key column + migration 0016
@workflow/world-vercelroute into the v4 frame meta block
@workflow/web-sharedhide the field in the attribute panel (internal plumbing)

Notes for reviewers

  • world-vercel's compile-time wire-contract guard caught the new field before it could be silently dropped on the v4 path, exactly as designed — it failed the build naming encryptionPublicKey. Nice.
  • Base64 helpers are hand-rolled because sealed-box.ts runs in the browser (o11y) and inside the workflow VM, where neither Buffer nor btoa can be assumed. They're cross-validated against Buffer in tests across a range of lengths.
  • base64ToBytes returns undefined on malformed input rather than throwing, so a corrupt stored key degrades to "no usable public key" → symmetric fallback, instead of crashing a resumption.
  • Tests include an end-to-end assertion that the published key actually opens a payload sealed to it, plus per-run isolation and the encryption-disabled case.

Postgres migration is additive and nullable. Full core suite (1585) passes; all world suites pass; workspace typecheck clean.

CopilotAI review requested due to automatic review settings July 24, 2026 20:56
@TooTallNate
TooTallNate requested review from a team and ijjk as code ownersJuly 24, 2026 20:56
@changeset-bot

changeset-botBot commented Jul 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e7c2128

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/world-postgresMinor
@workflow/web-sharedPatch
@workflow/worldMinor
@workflow/world-localMinor
@workflow/world-vercelMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/webPatch
workflowMinor
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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 24, 2026

Copy link
Copy Markdown
Contributor

@github-actions

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

This PR publishes each workflow run’s X25519 public key (encryptionPublicKey) onto the run entity at creation time, so cross-run writers can seal (encp) payloads to a run without an additional run-key fetch, while keeping private key material derivable-only (not stored).

Changes:

  • Derive the run’s X25519 public key from existing per-run key material during start() and include it on run_created and queued runInput (resilient start).
  • Plumb the optional encryptionPublicKey field through world schemas, Vercel v4 meta splitting, and local/postgres run materialization (plus a postgres migration).
  • Add base64 encode/decode helpers and tests; hide the field in the web attribute panel.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
packages/world/src/runs.tsAdds optional encryptionPublicKey to the run schema with rationale/semantics.
packages/world/src/events.tsAdds encryptionPublicKey to run_created and resilient-start run_started event schemas.
packages/world-vercel/src/events.tsRoutes encryptionPublicKey into the v4 meta block so it isn’t dropped on the wire.
packages/world-postgres/src/storage.tsPersists encryptionPublicKey from run_created into the run row.
packages/world-postgres/src/drizzle/schema.tsAdds encryptionPublicKey column mapping to the runs table schema.
packages/world-postgres/src/drizzle/migrations/meta/_journal.jsonRegisters migration 0016_add_encryption_public_key.
packages/world-postgres/src/drizzle/migrations/0016_add_encryption_public_key.sqlAdds encryption_public_key column to workflow_runs.
packages/world-local/src/storage/events-storage.tsPersists encryptionPublicKey onto the local run entity for run_created.
packages/web-shared/src/components/sidebar/attribute-panel.tsxHides encryptionPublicKey in the attribute panel UI.
packages/core/src/sealed-box.tsAdds VM/browser-safe base64 helpers used for publishing the run public key.
packages/core/src/sealed-box.test.tsTests base64 helpers against Buffer plus malformed input behavior.
packages/core/src/runtime/start.tsDerives/stamps encryptionPublicKey on run_created and queued runInput.
packages/core/src/runtime/start.test.tsAdds tests asserting stamping, usability (seal/open), isolation, and disabled-encryption behavior.
.changeset/encp-run-public-key-worlds.mdChangeset for world packages to persist/transport encryptionPublicKey.
.changeset/encp-run-public-key-web-shared.mdChangeset for hiding encryptionPublicKey in the web attribute panel.
.changeset/encp-run-public-key-postgres.mdChangeset for the postgres column/migration.
.changeset/encp-run-public-key-core.mdChangeset for core stamping at start().

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

Comment threadpackages/core/src/sealed-box.ts
Comment threadpackages/world-postgres/src/storage.ts
Comment threadpackages/world-local/src/storage/events-storage.ts

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

  1. world-local lifecycle handlers (run_started/completed/failed/cancelled) rebuild the run row without encryptionPublicKey, so the key stamped at run_created is dropped as soon as the run starts.
  1. The world-postgres resilient-start path (run_started recreating a run) drops encryptionPublicKey, so runs recovered this way permanently lack their public key and cannot receive sealed writes.
  1. The resilient-start path (run_started recreating a run when run_created was missed) drops encryptionPublicKey, so a run recovered this way permanently loses its ability to receive sealed writes on the world-local (and world-postgres) backends.

Fix on Vercel

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

Accidentally committed generated file quickjs-assets.generated.ts imports the undeclared module quickjs-wasi, causing TS2307: Cannot find module 'quickjs-wasi' and breaking @workflow/core's build.

Fix on Vercel

@TooTallNate

TooTallNate commented Jul 25, 2026

Copy link
Copy Markdown
MemberAuthor

Heads up — this PR's public key never actually reached the server. Found it during manual verification; the fix is now the third commit on this branch (34056d55f).

What was happening

The key was computed, put in the run_created event data, lifted into the v4 frame meta, spread into the request input… and then silently dropped before the request was sent.

packages/world-vercel/src/events-v4.ts builds the wire meta field-by-field, and buildPostFrameMeta never forwarded encryptionPublicKey. The field was also missing from the CreateEventV4Input interface entirely.

The reason nothing caught it: events.ts applies the meta with a spread:

const{ payload, meta }=splitEventDataForV4(data);awaitcreateWorkflowRunEventV4({runId: id,/* … */ payload, ...meta},config);

TypeScript doesn't apply excess-property checks to spreads, so an unforwarded field is not a type error. The exhaustiveness guard added in this PR covers world schema ↔ events.ts allowlist, but there is no guard for events.ts meta ↔ events-v4.ts wire. That's the same hand-maintained v4 mapping AGENTS.md already flags for having regressed trace-context propagation.

Why this was hard to see

Every symptom pointed at the server side rather than at us:

  • a deliberately oversized key was accepted rather than rejected — because the field never arrived
  • the key was never echoed back
  • encryptionPublicKey was absent from the run row in DynamoDB
  • resumeHook() always chose encr

I spent a while looking server-side. The problem was here: the SDK was dropping the field before it ever went out.

Fix

Add encryptionPublicKey to CreateEventV4Input, forward it in buildPostFrameMeta, cover both run_created and resilient-start run_started, and add a generic guard asserting every field the splitter puts in the meta reaches the wire — so the next omission in this mapping fails a test instead of silently degrading encryption. All three new tests fail without the one-line forward.

Verified in production

run_created.eventData.encryptionPublicKey = 4jWP17aASciRa+hftatSP8/21Qy+byelU0WGkFKjC2w=
run entity .encryptionPublicKey = 4jWP17aASciRa+hftatSP8/21Qy+byelU0WGkFKjC2w=

Then a genuinely cross-deployment resumeHook() (owner dpl_7HJcFLVE…, resumer dpl_9RYnfewA…):

hook_received.payload._data prefix = b'encp' (111 bytes) <- was `encr`
run status = completed
returnValue.received = {"sealed":"from-deployment-B","n":42}

The round trip is the important part: an encp prefix only proves the writer sealed, but the payload coming back out of the workflow proves the owner re-derived its scalar and opened it. (The run's own outputRef stays encr, which is correct — only cross-run writers seal.)

Where the fix landed

Originally I committed this on the stack tip, which would have meant merging #3095#3098 with encryption silently downgraded to encr. It now lives here instead, so every merge point in the stack is correct. The whole stack was rebased onto latest main at the same time.

@karthikscale3karthikscale3 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: Reviewed the run public-key publication path end to end. The key is derived only when encryption material exists and is propagated through run_created, queued runInput, resilient-start reconstruction, lifecycle rewrites, Postgres persistence/migration, and the Vercel v4 split/meta wire path. The three existing inline findings and the lifecycle/resilient-start bot findings are fixed on this head. The stale generated-file warning is not present and module-boundary checks pass. The matrix-wide Vercel production failures remain unrelated to this change.

@github-actions

github-actionsBot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit e7c2128 · Mon, 27 Jul 2026 21:52:33 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1302 (+30%) 🔻1391 🔴 (+23%) 🔻1405 🔴 (+18%) 🔻1488 🔴 (-7.5%)30
TTFSstream1289 (+325%) 🔻1348 🔴 (+17%) 🔻1380 🔴 (+15%) 🔻1428 🔴 (+6.5%)30
TTFShook + stream1162 (+157%) 🔻1630 🔴 (+14%)1689 🔴 (+14%)1971 🔴 (+5.2%)30
STSO1020 steps (1-20)167 (-9.7%)273 🔴 (-6.2%)321 🔴 (-5.3%)559 🔴 (+37%) 🔻19
STSO1020 steps (101-120)196 (+1.0%)278 🔴 (-4.5%)322 🔴 (-41%) 💚322 🔴 (-49%) 💚19
STSO1020 steps (1001-1020)465 (-13%)517 🔴 (-23%) 💚571 🔴 (-40%) 💚676 🔴 (-31%) 💚19
WO1020 steps401208 (-9.9%)401208 (-9.9%)401208 (-9.9%)401208 (-9.9%)1
SLstream latency107 (+7.0%)161 🔴 (-25%) 💚213 🔴 (-52%) 💚442 🔴 (-22%) 💚30
SOstream overhead (text)93 (-27%) 💚195 (-19%) 💚220 (-22%) 💚295 (-69%) 💚30
SOstream overhead (structured)122 (+2.5%)183 (-33%) 💚224 (-24%) 💚297 (-29%) 💚30
📜 Previous results (1)

57d0c1b

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFShook + stream399 (-63%) 💚1644 🔴 (+34%) 🔻1686 🔴 (+34%) 🔻2072 🔴 (+41%) 🔻30
STSO1020 steps (1-20)187 (+3.3%)297 🔴 (+7.6%)349 🔴 (+15%)409 🔴 (+3.8%)19
STSO1020 steps (101-120)190 (+2.2%)373 🔴 (+40%) 🔻464 🔴 (+54%) 🔻2155 🔴 (-11%)19
STSO1020 steps (1001-1020)515 (+15%) 🔻594 🔴 (+15%) 🔻712 🔴 (+32%) 🔻1307 🔴 (+99%) 🔻19
WO1020 steps433580 (+13%)433580 (+13%)433580 (+13%)433580 (+13%)1
SLstream latency99 (+29%) 🔻180 🔴 (+45%) 🔻191 🔴 (+33%) 🔻495 🔴 (+94%) 🔻30
SOstream overhead (text)125 (+29%) 🔻238 (+54%) 🔻273 (+56%) 🔻389 (+96%) 🔻30
SOstream overhead (structured)134 (+33%) 🔻221 (+32%) 🔻299 (+69%) 🔻426 (-72%) 💚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.

A cross-run writer needs the recipient run's public key to seal a payload
to it. Derive that key at `start()` and stamp it on the run, so a hook
resumption or a forwarded-stream writer can find it on a run fetch it was
already making instead of spending ~350ms on `run-key`.
The key is derived from the per-run key material `getEncryptionKeyForRun()`
already returns, so nothing about key acquisition changes. It is not
secret: the matching private scalar is never stored anywhere, only
re-derived on demand from the deployment's own env seed. Storing it beside
run metadata therefore does not weaken the run's confidentiality.
**Presence is the writer-side gate for sealed envelopes.** A run only
carries a public key if the runtime that created it could also open one —
which holds by construction, since derivation and `encp` dispatch both
live in `@workflow/core`, so any core that can stamp can also open. Runs
are pinned to their creating deployment, so the capability this attests to
is still accurate at resume time. Writers seal iff the field is set and
otherwise fall back to the symmetric path, which makes version skew
degrade gracefully instead of wedging a run.
The field rides on `run_created`, and is mirrored onto the queued
`runInput` so the resilient-start path (server recreates the run from the
queue message when the `run_created` write failed) doesn't silently
produce a run that can't receive sealed writes.
world-vercel's compile-time wire-contract guard caught the new field
before it could be silently dropped on the v4 path, exactly as designed —
routed into the frame meta block as plaintext metadata.
Also adds browser- and VM-safe base64 helpers to `sealed-box.ts`, since
neither `Buffer` nor `btoa` can be assumed in every context that module
runs in. `base64ToBytes` returns undefined on malformed input rather than
throwing, so a corrupt stored key degrades to "no usable public key" and
falls back to the symmetric path instead of crashing a resumption. Both
are cross-validated against `Buffer` in tests.
Two real bugs found in review, both in the local worlds. Neither surfaces
as an error — a run just silently stops accepting sealed cross-run writes
and falls back to the slow symmetric path forever.
**Resilient start dropped the key.** When a `run_started` arrives for a
run that was never created, world-local and world-postgres rebuild the run
from the queued message. Neither copied `encryptionPublicKey` onto the run
row or the synthetic `run_created` event they write. That is precisely the
scenario this field exists to survive. (The equivalent server-side path was
already handled.)
**world-local also wiped the key on every lifecycle transition.** Its
run_started / run_completed / run_failed / run_cancelled handlers rewrite
the whole run document field-by-field, so any field not explicitly listed
is dropped — meaning the key was lost on the *first* `run_started`, not
just on the resilient path. All four rebuild sites now carry it.
world-postgres is safe here by construction because it issues
column-scoped SQL UPDATEs rather than rewriting the row.
**base64 decoding is now strict.** The decoder accepted shapes that
cannot describe a whole number of bytes (`length % 4 === 1`) and ignored
anything after a mid-string `=`, returning a short array instead of
`undefined`. That is worse than throwing: a corrupt stored key looked
*present*, so callers sealed to garbage rather than taking the symmetric
fallback. Now rejects out-of-alphabet characters, bad lengths, misplaced
padding, and non-zero trailing bits — with a round-trip test over every
length 0–48 to make sure the strictness does not overshoot.
`splitEventDataForV4` lifted the run's public key into the frame meta and
`events.ts` spread that meta into `CreateEventV4Input`, but
`buildPostFrameMeta` — which copies meta onto the wire field by field — never
forwarded `encryptionPublicKey`, and the field was missing from
`CreateEventV4Input` entirely. Because the meta is applied with a spread,
TypeScript's excess-property check doesn't fire, so the key was computed, put
in the meta, and then silently dropped before the request was sent.
The server therefore never received the key, never stored it on the run
entity, and every cross-run writer fell back to the symmetric envelope. Every
symptom pointed away from the SDK: a deliberately oversized key was accepted
rather than rejected (the field never arrived), the key was absent from the run
row, and `resumeHook()` always chose `encr`.
Add the field to `CreateEventV4Input`, forward it in `buildPostFrameMeta`, and
cover it for both `run_created` and resilient-start `run_started`. Also add a
generic guard asserting that every field the splitter puts in the meta reaches
the wire, so the next omission in this hand-maintained mapping fails a test
instead of silently degrading encryption.
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for b4ba79e (AI decision).

This is feature work: it adds a new encryptionPublicKey field to the run schema, new exported base64 helpers, a new Postgres column plus migration 0016, and new v4 wire-contract plumbing — carrying minor changesets and building toward the stacked sealed-envelope (encp) feature series (#3093#3099). The fix-flavored commits inside it (public-key loss on resilient start, strict base64 decoding, missing field in buildPostFrameMeta) only correct defects in this same new, main-only capability, so they have nothing to fix on stable.

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

b4ba79ebc501248408474efdd6e353f1753d83e3

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

@TooTallNate@karthikscale3
, '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); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: publish each run's X25519 public key on the run entity by TooTallNate · Pull Request #3095 · vercel/workflow · GitHub
Skip to content

feat: publish each run's X25519 public key on the run entity - #3095

Merged
TooTallNate merged 3 commits into
mainfrom
nate/encp-run-public-key
Jul 27, 2026
Merged

feat: publish each run's X25519 public key on the run entity#3095
TooTallNate merged 3 commits into
mainfrom
nate/encp-run-public-key

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Jul 24, 2026

Copy link
Copy Markdown
Member

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

What

A cross-run writer needs the recipient run's public key to seal to it. Derive that key at start() and stamp it on the run, so a hook resumption or forwarded-stream writer finds it on a run fetch it was already making instead of spending ~350ms on run-key.

Derived from the per-run key material getEncryptionKeyForRun() already returns, so nothing about key acquisition changes.

Why storing it is safe

It is not secret. The matching private scalar is never stored anywhere — only re-derived on demand from the deployment's own env seed. So this does not violate the requirement that no key material live beside run metadata (that requirement is about secrets), and the server remains zero-knowledge: it stores and serves 32 public bytes it cannot use to decrypt anything.

Anyone with tenant-scoped read access to a run gains encryption capability for it, and nothing more.

Presence is the writer-side gate

A run only carries a public key if the runtime that created it could also open a sealed payload. That holds by construction: derivation and encp dispatch both live in @workflow/core, so any core that can stamp can also open. Runs are pinned to their creating deployment, so the capability this attests to is still accurate at resume time.

This matters because @workflow/core and @workflow/world-vercel are versioned independently and do drift in practice. A gate that consulted only one of them would wedge runs under one drift direction or the other:

  • If a deployment could stamp a key but its core could not dispatchencp, a writer would seal a payload the target cannot decode — run wedged. Locating derivation, stamping and dispatch all in @workflow/core makes this combination impossible to construct.
  • If a deployment's core supports encp but its world package never persisted the field, the run simply has no public key, and writers fall back to the symmetric path — degraded, not broken.

Presence therefore attests to both halves at once, which a version comparison cannot.

Resilient start

The field rides on run_createdand is mirrored onto the queued runInput, because the resilient-start path recreates the run from the queue message when the run_created write failed. Without that mirror, a run recovered that way would silently lose the ability to receive sealed writes.

Surface

PackageChange
@workflow/corederive + stamp at start(); browser/VM-safe base64 helpers
@workflow/worldoptional encryptionPublicKey on the run schema + run_created/run_started
@workflow/world-localmaterialize onto the run entity
@workflow/world-postgresencryption_public_key column + migration 0016
@workflow/world-vercelroute into the v4 frame meta block
@workflow/web-sharedhide the field in the attribute panel (internal plumbing)

Notes for reviewers

  • world-vercel's compile-time wire-contract guard caught the new field before it could be silently dropped on the v4 path, exactly as designed — it failed the build naming encryptionPublicKey. Nice.
  • Base64 helpers are hand-rolled because sealed-box.ts runs in the browser (o11y) and inside the workflow VM, where neither Buffer nor btoa can be assumed. They're cross-validated against Buffer in tests across a range of lengths.
  • base64ToBytes returns undefined on malformed input rather than throwing, so a corrupt stored key degrades to "no usable public key" → symmetric fallback, instead of crashing a resumption.
  • Tests include an end-to-end assertion that the published key actually opens a payload sealed to it, plus per-run isolation and the encryption-disabled case.

Postgres migration is additive and nullable. Full core suite (1585) passes; all world suites pass; workspace typecheck clean.

CopilotAI review requested due to automatic review settings July 24, 2026 20:56
@TooTallNate
TooTallNate requested review from a team and ijjk as code ownersJuly 24, 2026 20:56
@changeset-bot

changeset-botBot commented Jul 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e7c2128

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/world-postgresMinor
@workflow/web-sharedPatch
@workflow/worldMinor
@workflow/world-localMinor
@workflow/world-vercelMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/webPatch
workflowMinor
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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 24, 2026

Copy link
Copy Markdown
Contributor

@github-actions

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

This PR publishes each workflow run’s X25519 public key (encryptionPublicKey) onto the run entity at creation time, so cross-run writers can seal (encp) payloads to a run without an additional run-key fetch, while keeping private key material derivable-only (not stored).

Changes:

  • Derive the run’s X25519 public key from existing per-run key material during start() and include it on run_created and queued runInput (resilient start).
  • Plumb the optional encryptionPublicKey field through world schemas, Vercel v4 meta splitting, and local/postgres run materialization (plus a postgres migration).
  • Add base64 encode/decode helpers and tests; hide the field in the web attribute panel.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
packages/world/src/runs.tsAdds optional encryptionPublicKey to the run schema with rationale/semantics.
packages/world/src/events.tsAdds encryptionPublicKey to run_created and resilient-start run_started event schemas.
packages/world-vercel/src/events.tsRoutes encryptionPublicKey into the v4 meta block so it isn’t dropped on the wire.
packages/world-postgres/src/storage.tsPersists encryptionPublicKey from run_created into the run row.
packages/world-postgres/src/drizzle/schema.tsAdds encryptionPublicKey column mapping to the runs table schema.
packages/world-postgres/src/drizzle/migrations/meta/_journal.jsonRegisters migration 0016_add_encryption_public_key.
packages/world-postgres/src/drizzle/migrations/0016_add_encryption_public_key.sqlAdds encryption_public_key column to workflow_runs.
packages/world-local/src/storage/events-storage.tsPersists encryptionPublicKey onto the local run entity for run_created.
packages/web-shared/src/components/sidebar/attribute-panel.tsxHides encryptionPublicKey in the attribute panel UI.
packages/core/src/sealed-box.tsAdds VM/browser-safe base64 helpers used for publishing the run public key.
packages/core/src/sealed-box.test.tsTests base64 helpers against Buffer plus malformed input behavior.
packages/core/src/runtime/start.tsDerives/stamps encryptionPublicKey on run_created and queued runInput.
packages/core/src/runtime/start.test.tsAdds tests asserting stamping, usability (seal/open), isolation, and disabled-encryption behavior.
.changeset/encp-run-public-key-worlds.mdChangeset for world packages to persist/transport encryptionPublicKey.
.changeset/encp-run-public-key-web-shared.mdChangeset for hiding encryptionPublicKey in the web attribute panel.
.changeset/encp-run-public-key-postgres.mdChangeset for the postgres column/migration.
.changeset/encp-run-public-key-core.mdChangeset for core stamping at start().

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

Comment threadpackages/core/src/sealed-box.ts
Comment threadpackages/world-postgres/src/storage.ts
Comment threadpackages/world-local/src/storage/events-storage.ts

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

  1. world-local lifecycle handlers (run_started/completed/failed/cancelled) rebuild the run row without encryptionPublicKey, so the key stamped at run_created is dropped as soon as the run starts.
  1. The world-postgres resilient-start path (run_started recreating a run) drops encryptionPublicKey, so runs recovered this way permanently lack their public key and cannot receive sealed writes.
  1. The resilient-start path (run_started recreating a run when run_created was missed) drops encryptionPublicKey, so a run recovered this way permanently loses its ability to receive sealed writes on the world-local (and world-postgres) backends.

Fix on Vercel

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

Accidentally committed generated file quickjs-assets.generated.ts imports the undeclared module quickjs-wasi, causing TS2307: Cannot find module 'quickjs-wasi' and breaking @workflow/core's build.

Fix on Vercel

@TooTallNate

TooTallNate commented Jul 25, 2026

Copy link
Copy Markdown
MemberAuthor

Heads up — this PR's public key never actually reached the server. Found it during manual verification; the fix is now the third commit on this branch (34056d55f).

What was happening

The key was computed, put in the run_created event data, lifted into the v4 frame meta, spread into the request input… and then silently dropped before the request was sent.

packages/world-vercel/src/events-v4.ts builds the wire meta field-by-field, and buildPostFrameMeta never forwarded encryptionPublicKey. The field was also missing from the CreateEventV4Input interface entirely.

The reason nothing caught it: events.ts applies the meta with a spread:

const{ payload, meta }=splitEventDataForV4(data);awaitcreateWorkflowRunEventV4({runId: id,/* … */ payload, ...meta},config);

TypeScript doesn't apply excess-property checks to spreads, so an unforwarded field is not a type error. The exhaustiveness guard added in this PR covers world schema ↔ events.ts allowlist, but there is no guard for events.ts meta ↔ events-v4.ts wire. That's the same hand-maintained v4 mapping AGENTS.md already flags for having regressed trace-context propagation.

Why this was hard to see

Every symptom pointed at the server side rather than at us:

  • a deliberately oversized key was accepted rather than rejected — because the field never arrived
  • the key was never echoed back
  • encryptionPublicKey was absent from the run row in DynamoDB
  • resumeHook() always chose encr

I spent a while looking server-side. The problem was here: the SDK was dropping the field before it ever went out.

Fix

Add encryptionPublicKey to CreateEventV4Input, forward it in buildPostFrameMeta, cover both run_created and resilient-start run_started, and add a generic guard asserting every field the splitter puts in the meta reaches the wire — so the next omission in this mapping fails a test instead of silently degrading encryption. All three new tests fail without the one-line forward.

Verified in production

run_created.eventData.encryptionPublicKey = 4jWP17aASciRa+hftatSP8/21Qy+byelU0WGkFKjC2w=
run entity .encryptionPublicKey = 4jWP17aASciRa+hftatSP8/21Qy+byelU0WGkFKjC2w=

Then a genuinely cross-deployment resumeHook() (owner dpl_7HJcFLVE…, resumer dpl_9RYnfewA…):

hook_received.payload._data prefix = b'encp' (111 bytes) <- was `encr`
run status = completed
returnValue.received = {"sealed":"from-deployment-B","n":42}

The round trip is the important part: an encp prefix only proves the writer sealed, but the payload coming back out of the workflow proves the owner re-derived its scalar and opened it. (The run's own outputRef stays encr, which is correct — only cross-run writers seal.)

Where the fix landed

Originally I committed this on the stack tip, which would have meant merging #3095#3098 with encryption silently downgraded to encr. It now lives here instead, so every merge point in the stack is correct. The whole stack was rebased onto latest main at the same time.

@karthikscale3karthikscale3 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: Reviewed the run public-key publication path end to end. The key is derived only when encryption material exists and is propagated through run_created, queued runInput, resilient-start reconstruction, lifecycle rewrites, Postgres persistence/migration, and the Vercel v4 split/meta wire path. The three existing inline findings and the lifecycle/resilient-start bot findings are fixed on this head. The stale generated-file warning is not present and module-boundary checks pass. The matrix-wide Vercel production failures remain unrelated to this change.

@github-actions

github-actionsBot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit e7c2128 · Mon, 27 Jul 2026 21:52:33 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1302 (+30%) 🔻1391 🔴 (+23%) 🔻1405 🔴 (+18%) 🔻1488 🔴 (-7.5%)30
TTFSstream1289 (+325%) 🔻1348 🔴 (+17%) 🔻1380 🔴 (+15%) 🔻1428 🔴 (+6.5%)30
TTFShook + stream1162 (+157%) 🔻1630 🔴 (+14%)1689 🔴 (+14%)1971 🔴 (+5.2%)30
STSO1020 steps (1-20)167 (-9.7%)273 🔴 (-6.2%)321 🔴 (-5.3%)559 🔴 (+37%) 🔻19
STSO1020 steps (101-120)196 (+1.0%)278 🔴 (-4.5%)322 🔴 (-41%) 💚322 🔴 (-49%) 💚19
STSO1020 steps (1001-1020)465 (-13%)517 🔴 (-23%) 💚571 🔴 (-40%) 💚676 🔴 (-31%) 💚19
WO1020 steps401208 (-9.9%)401208 (-9.9%)401208 (-9.9%)401208 (-9.9%)1
SLstream latency107 (+7.0%)161 🔴 (-25%) 💚213 🔴 (-52%) 💚442 🔴 (-22%) 💚30
SOstream overhead (text)93 (-27%) 💚195 (-19%) 💚220 (-22%) 💚295 (-69%) 💚30
SOstream overhead (structured)122 (+2.5%)183 (-33%) 💚224 (-24%) 💚297 (-29%) 💚30
📜 Previous results (1)

57d0c1b

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFShook + stream399 (-63%) 💚1644 🔴 (+34%) 🔻1686 🔴 (+34%) 🔻2072 🔴 (+41%) 🔻30
STSO1020 steps (1-20)187 (+3.3%)297 🔴 (+7.6%)349 🔴 (+15%)409 🔴 (+3.8%)19
STSO1020 steps (101-120)190 (+2.2%)373 🔴 (+40%) 🔻464 🔴 (+54%) 🔻2155 🔴 (-11%)19
STSO1020 steps (1001-1020)515 (+15%) 🔻594 🔴 (+15%) 🔻712 🔴 (+32%) 🔻1307 🔴 (+99%) 🔻19
WO1020 steps433580 (+13%)433580 (+13%)433580 (+13%)433580 (+13%)1
SLstream latency99 (+29%) 🔻180 🔴 (+45%) 🔻191 🔴 (+33%) 🔻495 🔴 (+94%) 🔻30
SOstream overhead (text)125 (+29%) 🔻238 (+54%) 🔻273 (+56%) 🔻389 (+96%) 🔻30
SOstream overhead (structured)134 (+33%) 🔻221 (+32%) 🔻299 (+69%) 🔻426 (-72%) 💚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.

A cross-run writer needs the recipient run's public key to seal a payload
to it. Derive that key at `start()` and stamp it on the run, so a hook
resumption or a forwarded-stream writer can find it on a run fetch it was
already making instead of spending ~350ms on `run-key`.
The key is derived from the per-run key material `getEncryptionKeyForRun()`
already returns, so nothing about key acquisition changes. It is not
secret: the matching private scalar is never stored anywhere, only
re-derived on demand from the deployment's own env seed. Storing it beside
run metadata therefore does not weaken the run's confidentiality.
**Presence is the writer-side gate for sealed envelopes.** A run only
carries a public key if the runtime that created it could also open one —
which holds by construction, since derivation and `encp` dispatch both
live in `@workflow/core`, so any core that can stamp can also open. Runs
are pinned to their creating deployment, so the capability this attests to
is still accurate at resume time. Writers seal iff the field is set and
otherwise fall back to the symmetric path, which makes version skew
degrade gracefully instead of wedging a run.
The field rides on `run_created`, and is mirrored onto the queued
`runInput` so the resilient-start path (server recreates the run from the
queue message when the `run_created` write failed) doesn't silently
produce a run that can't receive sealed writes.
world-vercel's compile-time wire-contract guard caught the new field
before it could be silently dropped on the v4 path, exactly as designed —
routed into the frame meta block as plaintext metadata.
Also adds browser- and VM-safe base64 helpers to `sealed-box.ts`, since
neither `Buffer` nor `btoa` can be assumed in every context that module
runs in. `base64ToBytes` returns undefined on malformed input rather than
throwing, so a corrupt stored key degrades to "no usable public key" and
falls back to the symmetric path instead of crashing a resumption. Both
are cross-validated against `Buffer` in tests.
Two real bugs found in review, both in the local worlds. Neither surfaces
as an error — a run just silently stops accepting sealed cross-run writes
and falls back to the slow symmetric path forever.
**Resilient start dropped the key.** When a `run_started` arrives for a
run that was never created, world-local and world-postgres rebuild the run
from the queued message. Neither copied `encryptionPublicKey` onto the run
row or the synthetic `run_created` event they write. That is precisely the
scenario this field exists to survive. (The equivalent server-side path was
already handled.)
**world-local also wiped the key on every lifecycle transition.** Its
run_started / run_completed / run_failed / run_cancelled handlers rewrite
the whole run document field-by-field, so any field not explicitly listed
is dropped — meaning the key was lost on the *first* `run_started`, not
just on the resilient path. All four rebuild sites now carry it.
world-postgres is safe here by construction because it issues
column-scoped SQL UPDATEs rather than rewriting the row.
**base64 decoding is now strict.** The decoder accepted shapes that
cannot describe a whole number of bytes (`length % 4 === 1`) and ignored
anything after a mid-string `=`, returning a short array instead of
`undefined`. That is worse than throwing: a corrupt stored key looked
*present*, so callers sealed to garbage rather than taking the symmetric
fallback. Now rejects out-of-alphabet characters, bad lengths, misplaced
padding, and non-zero trailing bits — with a round-trip test over every
length 0–48 to make sure the strictness does not overshoot.
`splitEventDataForV4` lifted the run's public key into the frame meta and
`events.ts` spread that meta into `CreateEventV4Input`, but
`buildPostFrameMeta` — which copies meta onto the wire field by field — never
forwarded `encryptionPublicKey`, and the field was missing from
`CreateEventV4Input` entirely. Because the meta is applied with a spread,
TypeScript's excess-property check doesn't fire, so the key was computed, put
in the meta, and then silently dropped before the request was sent.
The server therefore never received the key, never stored it on the run
entity, and every cross-run writer fell back to the symmetric envelope. Every
symptom pointed away from the SDK: a deliberately oversized key was accepted
rather than rejected (the field never arrived), the key was absent from the run
row, and `resumeHook()` always chose `encr`.
Add the field to `CreateEventV4Input`, forward it in `buildPostFrameMeta`, and
cover it for both `run_created` and resilient-start `run_started`. Also add a
generic guard asserting that every field the splitter puts in the meta reaches
the wire, so the next omission in this hand-maintained mapping fails a test
instead of silently degrading encryption.
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for b4ba79e (AI decision).

This is feature work: it adds a new encryptionPublicKey field to the run schema, new exported base64 helpers, a new Postgres column plus migration 0016, and new v4 wire-contract plumbing — carrying minor changesets and building toward the stacked sealed-envelope (encp) feature series (#3093#3099). The fix-flavored commits inside it (public-key loss on resilient start, strict base64 decoding, missing field in buildPostFrameMeta) only correct defects in this same new, main-only capability, so they have nothing to fix on stable.

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

b4ba79ebc501248408474efdd6e353f1753d83e3

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

@TooTallNate@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat: publish each run's X25519 public key on the run entity by TooTallNate · Pull Request #3095 · vercel/workflow · GitHub
Skip to content

feat: publish each run's X25519 public key on the run entity - #3095

Merged
TooTallNate merged 3 commits into
mainfrom
nate/encp-run-public-key
Jul 27, 2026
Merged

feat: publish each run's X25519 public key on the run entity#3095
TooTallNate merged 3 commits into
mainfrom
nate/encp-run-public-key

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Jul 24, 2026

Copy link
Copy Markdown
Member

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

What

A cross-run writer needs the recipient run's public key to seal to it. Derive that key at start() and stamp it on the run, so a hook resumption or forwarded-stream writer finds it on a run fetch it was already making instead of spending ~350ms on run-key.

Derived from the per-run key material getEncryptionKeyForRun() already returns, so nothing about key acquisition changes.

Why storing it is safe

It is not secret. The matching private scalar is never stored anywhere — only re-derived on demand from the deployment's own env seed. So this does not violate the requirement that no key material live beside run metadata (that requirement is about secrets), and the server remains zero-knowledge: it stores and serves 32 public bytes it cannot use to decrypt anything.

Anyone with tenant-scoped read access to a run gains encryption capability for it, and nothing more.

Presence is the writer-side gate

A run only carries a public key if the runtime that created it could also open a sealed payload. That holds by construction: derivation and encp dispatch both live in @workflow/core, so any core that can stamp can also open. Runs are pinned to their creating deployment, so the capability this attests to is still accurate at resume time.

This matters because @workflow/core and @workflow/world-vercel are versioned independently and do drift in practice. A gate that consulted only one of them would wedge runs under one drift direction or the other:

  • If a deployment could stamp a key but its core could not dispatchencp, a writer would seal a payload the target cannot decode — run wedged. Locating derivation, stamping and dispatch all in @workflow/core makes this combination impossible to construct.
  • If a deployment's core supports encp but its world package never persisted the field, the run simply has no public key, and writers fall back to the symmetric path — degraded, not broken.

Presence therefore attests to both halves at once, which a version comparison cannot.

Resilient start

The field rides on run_createdand is mirrored onto the queued runInput, because the resilient-start path recreates the run from the queue message when the run_created write failed. Without that mirror, a run recovered that way would silently lose the ability to receive sealed writes.

Surface

PackageChange
@workflow/corederive + stamp at start(); browser/VM-safe base64 helpers
@workflow/worldoptional encryptionPublicKey on the run schema + run_created/run_started
@workflow/world-localmaterialize onto the run entity
@workflow/world-postgresencryption_public_key column + migration 0016
@workflow/world-vercelroute into the v4 frame meta block
@workflow/web-sharedhide the field in the attribute panel (internal plumbing)

Notes for reviewers

  • world-vercel's compile-time wire-contract guard caught the new field before it could be silently dropped on the v4 path, exactly as designed — it failed the build naming encryptionPublicKey. Nice.
  • Base64 helpers are hand-rolled because sealed-box.ts runs in the browser (o11y) and inside the workflow VM, where neither Buffer nor btoa can be assumed. They're cross-validated against Buffer in tests across a range of lengths.
  • base64ToBytes returns undefined on malformed input rather than throwing, so a corrupt stored key degrades to "no usable public key" → symmetric fallback, instead of crashing a resumption.
  • Tests include an end-to-end assertion that the published key actually opens a payload sealed to it, plus per-run isolation and the encryption-disabled case.

Postgres migration is additive and nullable. Full core suite (1585) passes; all world suites pass; workspace typecheck clean.

CopilotAI review requested due to automatic review settings July 24, 2026 20:56
@TooTallNate
TooTallNate requested review from a team and ijjk as code ownersJuly 24, 2026 20:56
@changeset-bot

changeset-botBot commented Jul 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e7c2128

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/world-postgresMinor
@workflow/web-sharedPatch
@workflow/worldMinor
@workflow/world-localMinor
@workflow/world-vercelMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/webPatch
workflowMinor
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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 24, 2026

Copy link
Copy Markdown
Contributor

@github-actions

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

This PR publishes each workflow run’s X25519 public key (encryptionPublicKey) onto the run entity at creation time, so cross-run writers can seal (encp) payloads to a run without an additional run-key fetch, while keeping private key material derivable-only (not stored).

Changes:

  • Derive the run’s X25519 public key from existing per-run key material during start() and include it on run_created and queued runInput (resilient start).
  • Plumb the optional encryptionPublicKey field through world schemas, Vercel v4 meta splitting, and local/postgres run materialization (plus a postgres migration).
  • Add base64 encode/decode helpers and tests; hide the field in the web attribute panel.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
packages/world/src/runs.tsAdds optional encryptionPublicKey to the run schema with rationale/semantics.
packages/world/src/events.tsAdds encryptionPublicKey to run_created and resilient-start run_started event schemas.
packages/world-vercel/src/events.tsRoutes encryptionPublicKey into the v4 meta block so it isn’t dropped on the wire.
packages/world-postgres/src/storage.tsPersists encryptionPublicKey from run_created into the run row.
packages/world-postgres/src/drizzle/schema.tsAdds encryptionPublicKey column mapping to the runs table schema.
packages/world-postgres/src/drizzle/migrations/meta/_journal.jsonRegisters migration 0016_add_encryption_public_key.
packages/world-postgres/src/drizzle/migrations/0016_add_encryption_public_key.sqlAdds encryption_public_key column to workflow_runs.
packages/world-local/src/storage/events-storage.tsPersists encryptionPublicKey onto the local run entity for run_created.
packages/web-shared/src/components/sidebar/attribute-panel.tsxHides encryptionPublicKey in the attribute panel UI.
packages/core/src/sealed-box.tsAdds VM/browser-safe base64 helpers used for publishing the run public key.
packages/core/src/sealed-box.test.tsTests base64 helpers against Buffer plus malformed input behavior.
packages/core/src/runtime/start.tsDerives/stamps encryptionPublicKey on run_created and queued runInput.
packages/core/src/runtime/start.test.tsAdds tests asserting stamping, usability (seal/open), isolation, and disabled-encryption behavior.
.changeset/encp-run-public-key-worlds.mdChangeset for world packages to persist/transport encryptionPublicKey.
.changeset/encp-run-public-key-web-shared.mdChangeset for hiding encryptionPublicKey in the web attribute panel.
.changeset/encp-run-public-key-postgres.mdChangeset for the postgres column/migration.
.changeset/encp-run-public-key-core.mdChangeset for core stamping at start().

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

Comment threadpackages/core/src/sealed-box.ts
Comment threadpackages/world-postgres/src/storage.ts
Comment threadpackages/world-local/src/storage/events-storage.ts

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

  1. world-local lifecycle handlers (run_started/completed/failed/cancelled) rebuild the run row without encryptionPublicKey, so the key stamped at run_created is dropped as soon as the run starts.
  1. The world-postgres resilient-start path (run_started recreating a run) drops encryptionPublicKey, so runs recovered this way permanently lack their public key and cannot receive sealed writes.
  1. The resilient-start path (run_started recreating a run when run_created was missed) drops encryptionPublicKey, so a run recovered this way permanently loses its ability to receive sealed writes on the world-local (and world-postgres) backends.

Fix on Vercel

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

Accidentally committed generated file quickjs-assets.generated.ts imports the undeclared module quickjs-wasi, causing TS2307: Cannot find module 'quickjs-wasi' and breaking @workflow/core's build.

Fix on Vercel

@TooTallNate

TooTallNate commented Jul 25, 2026

Copy link
Copy Markdown
MemberAuthor

Heads up — this PR's public key never actually reached the server. Found it during manual verification; the fix is now the third commit on this branch (34056d55f).

What was happening

The key was computed, put in the run_created event data, lifted into the v4 frame meta, spread into the request input… and then silently dropped before the request was sent.

packages/world-vercel/src/events-v4.ts builds the wire meta field-by-field, and buildPostFrameMeta never forwarded encryptionPublicKey. The field was also missing from the CreateEventV4Input interface entirely.

The reason nothing caught it: events.ts applies the meta with a spread:

const{ payload, meta }=splitEventDataForV4(data);awaitcreateWorkflowRunEventV4({runId: id,/* … */ payload, ...meta},config);

TypeScript doesn't apply excess-property checks to spreads, so an unforwarded field is not a type error. The exhaustiveness guard added in this PR covers world schema ↔ events.ts allowlist, but there is no guard for events.ts meta ↔ events-v4.ts wire. That's the same hand-maintained v4 mapping AGENTS.md already flags for having regressed trace-context propagation.

Why this was hard to see

Every symptom pointed at the server side rather than at us:

  • a deliberately oversized key was accepted rather than rejected — because the field never arrived
  • the key was never echoed back
  • encryptionPublicKey was absent from the run row in DynamoDB
  • resumeHook() always chose encr

I spent a while looking server-side. The problem was here: the SDK was dropping the field before it ever went out.

Fix

Add encryptionPublicKey to CreateEventV4Input, forward it in buildPostFrameMeta, cover both run_created and resilient-start run_started, and add a generic guard asserting every field the splitter puts in the meta reaches the wire — so the next omission in this mapping fails a test instead of silently degrading encryption. All three new tests fail without the one-line forward.

Verified in production

run_created.eventData.encryptionPublicKey = 4jWP17aASciRa+hftatSP8/21Qy+byelU0WGkFKjC2w=
run entity .encryptionPublicKey = 4jWP17aASciRa+hftatSP8/21Qy+byelU0WGkFKjC2w=

Then a genuinely cross-deployment resumeHook() (owner dpl_7HJcFLVE…, resumer dpl_9RYnfewA…):

hook_received.payload._data prefix = b'encp' (111 bytes) <- was `encr`
run status = completed
returnValue.received = {"sealed":"from-deployment-B","n":42}

The round trip is the important part: an encp prefix only proves the writer sealed, but the payload coming back out of the workflow proves the owner re-derived its scalar and opened it. (The run's own outputRef stays encr, which is correct — only cross-run writers seal.)

Where the fix landed

Originally I committed this on the stack tip, which would have meant merging #3095#3098 with encryption silently downgraded to encr. It now lives here instead, so every merge point in the stack is correct. The whole stack was rebased onto latest main at the same time.

@karthikscale3karthikscale3 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: Reviewed the run public-key publication path end to end. The key is derived only when encryption material exists and is propagated through run_created, queued runInput, resilient-start reconstruction, lifecycle rewrites, Postgres persistence/migration, and the Vercel v4 split/meta wire path. The three existing inline findings and the lifecycle/resilient-start bot findings are fixed on this head. The stale generated-file warning is not present and module-boundary checks pass. The matrix-wide Vercel production failures remain unrelated to this change.

@github-actions

github-actionsBot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit e7c2128 · Mon, 27 Jul 2026 21:52:33 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1302 (+30%) 🔻1391 🔴 (+23%) 🔻1405 🔴 (+18%) 🔻1488 🔴 (-7.5%)30
TTFSstream1289 (+325%) 🔻1348 🔴 (+17%) 🔻1380 🔴 (+15%) 🔻1428 🔴 (+6.5%)30
TTFShook + stream1162 (+157%) 🔻1630 🔴 (+14%)1689 🔴 (+14%)1971 🔴 (+5.2%)30
STSO1020 steps (1-20)167 (-9.7%)273 🔴 (-6.2%)321 🔴 (-5.3%)559 🔴 (+37%) 🔻19
STSO1020 steps (101-120)196 (+1.0%)278 🔴 (-4.5%)322 🔴 (-41%) 💚322 🔴 (-49%) 💚19
STSO1020 steps (1001-1020)465 (-13%)517 🔴 (-23%) 💚571 🔴 (-40%) 💚676 🔴 (-31%) 💚19
WO1020 steps401208 (-9.9%)401208 (-9.9%)401208 (-9.9%)401208 (-9.9%)1
SLstream latency107 (+7.0%)161 🔴 (-25%) 💚213 🔴 (-52%) 💚442 🔴 (-22%) 💚30
SOstream overhead (text)93 (-27%) 💚195 (-19%) 💚220 (-22%) 💚295 (-69%) 💚30
SOstream overhead (structured)122 (+2.5%)183 (-33%) 💚224 (-24%) 💚297 (-29%) 💚30
📜 Previous results (1)

57d0c1b

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

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFShook + stream399 (-63%) 💚1644 🔴 (+34%) 🔻1686 🔴 (+34%) 🔻2072 🔴 (+41%) 🔻30
STSO1020 steps (1-20)187 (+3.3%)297 🔴 (+7.6%)349 🔴 (+15%)409 🔴 (+3.8%)19
STSO1020 steps (101-120)190 (+2.2%)373 🔴 (+40%) 🔻464 🔴 (+54%) 🔻2155 🔴 (-11%)19
STSO1020 steps (1001-1020)515 (+15%) 🔻594 🔴 (+15%) 🔻712 🔴 (+32%) 🔻1307 🔴 (+99%) 🔻19
WO1020 steps433580 (+13%)433580 (+13%)433580 (+13%)433580 (+13%)1
SLstream latency99 (+29%) 🔻180 🔴 (+45%) 🔻191 🔴 (+33%) 🔻495 🔴 (+94%) 🔻30
SOstream overhead (text)125 (+29%) 🔻238 (+54%) 🔻273 (+56%) 🔻389 (+96%) 🔻30
SOstream overhead (structured)134 (+33%) 🔻221 (+32%) 🔻299 (+69%) 🔻426 (-72%) 💚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.

A cross-run writer needs the recipient run's public key to seal a payload
to it. Derive that key at `start()` and stamp it on the run, so a hook
resumption or a forwarded-stream writer can find it on a run fetch it was
already making instead of spending ~350ms on `run-key`.
The key is derived from the per-run key material `getEncryptionKeyForRun()`
already returns, so nothing about key acquisition changes. It is not
secret: the matching private scalar is never stored anywhere, only
re-derived on demand from the deployment's own env seed. Storing it beside
run metadata therefore does not weaken the run's confidentiality.
**Presence is the writer-side gate for sealed envelopes.** A run only
carries a public key if the runtime that created it could also open one —
which holds by construction, since derivation and `encp` dispatch both
live in `@workflow/core`, so any core that can stamp can also open. Runs
are pinned to their creating deployment, so the capability this attests to
is still accurate at resume time. Writers seal iff the field is set and
otherwise fall back to the symmetric path, which makes version skew
degrade gracefully instead of wedging a run.
The field rides on `run_created`, and is mirrored onto the queued
`runInput` so the resilient-start path (server recreates the run from the
queue message when the `run_created` write failed) doesn't silently
produce a run that can't receive sealed writes.
world-vercel's compile-time wire-contract guard caught the new field
before it could be silently dropped on the v4 path, exactly as designed —
routed into the frame meta block as plaintext metadata.
Also adds browser- and VM-safe base64 helpers to `sealed-box.ts`, since
neither `Buffer` nor `btoa` can be assumed in every context that module
runs in. `base64ToBytes` returns undefined on malformed input rather than
throwing, so a corrupt stored key degrades to "no usable public key" and
falls back to the symmetric path instead of crashing a resumption. Both
are cross-validated against `Buffer` in tests.
Two real bugs found in review, both in the local worlds. Neither surfaces
as an error — a run just silently stops accepting sealed cross-run writes
and falls back to the slow symmetric path forever.
**Resilient start dropped the key.** When a `run_started` arrives for a
run that was never created, world-local and world-postgres rebuild the run
from the queued message. Neither copied `encryptionPublicKey` onto the run
row or the synthetic `run_created` event they write. That is precisely the
scenario this field exists to survive. (The equivalent server-side path was
already handled.)
**world-local also wiped the key on every lifecycle transition.** Its
run_started / run_completed / run_failed / run_cancelled handlers rewrite
the whole run document field-by-field, so any field not explicitly listed
is dropped — meaning the key was lost on the *first* `run_started`, not
just on the resilient path. All four rebuild sites now carry it.
world-postgres is safe here by construction because it issues
column-scoped SQL UPDATEs rather than rewriting the row.
**base64 decoding is now strict.** The decoder accepted shapes that
cannot describe a whole number of bytes (`length % 4 === 1`) and ignored
anything after a mid-string `=`, returning a short array instead of
`undefined`. That is worse than throwing: a corrupt stored key looked
*present*, so callers sealed to garbage rather than taking the symmetric
fallback. Now rejects out-of-alphabet characters, bad lengths, misplaced
padding, and non-zero trailing bits — with a round-trip test over every
length 0–48 to make sure the strictness does not overshoot.
`splitEventDataForV4` lifted the run's public key into the frame meta and
`events.ts` spread that meta into `CreateEventV4Input`, but
`buildPostFrameMeta` — which copies meta onto the wire field by field — never
forwarded `encryptionPublicKey`, and the field was missing from
`CreateEventV4Input` entirely. Because the meta is applied with a spread,
TypeScript's excess-property check doesn't fire, so the key was computed, put
in the meta, and then silently dropped before the request was sent.
The server therefore never received the key, never stored it on the run
entity, and every cross-run writer fell back to the symmetric envelope. Every
symptom pointed away from the SDK: a deliberately oversized key was accepted
rather than rejected (the field never arrived), the key was absent from the run
row, and `resumeHook()` always chose `encr`.
Add the field to `CreateEventV4Input`, forward it in `buildPostFrameMeta`, and
cover it for both `run_created` and resilient-start `run_started`. Also add a
generic guard asserting that every field the splitter puts in the meta reaches
the wire, so the next omission in this hand-maintained mapping fails a test
instead of silently degrading encryption.
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for b4ba79e (AI decision).

This is feature work: it adds a new encryptionPublicKey field to the run schema, new exported base64 helpers, a new Postgres column plus migration 0016, and new v4 wire-contract plumbing — carrying minor changesets and building toward the stacked sealed-envelope (encp) feature series (#3093#3099). The fix-flavored commits inside it (public-key loss on resilient start, strict base64 decoding, missing field in buildPostFrameMeta) only correct defects in this same new, main-only capability, so they have nothing to fix on stable.

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

b4ba79ebc501248408474efdd6e353f1753d83e3

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

@TooTallNate@karthikscale3