feat: decrypt sealed payloads in the dashboard and CLI - #3146
Conversation
Without this, any payload another run sealed to this one renders as a lock icon with no way to open it — a visible regression for anyone debugging a run that received a cross-deployment hook resumption. The user is entitled to read the data and has already supplied the key; only the plumbing was missing. `hydrateDataWithKey` now delegates to the envelope layer, which dispatches on the format prefix, instead of unconditionally running AES-GCM. All four o11y key-resolution sites (web-shared hydration, the web stream reader, and both CLI `--decrypt` paths) resolve the full capability rather than just the symmetric key. Each already had the raw 32 bytes in hand, so this costs one extra derivation and no additional requests. A caller that supplies only a symmetric key still gets the ciphertext placeholder for sealed payloads rather than a decryption error, since that key never could have opened them. **Browser bundling.** The obvious import for the new helper is `@workflow/core/serialization`, but that module graph reaches `node:util` and `node:async_hooks` and cannot be bundled for the browser — which is what `@workflow/core/serialization-format` exists to avoid. The key helpers are re-exported from that browser-safe entrypoint instead, and the two browser consumers import from there; the CLI keeps the direct import since it runs on Node. Verified by walking the built import graph: the entrypoint reaches 6 modules and zero Node built-ins. Unrelated: `pnpm --filter @workflow/web build` currently fails on `main` too (`reducers/common.js` importing `node:util`). Turbo caching had been hiding it; touching core caused a cache miss that surfaced it. Not addressed here.
- `hydrateDataWithKey` accepted `PayloadKey`, which includes `SealTarget`. A seal target holds only a public key, so it can open neither scheme — passing one compiled fine and then always failed at runtime. Added a `DecryptionKey` alias (`CryptoKey | RunPayloadKeys`) and narrowed the signature, so that misuse is now a compile error. A `@ts-expect-error` test pins the guarantee. - `hydrateResourceIOAsync` dynamically imported `@workflow/core/serialization-format` twice. Destructure both bindings from the single existing import instead.
🦋 Changeset detectedLatest commit: 916bc93 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
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 |
📊 Workflow Benchmarkscommit Backend:
ℹ️ Metric definitions & methodologyBest/P75/P90/P99 deltas compare against the most recent benchmark run on 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 ( Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the |
🧪 E2E Test Results✅ All tests passed E2E Test SummarySummary
Details by Category✅ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
✅ 📋 Other
✅ vercel-multi-region
|
karthikscale3
left a comment
There was a problem hiding this comment.
ai review: The decryption changes look correct, but the published @workflow/web package is missing from the changeset.
karthikscale3
left a comment
There was a problem hiding this comment.
Reviewed at 85e262c. Implementation looks good; the existing inline changeset note is non-blocking.
This PR changes the dashboard's stream reader (`packages/web/app/lib/hooks/use-stream-reader.ts`) so it dispatches on the envelope format and can read sealed (`encp`) frames, but the changeset listed only core, web-shared and cli. `@workflow/web` is published, so without an entry the change would still ship — just as an incidental dependency bump, with nothing in that package's release notes explaining that sealed-stream decryption landed.
|
No backport to This is feature work — PR 5 of 7 in a stack that introduces the sealed-box ( To override, re-run the Backport to stable workflow manually via |
…ry-2 * origin/main: (292 commits) feat(core): seal forwarded stream writes to the owner's public key (#3098) feat(core): seal hook payloads to the target run's public key (#3096) [e2e] Rebuild the event-log corruption repro around step-count divergence (#3147) feat: decrypt sealed payloads in the dashboard and CLI (#3146) Prewarm only appended replay payloads (#3131) feat: publish each run's X25519 public key on the run entity (#3095) feat(core): route sealed envelopes through the serialization layer (#3094) docs: redirect retired migration-guides URLs to comparisons (#3127) feat(core): add `encp` sealed-box encryption primitive (#3093) chore(core): clarify runtime comments (#3111) Remove obsolete world factory aliases (#3112) feat(core): deterministic sandbox hardening (#3045) Remove retired v1 step route plumbing (#3061) [core] Don't count racing invocations' duplicate step_started events toward the maxRetries ceiling (#3069) [world-testing] Isolate each spawned test server's data directory (#3055) fix: upgrade postcss to >=8.5.18 to address GHSA-r28c-9q8g-f849 (#3102) [next] Respect .gitignore in dev watcher to avoid EMFILE on large monorepos (#3085) [ci] Backport only stability fixes to `stable`, default to claude-opus-5 (#3092) perf(core): immediate leading-edge dispatch for idle streams (flush window default 0) (#3088) Optimize `processImportSpecifier` by computing `shouldFollowImportsFromFile` once per file (#3052) ... # Conflicts: # docs/components/geistdocs/desktop-menu.tsx # docs/components/geistdocs/mobile-menu.tsx # docs/content/docs/v5/cookbook/advanced/child-workflows.mdx # docs/content/docs/v5/cookbook/advanced/upgrading-workflows.mdx # docs/content/docs/v5/cookbook/agent-patterns/agent-cancellation.mdx # docs/content/docs/v5/cookbook/agent-patterns/durable-agent.mdx # docs/content/docs/v5/cookbook/agent-patterns/human-in-the-loop.mdx # docs/content/docs/v5/cookbook/common-patterns/batching.mdx # docs/content/docs/v5/cookbook/common-patterns/idempotency.mdx # docs/content/docs/v5/cookbook/common-patterns/rate-limiting.mdx # docs/content/docs/v5/cookbook/common-patterns/saga.mdx # docs/content/docs/v5/cookbook/common-patterns/scheduling.mdx # docs/content/docs/v5/cookbook/common-patterns/sequential-and-parallel.mdx # docs/content/docs/v5/cookbook/common-patterns/timeouts.mdx # docs/content/docs/v5/cookbook/common-patterns/webhooks.mdx # docs/content/docs/v5/cookbook/common-patterns/workflow-composition.mdx # docs/content/docs/v5/cookbook/index.mdx # docs/content/docs/v5/cookbook/integrations/ai-sdk.mdx # docs/content/docs/v5/cookbook/integrations/chat-sdk.mdx # docs/content/docs/v5/cookbook/integrations/sandbox.mdx # docs/next.config.ts # docs/proxy.ts # docs/scripts/lint.ts # pnpm-lock.yaml # pnpm-workspace.yaml
Ordering change: this now targets
maindirectly and should land before#3096, per the review there. #3096 begins emitting sealed (
encp) hook payloadswhile
workflow inspect --decryptcan only build a symmetric key, so merging itfirst would leave those payloads showing as ciphertext placeholders. Landing the
reader first closes that window.
To make this PR stand alone,
deriveRunPayloadKeysmoved here from #3096 — itis the capability the reader needs, and its own doc says to use it "anywhere a
run reads its own event log", so it belongs on this side regardless.
PR 5 of 7. Stacked on #3096; review the stack in order, starting from #3093.
encpsealed-box encryption primitive #3093 — theencpsealed-box primitiveresumeHook()seals instead of fetching the key ← the payoffstart()gets the key from the probe it already awaitsWhy this is required, not optional
Once #3096 lands, a hook resumption from another deployment writes a sealed payload into the run's event log. Observability tooling can't open it:
hydrateDataWithKeyonly ever ran AES-GCM. So the dashboard and CLI would render a 🔒 placeholder on data the user is entitled to read and has already supplied the key for — a visible debugging regression on exactly the runs this work speeds up.What changed
hydrateDataWithKeydelegates to the envelope layer, which dispatches on the 4-byte format prefix, instead of unconditionally decrypting with AES-GCM.All four o11y key-resolution sites now resolve the run's full key capability rather than just its symmetric key:
@workflow/web-sharedhydrateResourceIOAsync@workflow/webuse-stream-reader(hand-rolled frame decode)@workflow/cli--decryptfield hydration@workflow/cli--decryptstream outputEach already had the raw 32 bytes in hand, so this costs one extra key derivation and no additional requests or round trips.
A caller that supplies only a symmetric key still gets the ciphertext placeholder for sealed payloads rather than a decryption error — that key never could have opened them, so surfacing a failure would be misleading.
Browser bundling
The natural import for the new helper is
@workflow/core/serialization, but that module graph reachesnode:utilandnode:async_hooksand cannot be bundled for the browser — which is precisely what the@workflow/core/serialization-formatentrypoint exists to avoid. I hit this as a build failure and moved the key helpers to a re-export from that browser-safe entrypoint; the two browser consumers import from there, while the CLI keeps the direct import since it runs on Node.Verified by walking the built import graph:
serialization-format.jsreaches 6 modules and zero Node built-ins.Tests
Two new cases assert the o11y path opens a real sealed payload with the derived keypair, and that the same resolved key still opens the run's own symmetric payloads — a run's event log mixes both.
Core suite 1598 passing, web-shared 160, CLI 78. Zero new lint diagnostics (diffed against a stashed baseline).