Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/dynamic-workflows-concept-docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Docs: fill gaps in the dynamic workflows concept page: per-gate errors and which are fatal, Vercel storage enablement, the execution-context budget, raw-source hashing, and trusted authoring.
4 changes: 4 additions & 0 deletions .changeset/dynamic-workflows-cookbook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Docs: add a dynamic workflows cookbook recipe for running customer-specific migration procedures published after deploy, backed by e2e fixtures.
45 changes: 36 additions & 9 deletions docs/content/docs/v5/advanced/dynamic-workflows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,18 @@ console.log(await run.status); // 'running'

Only the *orchestration* is dynamic. Every step the source calls was deployed with your app, and `experimental_dynamic.steps` names the ones it calls by alias. That map does not stop source from reaching other steps; see [Security](#security). There is no way to define a new step from source.

`start()` can be called from any server code in the deployment, including a route handler, with no wrapper workflow. Pass the **imported** step functions: the build stamps a `stepId` on each one, and that ID is what binds the alias to a deployed step. Code without a handle on the function, such as a script outside the deployment, has to pass `{ stepId: '...' }` references instead.

For a complete application of this, see the [Dynamic Workflows recipe](/cookbook/advanced/dynamic-workflows): a migration step catalog deployed once, and customer-specific procedures written, reviewed, and started after deploy.

## When to use this

Reach for dynamic workflows when the **shape** of the orchestration is only known after you deploy, and the source comes from code you trust as much as your own:

- **Orchestration your application assembles** from reviewed templates, over a fixed set of deployed steps.
- **Orchestration published after deploy**, such as reviewed workflows your operators add to a store, over a fixed set of deployed steps.
- **Experiments** — try a new composition of existing steps without shipping a build.

Dynamic workflows are not a way to run code written by your end users or generated by a model from their input. That source would run with your deployment's privileges; see [Security](#security).
Dynamic workflows execute trusted application code, including code authored by operators or drafted by a model and approved under your application-code review process. Do not execute end-user scripts, or unreviewed model-generated source influenced by untrusted requests, retrieved content, or tool outputs. That source would run with your deployment's privileges, and a fixed step catalog, syntax validation, and a dedicated project do not make it safe; see [Security](#security).

If your workflows are known at build time, use a normal workflow function. It has better types, better errors, no source validation, and no size limits.

Expand All @@ -73,14 +77,29 @@ WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS=1

Only `1` or `true` (case-insensitive) enables them; any other value, or no value, leaves them off. The runtime reads the variable where workflows execute, when it needs it, so set it on the deployment or dev server rather than at build time. It controls three things:

- **Starting.** `start()` with source throws before it contacts the World, creates a run, or enqueues anything unless this process has opted in.
- **Delivery.** When a dynamic run reaches a deployment that has not opted in, the runtime does not execute its stored code. It fails the run with a `RUNTIME_ERROR` rather than retrying it.
- **Starting.** `start()` with source throws before it contacts the World, creates a run, or enqueues anything unless this process has opted in. Source validation runs first, so a malformed source reports its validation error even when the opt-in is off.
- **Delivery.** When a dynamic run reaches a deployment that has not opted in, the runtime does not execute its stored code. It fails the run with a `RUNTIME_ERROR` rather than retrying it. This covers a run that was started while the deployment was opted in and wakes after the variable is removed.
- **Health check.** A deployment advertises dynamic support in its [health check](/docs/api-reference/workflow-runtime/health-check) only when it has opted in.

### Same deployment only

A dynamic run must execute on the deployment that started it. `start()` rejects a dynamic start whose target differs from the current deployment. That includes an explicit `deploymentId` for another deployment, `deploymentId: 'latest'` when it resolves to a different deployment, and any concrete target when the current deployment cannot be determined. The rejection happens before any capability check, key lookup, upload, run creation, or queue message.

### Errors for each gate

Each gate fails with a message that names it. Except at delivery, where the run itself fails, `start()` throws: a `WorkflowRuntimeError` for the checks it makes before writing anything, including the World's execution-context limit, and the World's own error when the backend refuses the run.

| Gate | Where it fails | Message |
| --- | --- | --- |
| Opt-in, at start | `start()` | `Dynamic workflows are disabled on this deployment, so no run was created. Set WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS=1 on the deployment to enable them.` |
| Opt-in, at delivery | The run, with `RUNTIME_ERROR` | `Workflow run "<runId>" is a dynamic workflow run (source <hash prefix>), but this deployment has not enabled dynamic workflows, so its stored code was not executed. Set WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS=1 on the deployment to enable them.` |
| Same deployment | `start()` | `Dynamic workflows can only start on the current deployment. This start targets "<target>" from "<current>", so no run was created.` |
| World capability | `start()` | ``Dynamic workflows require a World that declares `capabilities.dynamicWorkflowCode`. This World does not, so no run was created.`` |
| Execution-context limit (Vercel) | `start()` | `Dynamic workflow execution context is <n> bytes, exceeding the 2048-byte limit, so no run was created.`, followed by guidance on what counts |
| Project storage (Vercel) | `start()`, from the backend | A `WorkflowWorldError` with `status: 400`, `code: 'dynamic-workflow-storage-disabled'`, and the message `dynamic workflow storage is not enabled for this project`. See [World support](#world-support). |

The `WorkflowRuntimeError` refusals, like the [source validation](#rules-for-the-source) errors, are marked fatal: `FatalError.is()` returns `true` for them, so a step that calls `start()` fails on the first attempt instead of spending its retries. A same-deployment refusal is fatal only when the current deployment is known. When it cannot be determined, the message reads `from an unknown current deployment` and the error stays retryable, because the lookup can succeed on a retry. When workflow code calls `start()` directly, `start()` runs as its own step: that step still fails without retrying, but the error the workflow catches does not carry the fatal marker.

## What the source can use

Dynamic source has no imports. Instead, the generated code predefines a small runtime surface:
Expand Down Expand Up @@ -146,7 +165,7 @@ Supply a unique, deterministic approval token from the caller. The workflow must
- JavaScript only — no TypeScript syntax, no npm dependencies, no bundling.
- No inline `"use step"` functions. Steps come from `experimental_dynamic.steps`.
- At most 128 KB of source.
- On Vercel, the run's execution context is limited to 2,048 bytes of JSON, and the `dynamicWorkflow` metadata below counts against it. That leaves room for roughly 30 step aliases, depending on how long the aliases and step IDs are. A start that exceeds it fails before anything is written.
- On Vercel, the run's execution context is limited to **2,048 bytes of JSON**, and the `dynamicWorkflow` metadata below counts against it: the source hash, the export name, and every alias together with its full step ID. A step ID comes from the step's file path and function name, so it usually costs more than its alias, and a step in a deeply nested file costs more than one near the root. Treat any alias count as an estimate: in one measurement, 36 aliases bound to 36-character step IDs fit and 37 did not, while with 80-character step IDs about 19 fit. A start that exceeds the limit fails before anything is written. The Local World does not enforce this limit, so check a definition with many bindings on a Vercel preview.

Everything a static workflow must obey still applies: the body has to be [deterministic](/docs/foundations/workflows-and-steps), and any side effect belongs in a step.

Expand All @@ -163,7 +182,7 @@ Two consequences worth knowing:
- **The same definition always gets the same ID.** Runs of one generated workflow group together in [observability](/docs/observability) and share a queue topic, even across processes.
- **A caller cannot claim an ID.** Because the hash covers the source *and* the step bindings, arbitrary source cannot be made to run under a static workflow's name — or under another definition's.

Changing the source, or pointing an alias at a different step, produces a different workflow.
Changing the source, or pointing an alias at a different step, produces a different workflow. The hash covers the raw source bytes, so whitespace, formatting, and comment changes also produce a new workflow ID, and runs before and after the change group separately. When you need a name that stays stable across revisions of a definition, keep it in your application, alongside the exact source you started; see the [recipe](/cookbook/advanced/dynamic-workflows#procedure-revisions).

## How the code is stored

Expand All @@ -190,21 +209,29 @@ Dynamic workflows need a World that can store the run's workflow code.

| World | Support |
| --- | --- |
| [Vercel](/worlds/vercel) | Encrypted, ref-backed storage (small definitions transported inline; large definitions uploaded first). If dynamic-source storage is not enabled for the project, the backend rejects the run's creation and `start()` throws. |
| [Vercel](/worlds/vercel) | Encrypted, ref-backed storage (small definitions transported inline; large definitions uploaded first). Dynamic-source storage must be enabled for the project; see below. |
| [Local](/worlds/local) | Stored in plaintext on the run record in the local filesystem store. |
| [Postgres](/worlds/postgres) | Stored in plaintext on the run row. |
| Others | Supported when the World declares [`capabilities.dynamicWorkflowCode`](/worlds/building-a-world). |

After the opt-in and same-deployment checks, `start()` fails a dynamic start on a World that does not declare `capabilities.dynamicWorkflowCode`. On Vercel, `start()` then validates the final execution context against the 2,048-byte limit. All of this happens before serializing or uploading code, creating an event, or publishing a queue message.

A dynamic start then waits for `run_created` and confirms that the backend stored the code before it publishes the run's queue message. Any failure to create the run, retryable or not, reaches the caller with nothing published: unlike a static start, there is no resilient start that queues the run and retries its creation later. A backend that accepts the run without storing its code makes `start()` throw a message beginning `Workflow run <runId> was created, but this deployment's Workflow backend did not store its dynamic workflow code`; that run stays `pending` and is never queued.

### Enabling storage on Vercel

On Vercel, dynamic-source storage is enabled per project by a server-side allowlist. There is no project setting for it yet; contact the Workflow team to have a project enabled. Until it is, the backend refuses the run's creation and `start()` throws a `WorkflowWorldError` with `status: 400`, `code: 'dynamic-workflow-storage-disabled'`, and the message `dynamic workflow storage is not enabled for this project`. Nothing is queued, so retrying the start does not help until the project is enabled.

## Security

<Callout type="warning">
Dynamic source is **trusted application code** with the full privileges of your deployment's functions. The workflow VM is a determinism sandbox, not a security sandbox. Code in it can reach the host process: it can read every environment variable, use the network and the filesystem, and call any step registered in the deployment with any arguments.
</Callout>

- **`steps` is not a boundary.** The `steps` object contains only the aliases you passed and is frozen, so ordinary code that calls `steps.somethingElse()` fails the run instead of dispatching a step it was not given. Code that is trying to reach other steps, or the host, can.
- **Only start source you would merge.** Do not build source from end-user input, and do not run model output generated from untrusted input. Either one gives whoever controls that input your deployment's privileges.
- **The host is reachable.** Source can read `process.env`, reach host globals, call the World with the deployment's credentials (which reach other runs' hooks and streams), and alter state in a warm instance that later requests reuse.
- **Only start reviewed source.** Source authored by operators or drafted by a model is fine once it is approved under your application-code review process. Do not execute end-user scripts, or unreviewed model-generated source influenced by untrusted requests, retrieved content, or tool outputs; either one gives whoever controls that input your deployment's privileges. A fixed step catalog and syntax validation do not change that.
- **A dedicated project is defense in depth, not a boundary.** Running dynamic workflows from their own Vercel project, with only the environment variables and access its steps need, limits what a mistake in reviewed code can reach. It does not make unreviewed source safe to run.
- **Opting in is a deployment decision.** A deployment that sets `WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS` executes stored code for any dynamic run it receives. Anyone who can start a workflow on it can run code with its privileges.
- **Encryption gives confidentiality only.** Where the World encrypts stored code, it cannot be read at rest without the run's key, and a delivery refuses code that is not encrypted with that key. Anyone who can obtain the run's key can still write valid code, so encryption does not replace the opt-in.
- **Plaintext Worlds turn storage write access into code execution.** The Local and Postgres Worlds store the code in plaintext. On an opted-in deployment, anyone who can write to the Postgres database or the local data directory can make every worker execute code of their choosing.
Expand All @@ -223,5 +250,5 @@ Treat dynamic source the way you would treat code in a pull request: written or
- No caller-provided workflow IDs.
- No **Replay Run** from the observability UI.
- Parser-based validation checks JavaScript syntax and the required source/wrapper shape without executing it. It does not validate behavior, determinism, or intent.
- On Vercel, roughly 30 step aliases fit the 2,048-byte execution-context limit.
- On Vercel, the run's execution context is limited to 2,048 bytes of JSON, which bounds how many step aliases fit; the count depends on step-ID length.
- Requires a World with dynamic-source storage.
Loading
Loading