Skip to content

feat(core): widen retained boundaries to plain data and standard built-ins#3047

Draft
NathanColosimo wants to merge 14 commits into
nathanc/retained-vm-corefrom
nathanc/retained-vm-passivity
Draft

feat(core): widen retained boundaries to plain data and standard built-ins#3047
NathanColosimo wants to merge 14 commits into
nathanc/retained-vm-corefrom
nathanc/retained-vm-passivity

Conversation

@NathanColosimo

@NathanColosimo NathanColosimo commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Third of the 3-PR stack from #2990 — replaces #3046's primitives-only retention gate with taint-reporting hardened serialization, so real-world step arguments keep the fast path without breaking polyfills:

  • Devalue gains pluggable stringify operations (sveltejs/devalue#172, applied to devalue@5.8.1 via pnpm patch): every primitive read stringify performs — typeof, @@toStringTag, property gets, Map/Set iteration, typed-array introspection — routes through an overridable operations object. Default behavior is unchanged; a byte-stability corpus test proves it (see below).
  • Hardened operations + passive reducers (serialization/operations.ts): serialization reads values through host intrinsics captured at module load (Map.prototype.entries, Date getters, RegExp flags, URL.href, typed-array internal-slot getters, the V8 Error.stack accessor, …), brand checks via util.types/@@toStringTag instead of instanceof, and passiveGet for property access. Patching a built-in prototype in the workflow realm — Temporal's Date.prototype.toTemporalInstant, core-js Set.prototype.union, even replacing Date.prototype.toISOString — never executes and never affects the serialized bytes.
  • Taint flag instead of a pre-walk: when serialization does have to execute workflow code — own accessors (getters), proxy traps, custom [WORKFLOW_SERIALIZE] class serializers, step-function closure capture, thenable probes — it records the reason on a per-boundary SerializationPassivityReport. The suspension handler de-opts only tainted boundaries to ordinary replay; everything else retains.
  • Why this is safe (one invariant): step-argument serialization runs once at suspension and never re-runs during replay, so its only hazard is executing workflow code whose side effects live on in the retained VM. There is a single serialization path shared by all modes, so bytes cannot differ by mode; the taint flag is a ground-truth record of whether workflow code ran, not a prediction.

This deletes the previous approach on this branch (the descriptor-walking retained-step-input.ts pre-verifier and its pin registry, ~1,400 lines) in favor of ~500 lines that instrument the single real serialization path.

Wire-format guarantee

serialization/byte-stability.test.ts locks a 100+-value corpus (primitives, built-ins, errors, workflow types, getter/proxy values) to a committed snapshot recorded before this rewrite: the hardened operations produce byte-identical devalue output to stock stringify for every corpus value. Replays of pre-existing runs are unaffected.

Review focus

serialization/operations.ts (the captured intrinsics and the taint points) and the reducer rewrite in serialization/reducers/common.ts — every read there answers "can this dispatch into workflow code?". The suspension-handler wiring is small; the loop and state machine were reviewed in #3046, sandbox semantics in #3045.

Validation

Full core suite green in both modes (default and WORKFLOW_RETAINED_VM=0): 75 files, 1,582 passed, 3 expected failures. Coverage: operations unit tests (taint matrices, byte-compat with stock devalue, patched-prototype immunity, report scoping), suspension-handler taint gating, and loop-level tests proving getter/proxy/custom-serializer args demote (vmBuilds > 1) while built-ins — including a patched Date.prototype.toISOString — retain (vmBuilds === 1).

Docs Preview

Page Link
Runtime tuning — WORKFLOW_RETAINED_VM /v5/docs/configuration/runtime-tuning#workflow_retained_vm

Stack: #3045#3046 → this. #2990 stays open as the reference implementation.

@NathanColosimo
NathanColosimo requested review from a team and ijjk as code owners July 22, 2026 02:37
@changeset-bot

changeset-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a272681

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

This PR includes changesets to release 16 packages
Name Type
@workflow/core Patch
workflow Patch
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
@workflow/world-testing Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/nuxt Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview, Comment Jul 24, 2026 6:41am
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jul 24, 2026 6:41am
example-workflow Ready Ready Preview, Comment Jul 24, 2026 6:41am
workbench-astro-workflow Ready Ready Preview, Comment Jul 24, 2026 6:41am
workbench-express-workflow Ready Ready Preview, Comment Jul 24, 2026 6:41am
workbench-fastify-workflow Ready Ready Preview, Comment Jul 24, 2026 6:41am
workbench-hono-workflow Ready Ready Preview, Comment Jul 24, 2026 6:41am
workbench-nestjs-workflow Ready Ready Preview, Comment Jul 24, 2026 6:41am
workbench-nitro-workflow Ready Ready Preview, Comment Jul 24, 2026 6:41am
workbench-nuxt-workflow Ready Ready Preview, Comment Jul 24, 2026 6:41am
workbench-sveltekit-workflow Ready Ready Preview, Comment Jul 24, 2026 6:41am
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jul 24, 2026 6:41am
workbench-vite-workflow Ready Ready Preview, Comment Jul 24, 2026 6:41am
workflow-docs Ready Ready Preview, Comment, Open in v0 Jul 24, 2026 6:41am
workflow-swc-playground Ready Ready Preview, Comment Jul 24, 2026 6:41am
workflow-tarballs Ready Ready Preview, Comment Jul 24, 2026 6:41am
workflow-web Ready Ready Preview, Comment Jul 24, 2026 6:41am

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

Passed Failed Skipped Total
✅ ▲ Vercel Production 1455 0 239 1694
✅ 💻 Local Development 1621 0 227 1848
✅ 📦 Local Production 1621 0 227 1848
✅ 🐘 Local Postgres 1621 0 227 1848
✅ 🪟 Windows 154 0 0 154
✅ 📋 Other 1020 0 212 1232
✅ vercel-multi-region 27 0 0 27
Total 7519 0 1132 8651

Details by Category

✅ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 126 0 28
✅ example 126 0 28
✅ express 126 0 28
✅ fastify 126 0 28
✅ hono 126 0 28
✅ nextjs-turbopack 151 0 3
✅ nextjs-webpack 151 0 3
✅ nitro 126 0 28
✅ nuxt 126 0 28
✅ sveltekit 145 0 9
✅ vite 126 0 28
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 154 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 128 0 26
✅ e2e-local-dev-tanstack-start- 128 0 26
✅ e2e-local-postgres-nest-stable 128 0 26
✅ e2e-local-postgres-tanstack-start- 128 0 26
✅ e2e-local-prod-nest-stable 128 0 26
✅ e2e-local-prod-tanstack-start- 128 0 26
✅ e2e-vercel-prod-nest 126 0 28
✅ e2e-vercel-prod-tanstack-start 126 0 28
✅ vercel-multi-region
App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@NathanColosimo
NathanColosimo marked this pull request as draft July 22, 2026 05:15
@NathanColosimo
NathanColosimo force-pushed the nathanc/retained-vm-core branch from fda3612 to df5c2bc Compare July 22, 2026 17:47
@NathanColosimo
NathanColosimo force-pushed the nathanc/retained-vm-passivity branch from 6590434 to 1695747 Compare July 22, 2026 18:10
Applies the runtime changes from sveltejs/devalue#172 (head 2e97724)
via pnpm patch, adapted to 5.8.1's merged TypedArray/DataView case so
encoded output stays byte-identical. Enables injecting hardened,
side-effect-free introspection operations into stringify.
…ivity

# Conflicts:
#	packages/core/src/workflow.ts
Pins the exact devalue strings the workflow→step boundary produces for a
representative corpus, recorded from the serialization code before the
hardened stringify operations. The follow-up hardening commit must keep
every byte identical.
…lization

Serialization now runs through devalue's pluggable stringify operations
(patched in via devalue#172): reducers and operations read values with
captured host intrinsics and passive property access, and report when they
had to execute workflow code (getters, proxies, custom serializers,
step-function closures). The suspension handler de-opts a boundary to
ordinary replay only when that report is tainted, replacing the standalone
pre-serialization walker and its pin registry.
- RegExp flags: the spec'd `flags` getter does ordinary Gets of the per-flag
  properties, so an own flag getter could execute untainted. Compose the
  flags string from the individual internal-slot getters instead
  (byte-identical output).
- Error stacks: V8's lazy stack getter executes `Error.prepareStackTrace`
  when a realm replaced it. Capture each realm's pristine Error intrinsics
  at VM-context creation (never constructing an untrusted `global.Error`
  later) and taint stack reads when the realm's prepareStackTrace changed.
- Report scope: reducer construction resolves prototypes off the workflow
  global before stringify activates the passivity report; run it inside the
  report scope so a getter or proxy on a global constructor taints.
- Vendor devalue 5.8.1 + the pending operations interface (sveltejs/devalue#172)
  into @workflow/core, replacing the pnpm patch: patchedDependencies don't ship
  with published packages, so consumers would have silently received a stock
  devalue that ignores the hardened operations.
- Stop capturing URLSearchParams.prototype.size (absent on older Node 18,
  crashed at module load); derive emptiness from the captured toString output,
  which is equivalent to size === 0.
- Read all AbortController/AbortSignal serialization fields passively via
  captured native accessors (signal/aborted/reason), so native holders don't
  taint retention and patched accessors do.

@vercel vercel Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional Suggestion:

3 tests in packages/core/src/serialization/serialization.test.ts call require('devalue'), but devalue was removed as a dependency and vendored, so these require calls fail with Cannot find module 'devalue'.

Fix on Vercel

…spoof fallbacks

- Ship the vendored devalue MIT LICENSE (and provenance README) in the npm
  package by copying them into dist/vendor/devalue at build time.
- Taint Proxies encountered while walking prototype chains: in
  isInstanceOfPrototype, in a new objectShape wrapper (the default reads the
  prototype of plain candidates), and in a new passiveHas used for the
  'cause' / '__boundThis' presence checks that previously used `in`.
- Brand-check the tag-dispatched operations (dateISO, regExp, setValues,
  mapEntries, viewInfo): tag-spoofed / polyfilled built-ins now taint and
  fall back to the stock devalue behavior instead of throwing a brand-check
  TypeError that stock devalue never threw.
…fer taints, test devalue imports

- ops.get now reads through a Proxy's get trap (tainted), matching stock
  devalue bytes instead of silently serializing the target descriptor value.
- objectShape skips the prototype probe for Proxy values so their
  getPrototypeOf trap is not run an extra time relative to stock devalue.
- arrayLength / arrayBuffer taint tag-spoofed values before the serializer
  coerces them (valueOf on an object-valued length, base64 coercion).
- serialization/serialization.test.ts no longer requires the removed
  'devalue' npm package; it imports the vendored copy.
…Params emptiness

Prototype methods captured at module load can already be patched by code
that ran before @workflow/core was imported, and would then be invoked as
"passive" forever. Capture ECMAScript intrinsics (and the statics the
passive helpers dispatch) from a freshly created node:vm realm instead;
host web APIs stay module-load captures with the residual trust documented.

Also derive URLSearchParams emptiness from the captured native toString
before any dynamic dispatch, so an overridden toString can neither run
untainted on empty params nor change which values serialize as empty.
…rchParams conversion getters

The engine resolves Error.prepareStackTrace with an ordinary Get on the
realm's Error constructor, so a formatter inherited from the realm's
Function.prototype must also read as "replaced" (walk own descriptors up
the constructor chain; accessors/proxies resolve to a never-matching
sentinel so stack reads taint).

The URLSearchParams reducer now selects its native fast path with a
descriptor-level peek (passivePeek) that performs no reads, so a stateful
conversion getter fires exactly once, from the single String(value)
dispatch, like it did pre-hardening.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant