Execution plan
pipeline.ts is not what runs. Running it once — the plan pass — produces a JSON
document, and everything after that consumes only the JSON. That document is the
execution plan, or IR. See plan time vs exec time for why it works
that way; this page is the field list.
Print yours with:
flow explain --json
Versioning
export const IR_VERSION = 2;
The version bumps only on a breaking change — a removed or renamed field, or changed
semantics. Optional additive fields don't bump it. v2 added the third step kind
(write), which is breaking because a v1-only runtime cannot execute one.
A reader rejects a plan only for being newer than it understands:
plan version 3 is newer than supported (2) — produced by a newer FlowWright
There is no lower bound. An older plan is accepted if its shape still validates.
ExecutionPlan
| Field | Type | Notes |
|---|---|---|
version | 2 | Required |
name | string | Required, non-empty |
stages | IRStage[] | Required |
concurrency | number? | Positive integer. Absent or 1 = strictly sequential |
post | object? | { always?, success?, failure? }, each an IRStage |
post handlers are synthetic stages that reuse the stage machinery but sit outside
the needs graph — their needs must be [], and the validator enforces it. always
runs on every outcome; success and failure only on theirs.
IRStage
| Field | Type | Notes |
|---|---|---|
id | string | Unique in the plan, matches ^[a-z0-9][a-z0-9-]*$ |
name | string | Human label; the id is the slug of it unless set explicitly |
needs | string[] | Stage ids. [] = no dependencies. Must be acyclic |
container | string? | Image for this stage's steps. Absent = run on the host |
services | IRService[]? | Sidecars. Absent, not [], when none |
credentials | IRCredentialRef[]? | References only. Absent, not [], when none |
when | WhenDescriptor | Required |
timeout | string | null | Duration string, e.g. "10m". Required — null means unbounded |
continueOnError | boolean | Required |
retries | number | Required, >= 0 |
steps | IRStep[] | Required |
produces | string[] | Declared artifact paths |
cacheKeys | IRCacheKey[] | Required (may be []) |
stashes | IRStash[]? | Absent, not [], when none |
dynamic | boolean | Always false — see below |
Three optional arrays are omitted rather than emptied. services, credentials and
stashes are absent when the stage declares none, which keeps plans byte-stable: adding
the feature to the engine doesn't change the serialization of a plan that doesn't use it.
Read them as "absent or non-empty", never as "always an array".
dynamic is always false. It's a compatibility field, not something you control. A
stage body that throws now fails the build instead of recording a partial stage, but
removing the field would have forced IR_VERSION = 3 and a migration for stored plans.
Nested types
WhenDescriptor
| { kind: "always" }
| { kind: "branch"; equals: string }
| { kind: "tag"; matches: string }
| { kind: "dynamic"; source?: string }
The first three are fully explainable — flow explain prints the condition. dynamic
is an opaque closure evaluated at run time, and explain says so rather than guessing.
IRStep
Three variants, distinguished by shell and cmd:
| Kind | shell | cmd | raw | write |
|---|---|---|---|---|
| exec | false | string[] | null | absent |
| shell | true | null | string | absent |
| write | false | null | null | object |
All three also carry id (formatted "<stageId>:<index>"), cwd, env and
timeout. Exec is the safe default — argv goes straight to the executor with no
shell. A shell step comes from sh.raw() or a plain dynamic string and carries quoting
and injection risk. A write step materializes bytes directly, so its content never passes
through a shell; write is { path, content, mode? }.
Mixing those columns is the single largest source of step_inconsistent errors.
IRCacheKey
{ op: "restore" | "save", path, key, resolvedKey }
key preserves the template as you wrote it, so flow explain stays readable.
resolvedKey is the content-addressed key computed during the plan pass, or null when
it depends on a runtime value — in which case the runtime falls back to key.
IRStash
{ op: "stash" | "unstash", name, includes?, excludes? }
Like cacheKeys, a stage-boundary operation: unstash runs before the steps, stash
after they succeed. Unlike cache, a stash is run-scoped and deleted when the run ends.
includes/excludes apply to stash only.
IRService
{ name, image, env?, args? }
name matches ^[a-z0-9][a-z0-9-]*$ and doubles as the hostname on the stage-scoped
network, so a step reaches it at http://<name>.
IRCredentialRef
{ id, type, binding }, where binding is one of:
| { kind: "env"; name: string }
| { kind: "file"; pathVar: string }
| { kind: "usernamePassword"; userEnv: string; passEnv: string }
| { kind: "sshKey"; pathVar: string; passphraseEnv?: string }
The plan carries references and binding hints only — never values, encrypted blobs,
provider tokens, file contents, or resolved paths. The runtime resolves each value
just-in-time, immediately before the declaring stage. A plan is safe to log and store.
Adding a binding kind is additive and does not bump IR_VERSION.
A real plan
From the pipeline on Authoring pipelines, truncated to one stage:
{
"version": 2,
"name": "acme-api",
"stages": [
{
"id": "install",
"name": "install",
"needs": [],
"when": { "kind": "always" },
"timeout": null,
"continueOnError": false,
"retries": 0,
"steps": [
{
"id": "install:0",
"shell": true,
"cmd": null,
"raw": "npm ci",
"cwd": ".",
"env": {},
"timeout": null
}
],
"produces": [],
"cacheKeys": [
{ "op": "restore", "path": "node_modules", "key": "deps-v1", "resolvedKey": null },
{ "op": "save", "path": "node_modules", "key": "deps-v1", "resolvedKey": null }
],
"dynamic": false
}
]
}
Note what's not there: no services, credentials or stashes key at all, because
this stage declares none.
The human view is a summary
flow explain without --json prints stages, dependencies, conditions, steps, services,
artifacts, caches and stashes — but not retries, timeout, step env/cwd, or the
credential list. That's deliberate; it's a picture of the graph, not a dump.
--json is the complete IR. If a field isn't in the human view, it's in the JSON.
Both forms validate the plan first and exit 1 if it's invalid, so flow explain --json
never emits a plan that wouldn't run. See
Validation errors.