-
Notifications
You must be signed in to change notification settings - Fork 187
Add Encryptor interface and thread through serialization layer #979
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: nate/async-serde
Are you sure you want to change the base?
Conversation
🧪 E2E Test Results
⏳ Tests are running... Started at: 2026-02-09T23:19:18Z ❌ Some tests failed Summary
❌ Failed Tests🌍 Community Worlds (160 failed)mongodb (37 failed):
redis (40 failed):
starter (42 failed):
turso (41 failed):
Details by Category✅ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
❌ 🌍 Community Worlds
✅ 📋 Other
|
🦋 Changeset detectedLatest commit: 377d3b7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 19 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 |
|
Warning This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
pranaygp
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Review: PR #979 - Add Encryptor interface and thread through serialization layer
Summary: Adds the Encryptor, EncryptionContext, and KeyMaterial interfaces to @workflow/world, makes World extend Encryptor, and threads the encryptor parameter through all serialization functions. This is a no-op refactor -- the encryptor parameter is unused (_encryptor) throughout.
Strengths:
- Clean interface design:
Encryptorhas all-optional methods, so existingWorldimplementations don't break EncryptionContextis minimal (justrunId) -- good for forward compatibilityKeyMaterialinterface for o11y tooling is a thoughtful addition- The
getEncryptorForRun()method onWorldis a well-designed escape hatch for cross-deployment encryption (e.g.,resumeHook()from newer deployment) getHookByTokenWithEncryptor()resolves the encryptor once and reuses it -- avoids redundant key resolution
Concerns:
-
resolveEncryptorForRuntype safety: Inresume-hook.tsline 29-31,getEncryptorForRunis accessed via(world as any).getEncryptorForRun. SinceWorldalready extendsEncryptorandgetEncryptorForRunis defined onWorld, you should be able to use optional chaining directly:world.getEncryptorForRun?.(runId). The'getEncryptorForRun' in world+as anypattern bypasses type checking unnecessarily. -
Serialization parameter ordering: The PR reorders parameters in the dehydrate/hydrate functions. For example,
dehydrateWorkflowArgumentsgoes from(value, ops, runId, ...)to(value, runId, encryptor, ops, ...). This is a breaking change to the internal API. While these aren't public, any external code calling these directly would break. The reorder makes sense semantically (runId + encryptor are conceptually paired), but consider documenting this in the changeset. -
_encryptorunused parameter pattern: All 8 functions have_encryptor: Encryptorthat is unused. This is expected since the actual wiring happens in #957. However, this means if #979 lands but #957 doesn't (or is delayed), there's dead parameter threading throughout the codebase. A minor code smell but acceptable for a PR stack. -
hydrateResourceIOnow requiresencryptor: Inobservability.ts,hydrateResourceIOnow takes anEncryptorparameter, and all callers passworld. This means the observability layer now has a dependency on the World instance. Previously it was a pure data transformation. This is a reasonable tradeoff for encryption support, but worth noting the coupling increase.
Overall, well-structured interface design. The cross-deployment encryption support via getEncryptorForRun shows good foresight for production scenarios.
TooTallNate
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the thorough review @pranaygp! Addressing each concern:
-
resolveEncryptorForRuntype safety — Fixed. Now uses optional chaining:return (await world.getEncryptorForRun?.(runId)) ?? world; -
Serialization parameter ordering — Good call. Updated the changeset to document this as a breaking change to the internal API, noting the reorder and that these are not intended for external consumption.
-
_encryptorunused parameter pattern — Agreed this is a minor smell. It's intentional for the PR stack — #957 (wire encryption) removes the underscores and actually uses the parameter. If that PR is delayed, the unused params are harmless and serve as documentation of the intended API surface. -
hydrateResourceIOnow requiresencryptor— Fair point about the coupling increase. Previously it was pure data transformation; now it depends on anEncryptorinstance. In practice, the CLI and web callers already haveworldin scope (they need it forworld.runs.get()etc.), so the additional parameter doesn't introduce a new dependency — it just makes an existing one explicit. For the common case where no encryption is configured,{}satisfiesEncryptorsince all methods are optional.
There was a problem hiding this comment.
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 introduces a new Encryptor interface in @workflow/world and threads an encryptor + runId through the core serialization/hydration APIs and runtime plumbing, laying groundwork for future at-rest / E2E encryption (without enabling encryption yet).
Changes:
- Add
Encryptor,EncryptionContext, andKeyMaterialto@workflow/world, and extendWorldwith optionalgetEncryptorForRun(runId). - Update core serialization/hydration function signatures (and call sites) to accept
runId+encryptor. - Propagate the encryptor through runtime flows, including
runWorkflow(), step/hook hydration, andresumeHook()(resolving per-run encryptors once).
Reviewed changes
Copilot reviewed 29 out of 29 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| workbench/nextjs-webpack/pages/api/trigger-pages.ts | Update hydrateWorkflowArguments call to new (runId, encryptor, ...) signature. |
| workbench/nextjs-turbopack/pages/api/trigger-pages.ts | Same signature update for turbopack workbench. |
| packages/world/src/interfaces.ts | Add Encryptor/EncryptionContext/KeyMaterial; extend World and add getEncryptorForRun?. |
| packages/world-testing/src/null-byte.mts | Update hydration calls to pass (runId, encryptor). |
| packages/world-testing/src/idempotency.mts | Update hydration calls to pass (runId, encryptor). |
| packages/world-testing/src/hooks.mts | Update hydration calls to pass (runId, encryptor). |
| packages/world-testing/src/errors.mts | Update hydration calls to pass (runId, encryptor). |
| packages/world-testing/src/addition.mts | Update hydration calls to pass (runId, encryptor). |
| packages/web/src/server/workflow-server-actions.ts | Thread world into hydrateResourceIO for decrypt context. |
| packages/core/src/workflow/hook.ts | Pass ctx.runId/ctx.encryptor into step return value hydration. |
| packages/core/src/workflow/hook.test.ts | Add runId/encryptor to context + update serialization calls. |
| packages/core/src/workflow.ts | Add encryptor param to runWorkflow and pass through to (de)hydration. |
| packages/core/src/workflow.test.ts | Update tests for new serialization signatures (but one callsite is still incorrect). |
| packages/core/src/step.ts | Pass ctx.runId/ctx.encryptor into step result hydration. |
| packages/core/src/step.test.ts | Add runId/encryptor to context + update serialization calls. |
| packages/core/src/serialization.ts | Add Encryptor parameter to all 8 dehydrate/hydrate functions and reorder parameters. |
| packages/core/src/serialization.test.ts | Update test calls to new signatures across serialization suite. |
| packages/core/src/runtime/suspension-handler.ts | Thread runId + world (as encryptor) into step argument dehydration. |
| packages/core/src/runtime/step-handler.ts | Thread world (as encryptor) into step argument hydration + return dehydration. |
| packages/core/src/runtime/start.ts | Pass (runId, world) into dehydrateWorkflowArguments. |
| packages/core/src/runtime/runs.ts | Pass (runId, {}) into hydrateWorkflowArguments (placeholder encryptor). |
| packages/core/src/runtime/run.ts | Pass (runId, world) into hydrateWorkflowReturnValue. |
| packages/core/src/runtime/resume-hook.ts | Resolve per-run encryptor once and reuse for metadata hydration + payload dehydration. |
| packages/core/src/runtime.ts | Pass world into runWorkflow as encryptor. |
| packages/core/src/private.ts | Extend WorkflowOrchestratorContext with runId and encryptor. |
| packages/core/src/observability.ts | Require encryptor for hydrateResourceIO and thread into hydration helpers. |
| packages/core/src/observability.test.ts | Update hydrateResourceIO calls to provide encryptor. |
| packages/cli/src/lib/inspect/output.ts | Pass world into hydrateResourceIO for decrypt context. |
| .changeset/encryptor-interface.md | Changeset describing interface/signature threading for future encryption. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| workflowRun, | ||
| events, | ||
| mockEncryptor | ||
| )) as any, |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hydrateWorkflowReturnValue signature now expects (value, runId, encryptor, ops?, ...), but this test still passes ops as the second argument. This will fail typechecking and call hydration with the wrong parameters; pass the workflow runId and encryptor before ops.
| )) as any, | |
| )) as any, | |
| 'wrun_test', | |
| mockEncryptor, |
| input: await dehydrateWorkflowArguments( | ||
| [], | ||
| 'wrun_test', | ||
| mockEncryptor, | ||
| ops | ||
| ), |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These tests pass a hard-coded runId ('wrun_test') into dehydrateWorkflowArguments, even though workflowRun.runId is 'wrun_123'. Since runId is intended for per-run derivation (and is already used for stream serialization), the runId passed into (de)serialization should match the run being created to avoid future encryption/stream bugs.
| "@workflow/core": patch | ||
| "@workflow/world": patch | ||
| "@workflow/cli": patch | ||
| "@workflow/web": patch | ||
| "@workflow/world-testing": patch |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This changeset marks @workflow/core/@workflow/cli/@workflow/web as patch, but this PR changes public exported API signatures (e.g. @workflow/core/serialization and @workflow/core/observability) by adding new required parameters. That is a breaking change for downstream consumers; either make the new parameters optional/backwards-compatible (overloads/defaults) or bump to a minor/major release accordingly.
| "@workflow/core": patch | |
| "@workflow/world": patch | |
| "@workflow/cli": patch | |
| "@workflow/web": patch | |
| "@workflow/world-testing": patch | |
| "@workflow/core": minor | |
| "@workflow/world": minor | |
| "@workflow/cli": minor | |
| "@workflow/web": minor | |
| "@workflow/world-testing": minor |
| /** | ||
| * Called from the `start()` function to serialize the workflow arguments | ||
| * into a format that can be saved to the database and then hydrated from | ||
| * within the workflow execution environment. | ||
| * | ||
| * @param value | ||
| * @param global | ||
| * @param runId | ||
| * @returns The dehydrated value as binary data (Uint8Array) with format prefix | ||
| */ | ||
| export async function dehydrateWorkflowArguments( | ||
| value: unknown, | ||
| ops: Promise<void>[], | ||
| runId: string, | ||
| _encryptor: Encryptor, | ||
| ops: Promise<void>[] = [], | ||
| global: Record<string, any> = globalThis, | ||
| v1Compat = false |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
dehydrateWorkflowArguments now requires runId and Encryptor, but the JSDoc @param list above still describes the old parameter order (e.g. mentions global before runId) and does not mention the new encryptor argument. Please update the doc comment to match the new signature (or keep the old call shape via overloads/defaults).
| }, | ||
| >( | ||
| resource: T | ||
| resource: T, | ||
| encryptor: Encryptor | ||
| ): Promise<T> => { |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hydrateResourceIO is exported from @workflow/core/observability, and changing it to require an encryptor argument is a breaking API change. Consider keeping backward compatibility by making encryptor optional with a safe default (e.g. {}) or by adding an overload that preserves the previous single-argument call signature.
| if ( | ||
| 'getEncryptorForRun' in world && | ||
| typeof (world as any).getEncryptorForRun === 'function' | ||
| ) { | ||
| return await (world as any).getEncryptorForRun(runId); | ||
| } | ||
| return world; |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
resolveEncryptorForRun is doing runtime 'getEncryptorForRun' in world checks and any casts even though World.getEncryptorForRun? is now part of the type. Prefer const encryptor = await world.getEncryptorForRun?.(runId) ?? world; to avoid any and keep the implementation type-safe.
| if ( | |
| 'getEncryptorForRun' in world && | |
| typeof (world as any).getEncryptorForRun === 'function' | |
| ) { | |
| return await (world as any).getEncryptorForRun(runId); | |
| } | |
| return world; | |
| const encryptor = (await world.getEncryptorForRun?.(runId)) ?? world; | |
| return encryptor; |
| export async function resumeHook<T = any>( | ||
| tokenOrHook: string | Hook, | ||
| payload: T | ||
| payload: T, | ||
| _encryptor?: Encryptor | ||
| ): Promise<Hook> { |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The parameter is named _encryptor, but it is actually used to override the resolved encryptor. The underscore convention typically indicates an intentionally-unused parameter; rename this to something like encryptor/encryptorOverride to avoid confusion.
814d64c to
702c96d
Compare
4cc267f to
64be97d
Compare
702c96d to
7f8582e
Compare
64be97d to
377d3b7
Compare

Summary
Adds the
Encryptorinterface and threads it through the serialization layer, preparing the codebase for E2E encryption without yet wiring in any actual encryption logic.New types (
@workflow/world)Encryptor— optionalencrypt(),decrypt(), andgetKeyMaterial()methodsEncryptionContext— containsrunIdfor per-run key derivationKeyMaterial— key bytes + derivation metadata for external tooling (o11y)Worldnow extendsEncryptor— all methods optional, so existing implementations are unaffectedWorld.getEncryptorForRun?(runId)— resolves anEncryptorfor a specific run's deployment context, enabling cross-deployment encryption (e.g.,resumeHook()from a newer deployment)Serialization signature changes
All 8 dehydrate/hydrate functions gain
encryptor: Encryptoras a new parameter (prefixed with_since it's unused in this PR):dehydrateWorkflowArguments(value, ops, runId, ...)dehydrateWorkflowArguments(value, runId, encryptor, ops, ...)hydrateWorkflowArguments(value, global, ...)hydrateWorkflowArguments(value, runId, encryptor, global, ...)dehydrateStepReturnValue(value, ops, runId, ...)dehydrateStepReturnValue(value, runId, encryptor, ops, ...)Runtime changes
WorkflowOrchestratorContext— addsrunId: stringandencryptor: EncryptorrunWorkflow()— acceptsencryptoras 4th parameterresumeHook()— restructured withgetHookByTokenWithEncryptor()to resolve the encryptor once and reuse for both metadata decryption and payload encryption (zero redundant key resolutions)hydrateResourceIO()— acceptsencryptorparameter, threaded from CLI/web callersCross-deployment design
When
resumeHook()is called from a different deployment than the target workflow run, the encryption keys differ. The newWorld.getEncryptorForRun(runId)method allows the World implementation to resolve the correct key (e.g., via an authenticated API endpoint). TheresumeHookflow resolves the encryptor once viagetHookByTokenWithEncryptor()and reuses it for both operations.Test plan
All 305 core tests pass. Build succeeds. The
encryptorparameter is unused (_encryptor) in this PR — actual encryption is wired in PR #957.