RFC: Workflow
Status: Draft Author: Segev Shmueli Date: 2026-07-03
Summary
A Workflow is a directed acyclic graph of steps that runs as a single unit to accomplish a business outcome. Each step is an Invocation — a call into a cataloged App’s Action — wired to other steps by expressions that resolve at execution time. This RFC defines the workflow logical model (the definition), the run model (the checkpointed execution state), and the WorkflowContext host contract the engine executes against. Workflows target manifestVersion: "2".
Motivation
Actions are individual operations; Workflows are how users compose them into automations. Standardizing the workflow shape means:
- Publishers of authoring tools (studios, IDE extensions, partner UIs) render, edit, and validate workflows uniformly.
- Hosts implement one engine that runs every workflow — no per-workflow custom code.
- Runs are portable: a run started on host A can, in principle, be resumed on host B because its state is fully described by the model.
- Third-party tooling (analyzers, linters, static importers from n8n / Zapier) targets a single spec.
Absent this RFC, the definition and run shapes live only in the @w6w/workflow-types package. That package is the source of code truth; this RFC is the source of contract truth.
Goals
- Declare a workflow as a graph of steps with explicit or implicit edges.
- Each step is a single Invocation — no bespoke step contract.
- Wire data between steps with expressions over a shared run scope (
vars,steps,trigger). - Define retry and on-error behavior per step.
- Specify a checkpointed run state so failed runs can resume and completed runs can be replayed.
- Define the host contract (
WorkflowContext) an engine executes against — invocation, logging, state, scheduling, queuing.
Non-Goals
- How the engine executes the graph. Deferred to the workflow engine’s implementation (
@w6w/workflow-engine). Concurrent scheduling of independent branches, suspend/resume machinery, and back-pressure are engine concerns. - How workflows are triggered. The
triggerfield on a workflow is a reference; the trigger surface itself is specified in the Trigger RFC. - Scheduling. A cron reference is one flavor of trigger; the scheduling primitive lives in the Trigger RFC.
- The expression language. The
{ "$": ... }and{ "$expr": ... }markers used inwithrefer to@w6w/expr, which has its own spec (JSONLogic-based). This RFC only pins the marker convention. - Workflow-level auth or visibility. Individual Connections live on steps; workflow-level access control is a host concern.
Concept
A workflow is a directed acyclic graph of steps. Every step names an App and Action; when the step runs, the host packages { app, action, connection, params } into an Invocation and calls the core runtime. Whatever the action returns becomes the step’s output, which downstream steps can reference through expressions in their own params.
Control flow — branching, looping, waiting, running steps in parallel — is expressed the same way: as an Invocation of a control-type action (type: "control" in the Action RFC). Control actions are declared like any action so the editor renders them uniformly, but they are interpreted by the engine rather than called via the runtime. The Engine RFC pins the four canonical control identities (if, foreach, parallel, wait, all under the first-party @w6w/control app) that every conforming engine natively supports — a workflow using only actions + canonical controls is portable across every engine.
The graph is either explicit (via edges) or implicit (when no edges are declared, the engine runs steps in the declared order as a linear chain). Explicit graphs are validated at load time: cycles, dangling edge endpoints, and duplicate step ids are rejected.
Execution is checkpointed. After every step, the engine persists a StepExecution (input, output, status, timings) plus the run’s updated status. If the engine crashes mid-run, the host reads the RunState back and resumes from the first non-terminal step. Replays reuse recorded step outputs verbatim — no re-invocation of execute — so historical runs remain deterministic even if upstream apps change behavior.
Steps have retry and on-error policies. Retries apply to a single step’s Invocation; when retries are exhausted, onError selects one of three outcomes: fail (the default) aborts the run, continue proceeds to the next step, and continue-record proceeds and keeps the step’s StepError in the run’s end state so the failure stays observable. A step that declares an authored error edge (Edge.when: "error") is the exception: its failure routes down that edge and its onError is not consulted — see Amendment — 2026-07-29: failure-conditioned edges, which is the governing text for both. Retry policy honors the Invocation’s retryable classification: phase: "auth" errors are never retried (credentials aren’t going to change mid-run), phase: "execute" errors are retried only when the action declares idempotent: true or the error object marks itself retryable: true.
The engine never touches the outside world directly. Every operational effect — calling an app, writing a log, checkpointing state, scheduling a delay, enqueueing a fan-out job — flows through WorkflowContext. This is the same shape HookContext has for action execute: a thin abstraction hostable in-process, over HTTP, or against a test double, without engine changes.
Shape
{
"manifestVersion": "2",
"id": "wf_daily_report",
"name": "daily-report",
"displayName": "Daily Report",
"description": "Fetch yesterday's issues and post a summary to #ops.",
"trigger": { "subscriptionId": "sub_9f4c…" },
"variables": [
{ "key": "channel", "type": "string", "required": true, "default": "#ops" }
],
"steps": [
{
"id": "fetch",
"uses": { "app": "linear", "action": "search-issues", "connection": "conn_ab12" },
"with": {
"query": { "$expr": { "cat": ["updated:>", { "$": "trigger.event.since" }] } },
"limit": 50
},
"retry": { "maxAttempts": 3, "backoff": "exponential", "delayMs": 1000 },
"onError": "fail"
},
{
"id": "post",
"uses": { "app": "slack", "action": "send-message", "connection": "conn_cd34" },
"with": {
"channelId": { "$": "vars.channel" },
"text": { "$expr": { "join": ["\n", { "$": "steps.fetch.output.items" }] } }
}
}
],
"edges": [
{ "from": "fetch", "to": "post" }
]
}
Field reference
Workflow
| Field | Type | Required | Description |
|---|---|---|---|
manifestVersion | string | ✅ | Core spec version. "2" for the workflow model. |
id | string | ✅ | Host-issued opaque id. Stable across renames. |
name | string | ✅ | Machine name. Not enforced unique by the host — key (below) is the enforced, unique machine name. Lowercase, kebab-case. See Amendment — 2026-09-02: the Workflow key field. |
key | string | ⬜ | Machine-readable identifier, optional — a Workflow may have none. When set: matches /^[a-z][a-z0-9-]{2,38}/ (lowercase-first, then lowercase letters/digits/hyphens, no -- anywhere, no trailing -; _ is illegal), unique per (account, key) via a partial index (where key is not null, since key is optional), and validated against the grammar only when first assigned — never re-checked on a later save. See Amendment — 2026-09-02: the Workflow key field. |
displayName | string | ⬜ | Human-facing name. Falls back to name. |
description | string | ⬜ | One-line summary. |
trigger | WorkflowTrigger | ⬜ | How this workflow starts. Absent means manual-only. See Trigger RFC. |
variables | WorkflowVariable[] | ⬜ | Inputs collected from the caller / trigger event and made available as vars.*. |
steps | Step[] | ✅ | The graph nodes. At least one. |
edges | Edge[] | ⬜ | Directed dependencies. When absent, the engine treats steps as a linear chain in declared order. |
settings | object | ⬜ | { autoSave?, savePosition?, viewport? } — authoring presentation for this workflow. Declarative only; the engine ignores it. autoSave and savePosition default to true when omitted. See Amendment — 2026-07-29: authoring presentation. |
Step
| Field | Type | Required | Description |
|---|---|---|---|
id | string | ✅ | Machine name. Unique within the workflow. MUST NOT contain .: downstream expressions reference outputs as steps.<id>.output, a path resolvers split on ., so a dotted id could never be addressed. Hosts refuse a dotted id when the workflow is saved. |
uses | object | ✅ | { app, action, connection? }. Fully names the Invocation target. |
uses.app | string | ✅ | App id (registry-resolved). |
uses.action | string | ✅ | Action key within the app. |
uses.connection | string | null | ⬜ | Connection id. Required when the action’s app declares auth and the action doesn’t opt out with requiresAuth: false. |
with | object | ⬜ | Param values. Each value is a literal, an object, an array, or an expression marker. See Expression markers. |
retry | RetryPolicy | ⬜ | How to retry on failure. Defaults to no retry. |
onError | enum | ⬜ | "fail" (default), "continue", or "continue-record". Applied when retries are exhausted: fail aborts the run; continue swallows the failure and proceeds; continue-record proceeds and rolls the step’s StepError into the run’s end state (RunState.stepErrors) so the failure stays observable. Overridden, for the step it is declared on, by an authored error edge — see Amendment — 2026-07-29. |
ports | object | ⬜ | { in?, out? } — declared port cardinality. Omitted ⇒ { in: 1, out: 1 }. in > 1 opts the step into accepting multiple inbound edges (a fan-in node). See Node Types RFC · Ports & cardinality. |
notes | string | ⬜ | Free-form author notes carried on the step. Declarative only — the engine ignores it. |
position | object | ⬜ | { x, y } — the step’s coordinate on an authoring tool’s canvas. Declarative only — the engine ignores it. Omitted ⇒ the editor lays the step out itself, exactly as today. See Amendment — 2026-07-29: authoring presentation. |
Note: “Required” (✅) above describes what a valid, runnable Step needs — it is not a storage-time constraint. A host may persist a Step (and a Workflow containing it) as an incomplete draft, e.g. with
uses.app/uses.actionunset. A separate computed validity signal or a publish/invoke-time gate is what enforces runnability, not storage rejection.
Edge
| Field | Type | Required | Description |
|---|---|---|---|
from | string | ✅ | Step id. |
to | string | ✅ | Step id. |
when | enum | ⬜ | "success" (default) or "error" — which outcome of the from step activates this edge. "success" activates when that step succeeds, "error" when it fails. An omitted when means "success", so every pre-existing edge is a success edge. See Amendment — 2026-07-29. |
WorkflowVariable
| Field | Type | Required | Description |
|---|---|---|---|
key | string | ✅ | Machine name. Referenced as vars.<key>. |
type | enum | ⬜ | "string" | "number" | "boolean" | "object" | "array". Advisory; hosts SHOULD validate at trigger/run boundary. |
required | boolean | ⬜ | If true and no value provided at run start, the run is rejected. |
default | any | ⬜ | Used when the caller doesn’t supply a value. |
Note:
WorkflowVariableis a thin shape today. It’s intended to converge on the Param RFC at a future revision so workflow inputs validate and resolve identically to Action params.
RetryPolicy
| Field | Type | Required | Description |
|---|---|---|---|
maxAttempts | number | ✅ | Total attempts including the first. 1 = no retry. |
backoff | enum | ⬜ | "fixed" (default) or "exponential". |
delayMs | number | ⬜ | Base delay in ms before the first retry. Defaults to 0. Exponential doubles each attempt. |
See Amendment — 2026-10-04 for the shape rules a host enforces on write and the 300000 ms ceiling on any single computed wait.
Retries are attempted only for errors the runtime classifies as retryable. phase: "auth" errors are never retried. phase: "execute" errors are retried only when the action declared idempotent: true or the error itself sets retryable: true.
Execution model
Planning
Before any step runs, the engine plans the graph:
- Reject duplicate step ids.
- If
edgesis empty or absent → the plan isstepsin declared order (implicit linear chain). - Otherwise:
- Reject edges referencing unknown step ids (
fromorto). - Build the in-degree map and adjacency list.
- Topologically sort. Ties broken by declared step order for determinism.
- Reject cycles (topological sort fails to consume all nodes).
- Reject edges referencing unknown step ids (
The plan is a deterministic list of step ids. v0 executes them sequentially; parallel scheduling of independent branches is an engine-level enhancement that consumes the same plan.
Run state
Every run is fully described by a RunState:
{
"runId": "run_5f...",
"workflowId": "wf_daily_report",
"status": "running",
"variables": { "channel": "#ops" },
"steps": {
"fetch": {
"stepId": "fetch",
"status": "succeeded",
"attempt": 1,
"output": { "items": [...] },
"startedAt": "2026-07-03T09:00:00Z",
"finishedAt": "2026-07-03T09:00:03Z"
}
}
}
| Field | Type | Description |
|---|---|---|
runId | string | Host-issued. Stable across resumes and replays. |
workflowId | string | The workflow this run instantiates. |
status | enum | "queued" | "running" | "succeeded" | "failed" | "canceled". |
variables | object | The resolved input scope for this run. |
steps | object | Map of step id → StepExecution. Only completed / running / failed steps appear. |
output | any | Final output when status === "succeeded". Optional. |
error | StepError | Terminal error when status === "failed". |
The engine writes a checkpoint through WorkflowContext.state.checkpoint() after every observable transition (status change, step start, step end, retry attempt). The host implementation of checkpoint MUST be durable — a run that survives a crash is one whose last checkpoint reached durable storage.
StepExecution
| Field | Type | Description |
|---|---|---|
stepId | string | The step this record is for. |
status | enum | "pending" | "running" | "succeeded" | "failed" | "skipped". |
attempt | number | 1-based attempt count. Incremented per retry. |
input | object | Resolved params passed to the Invocation. Recorded for replay + debugging. |
output | any | Action’s return value. Present on succeeded. |
error | StepError | Present on failed. |
startedAt, finishedAt | ISO-8601 | When the last attempt started / finished. |
See Amendment — 2026-10-12: step execution metadata (steps.<id>.meta), which adds an optional meta field to this record.
StepError
| Field | Type | Description |
|---|---|---|
code | string | Machine code (e.g. unknown_action, network_error, param_invalid). |
message | string | Human-facing message. |
phase | string | Invocation phase the error came from: "resolution" | "auth" | "execute" | "output". |
retryable | boolean | Engine’s classification of retry safety. |
Replay
Replay re-executes a completed or failed run without re-invoking actions. The engine walks the plan; for each step that succeeded in the original run, it uses the recorded StepExecution.output verbatim. Steps that were pending or failed run fresh. This makes replays cheap and reproducible even when upstream apps have changed.
See Amendment — 2026-10-12: step execution metadata (steps.<id>.meta): stored meta is reused wherever stored output is.
Expression markers
Values in a step’s with block, and by extension anywhere the model accepts expressions, use a two-marker convention:
| Marker | Meaning |
|---|---|
{ "$": "steps.fetch.output.items.0.title" } | Path lookup sugar over the run scope. |
{ "$expr": <JSONLogic> } | Full JSONLogic evaluation over the run scope. |
| plain object / array | Resolved recursively — nested markers evaluated in place. |
| anything else | Literal passthrough — except a string every one of whose {{ … }} markers names a run-scope root, which resolves; see Amendment — 2026-08-14. |
The two-marker form keeps a literal object unambiguous from an expression — the engine never has to guess whether { "==": [...] } is data or logic.
The run scope is:
interface RunScope {
vars: Record<string, unknown>; // workflow-level variables
steps: Record<string, { output: unknown }>; // completed step outputs so far
trigger: { type: string; event?: unknown }; // trigger context (see Trigger RFC)
}
Expression semantics — operators, coercion, error handling — are specified by @w6w/expr (JSONLogic-based). This RFC only pins the marker convention and the scope shape.
See Amendment — 2026-08-11: the multipart expression envelope and the render part kind, which specifies the multipart envelope and declares two further run-scope roots (secrets, documents) — the RunScope block above is incomplete rather than exhaustive, and that section governs where the two disagree.
See Amendment — 2026-08-14: root-anchored template strings in with values, which specifies when a string with value resolves against the run scope — the marker table’s anything else row above is incomplete rather than exhaustive, and that section governs where the two disagree.
See Amendment — 2026-10-12: step execution metadata (steps.<id>.meta), which adds an optional meta beside output in each steps entry of the scope above.
See Amendment — 2026-10-12: whole-value bindings for json and group params, which lets a json or group param’s whole value be a $ marker resolving type-preserved.
Host contract — WorkflowContext
The engine is transport-free and host-free. Every operational effect goes through WorkflowContext, which the host implements.
interface WorkflowContext {
run: {
id: string;
workflowId: string;
trigger: "manual" | "schedule" | "webhook" | "replay";
attempt: number;
};
/** Package as an Invocation and call the core runtime. Returns the action's output. */
invoke(req: { app: string; action: string; connection?: string | null;
params: Record<string, unknown>; stepId: string }): Promise<unknown>;
log(level: "debug" | "info" | "warn" | "error", message: string, data?: unknown): void;
/** Durable state persistence. `checkpoint` MUST reach durable storage before returning. */
state: {
checkpoint(patch: RunStatePatch): Promise<void>;
load(runId: string): Promise<RunState | null>;
};
/** Durable scheduling for `wait` / `delay` steps. Optional in v0. */
schedule?(directive: { at: string; resumeToken: string }
| { afterMs: number; resumeToken: string }): Promise<void>;
/** Durable queueing for fan-out. Optional in v0. */
queue?: { enqueue(job: { kind: string; payload: unknown }): Promise<{ jobId: string }> };
/** Cooperative cancellation, checked between steps. */
signal?: AbortSignal;
}
| Member | Required | Notes |
|---|---|---|
run | ✅ | Correlation ids for logs and Invocations. trigger is the RunTrigger tag; the app-declared trigger (if any) is looked up via workflow.trigger.subscriptionId. |
invoke | ✅ | The single bridge to the core runtime. The engine NEVER sees credentials, source refs, or the sandbox. |
log | ✅ | Routed to the host’s observability sink. |
state.checkpoint | ✅ | Durable. A pre-crash checkpoint MUST be readable post-crash. |
state.load | ✅ | Idempotent read for resume / replay. |
schedule | ⬜ | Required only for hosts that support wait / delay steps. |
queue | ⬜ | Required only for hosts that support fan-out / parallel branches. |
signal | ⬜ | When provided, the engine polls between steps and aborts cleanly on signal. |
See Amendment — 2026-10-12: step execution metadata (steps.<id>.meta), which adds the optional onExecuted member to the invoke request.
Conformance
A host conforms to this RFC when:
- Planning rejects duplicate step ids, dangling edges, and cycles at load time.
- Implicit chain — a workflow with no
edgesexecutesstepsin declared order. - Checkpoint durability — a
RunStateread viastate.load()after a process crash reflects the last successfulstate.checkpoint(). - Retry classification —
phase: "auth"errors are never retried;phase: "execute"errors are retried only when idempotent orretryable: true. - Replay determinism — replaying a run yields the same terminal
statusand, for succeeded steps, the sameoutput, without callingexecuteagain. - Expression scope — the engine populates
vars,steps, andtriggerin every expression evaluation as specified. See Amendment — 2026-08-11: the multipart expression envelope and therenderpart kind, which adds thesecretsanddocumentsroots to that enumeration and governs this bullet. - Step metadata — applies only to an engine/host pair that implements step metadata, which is optional (an older host that never calls
onExecutedremains conformant). See Amendment — 2026-10-12: step execution metadata (steps.<id>.meta):metais host-supplied, allowlisted to four keys, and published only for succeeded app steps.
The @w6w/workflow reference engine + its test fixtures constitute the executable version of this contract.
Resolved questions
| Question | Resolution |
|---|---|
| Step contract | A step is an Invocation. No bespoke step shape — uses fully names an (app, action, connection) triple. |
| Graph shape | DAG with explicit edges; implicit linear chain when omitted. Cycles and dangling edges rejected at plan time. |
| Expression language | { "$": ... } for path lookup, { "$expr": ... } for JSONLogic. Two-marker form keeps literal objects unambiguous. |
| Replay semantics | Replays reuse recorded step outputs verbatim. Do not re-invoke actions. |
| Retry classification | Runtime-declared. Auth errors never retried. Execute errors retried only when idempotent or explicitly retryable. |
| Control-flow step types | Modeled as pseudo-actions with type: "control" (see Action RFC §Control). The canonical set (if, foreach, parallel, wait) lives in the first-party @w6w/control app; every conforming engine natively interprets them (see Engine RFC). Extension controls beyond the canonical four are a future RFC. |
Open questions
- Fan-out / parallel branches — default concurrency. The
parallelcanonical control expresses opt-in concurrency at the step level. Do we also allow a workflow-levelconcurrencyfield that lets independent branches of the base DAG run concurrently without aparallelwrapper? Adds ergonomics; adds engine responsibility. - Variables convergence with Param.
WorkflowVariabletoday is a shallow shape. Migrate to the full Param RFC so variables get validation, dynamic options, anddependsOn— at the cost of a manifest-version bump. - Sub-workflows. Should a step be able to invoke another workflow (
uses.workflow) as an alternative touses.action? If so, how do sub-workflow retries and state nest into the parent run? - Per-run TTL and cleanup. How long do completed
RunStaterecords live? Host-configurable; RFC-level default? - Node placement. Should the host-level
x-w6w-placementannotation — a top-level list of node labels a run requires, enforced today by the w6w host’s run queue — be promoted to a first-class Workflow field? Promotion makes placement portable across hosts; it also commits every host to a node-label model.
Status ladder
Draft— under active design; fields and shape may change without notice.Review— proposal is feature-complete; soliciting feedback before freeze.Final— frozen formanifestVersion: "2". Breaking changes require a new RFC and amanifestVersionbump.Superseded— replaced by another RFC; carry a pointer to its successor.
Amendment — 2026-07-29: failure-conditioned edges (Edge.when)
This section is additive to the Edge shape and the Execution model above; it introduces no breaking change and no new host primitive. It adds one optional field to
Edgeand pins how a step’s failure is routed along the graph. Everything it relies on already exists in the model: plan-time cycle rejection, and the edge-skip propagation the unmatched branch of@w6w/control·ifalready uses. Where this section and the pre-amendment prose ononErrordisagree, this section governs.
An error edge is an edge that activates when its source step fails, instead of when it
succeeds. It is expressed by the new optional Edge.when field:
| Value | Meaning |
|---|---|
"success" | The edge activates when the from step reaches status: "succeeded". The default. |
"error" | The edge activates when the from step reaches status: "failed". |
Default. when is optional and its omission means "success" — which is exactly what every
edge meant before this amendment. A host MUST reject a when value outside the two-member enum at
load time, alongside the graph validations in Planning.
Outcome selects the outgoing edges. A step that ends succeeded activates its when: "success"
edges (an omitted when is one of them) and marks its outgoing when: "error" edges skipped. A
step that ends failed and declares at least one outgoing when: "error" edge does the
converse: it activates those error edges and marks its outgoing success edges skipped. A step
reachable only through skipped edges is recorded status: "skipped" — the same propagation the
unmatched branch of if already produces. Success routing and error routing are one mechanism, not
two.
A failing step with no error edge is left to onError. When a step ends failed and declares
no outgoing when: "error" edge, the rule above does not fire: that step’s success edges are
not skipped, and its onError alone decides. "fail" (the default) ends the run; "continue"
and "continue-record" proceed along the step’s ordinary outgoing edges, so the next step runs
exactly as it did before Edge.when existed — "continue-record" additionally keeping the
StepError in the run’s end state. Skipping a step’s success lane on failure is what an
authored error edge buys; failing alone never does it.
An error edge overrides onError. When a step declares at least one outgoing when: "error"
edge, that edge — not the step’s onError — decides what happens on failure. The run takes the
error edge and continues, whatever the step declares for onError ("fail", "continue", or
"continue-record"); the two do not compose. The edge is the more specific statement about that
step’s failure. onError stays authoritative for every step that declares no error edge.
All failure phases route. Any step that ends failed takes its error edge, whatever the
StepError phase — "resolution", "auth", "execute", or "output". v1 draws no
phase distinction.
Retries come first. A step takes its error edge only once it is finally failed: after its
retry policy is exhausted, or as soon as the error is classified non-retryable. Two step kinds run
no retry loop at all and therefore take their error edge on their first failure: @w6w/call
steps, and @w6w/control steps. An author who wants attempts before the error branch must declare
retry on a step kind that honors one.
Error edges stay in the DAG. An error edge is an ordinary directed edge and is validated like one: dangling endpoints and cycles are rejected at load time. A failure therefore cannot route backwards to an earlier step in v1 — “retry from step X” is not expressible as an error edge. This is a stated limit of v1, carried by the existing cycle rejection rather than by new enforcement.
Main graph only. when: "error" applies to edges of the main graph. Steps owned by a
control’s sub-block (foreach.body, parallel.branches, an if branch block) do not participate in
main-graph edge routing, and an edge touching such a step is already rejected at plan time; a step
inside a sub-block therefore cannot carry an error edge. A sub-block failure is handled by its
enclosing control’s own failure modes (see the Engine RFC).
A success edge and an error edge MAY share a target. The model permits { from: "a", to: "b" }
and { from: "a", to: "b", when: "error" } to coexist. The engine keys a skipped edge by
(from, to, when), so the two never collide, and because a step is skipped only when every
inbound edge is skipped, the shared target runs exactly once on either path. The v1 editor
declines to author that pair — that is an authoring-tool limit, not a limit of this model.
Terminal status. A run whose failed step routes down an error edge, and whose error branch then
completes, ends succeeded. The failure is not erased: the failed step keeps its
StepExecution in RunState.steps with status: "failed" and its error
populated, and the underlying call remains in the host’s API-call log. A run ends failed only when
a failure reaches a step that declares neither an error edge nor a continuing onError.
Additive & backward-compatible. when is a new optional field on Edge; workflow
definitions are JSON, so existing manifestVersion: "2" workflows — every edge of which is
implicitly a success edge — remain valid and unchanged with no migration. A host that does not
understand when reads every edge as when: "success", which is exactly the pre-existing behavior.
Amendment — 2026-07-29: authoring presentation (Step.position, Workflow.settings)
This section is additive to the Workflow and Step shapes above; it introduces no breaking change, no new host primitive, and no change to any existing field. It adds two optional fields that carry authoring presentation — where a step sits on an editor’s canvas, and how an editor should behave while someone edits this workflow. The engine ignores both fields entirely: they are purely declarative, exactly like
Step.notes. No plan, no checkpoint, no expression scope, and no Conformance rule reads them, and a workflow’s execution is byte-for-byte identical with them present, absent, or arbitrary. It is independent of, and does not interact with, Amendment — 2026-07-29: failure-conditioned edges. HITL-6 asked whether a workflow’s arrangement and its auto-save preference belong to the workflow or to the person looking at it; this amendment takes the workflow answer — both fields live in the workflow document, so everyone who opens a workflow sees the same arrangement and the same preferences.
Authoring tools need two things the pre-amendment model cannot carry: where each step was placed on the canvas, and per-workflow authoring preferences (does editing save itself, is the arrangement persisted at all, and what was the last camera position). Both are presentation, not semantics, so they enter the model as optional declarative fields rather than as engine behaviour.
// on Step
position?: { x: number; y: number };
// on Workflow
settings?: {
autoSave?: boolean; // omitted ⇒ true
savePosition?: boolean; // omitted ⇒ true
viewport?: { x: number; y: number; zoom: number };
};
Workflow.settings
settings is itself optional; when the object is absent, every member below takes its own default.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
settings.autoSave | boolean | ⬜ | true | Whether an authoring tool persists edits to this workflow without an explicit save action. |
settings.savePosition | boolean | ⬜ | true | Whether an authoring tool persists step coordinates (Step.position) and settings.viewport when it saves. When false, an authoring tool does not write them; any values already stored are left as they are, not erased. |
settings.viewport | object | ⬜ | none | { x: number, y: number, zoom: number } — the last camera position of the canvas, so reopening the workflow restores the view. No default: a workflow with no viewport opens at whatever view the editor computes, exactly as today. |
viewport sits inside settings deliberately, so exactly one new top-level key enters the
portable workflow document. All three members are the same thing — authoring presentation for this
workflow — and they belong under one key.
autoSave and savePosition default to true when omitted. This is the single most important
sentence in this amendment, and it is deliberately unlike the house rule that
ports and onError follow, where an omitted field
reproduces prior behaviour. Here it does not: omitting autoSave means auto-save is on, and
omitting savePosition means positions are persisted, neither of which is what a host did before
this amendment. That is intended — the product requires both features on by default, so the
absent-value reading must be true, not false and not “whatever happened before”. Concretely:
settings.autoSave ?? true and settings.savePosition ?? true. An implementation that reads either
as ?? false, or that treats an absent settings object as “both off”, contradicts this amendment.
Only an explicit false turns a feature off.
Step.position
position is the step’s coordinate on an authoring tool’s canvas: { x: number, y: number }, in the
editor’s own coordinate space (the spec pins no unit, origin, or grid — those are the authoring
tool’s).
Rename-safe by construction. Because the coordinate travels with the step, renaming a step id
carries its position along untouched: there is no workflow-level id → {x,y} map to keep in step
with renames, and therefore no orphaned entries and no id fix-up. This is why coordinates attach
per-Step rather than as one layout map on the workflow.
No positions is a valid workflow. position is optional per step, so a workflow whose steps
carry none — every workflow authored before this amendment — is laid out by the editor exactly as it
is today, from the graph alone. Partial coverage is likewise valid: an editor places the steps that
declare a position and computes the rest.
Storage
Both fields live in the workflow document itself — the same JSON object that already carries
steps and edges. A host that stores a workflow definition as an opaque document (the reference
host stores it in an existing definition jsonb column) therefore needs no new column and no
migration; the two fields round-trip as ordinary members of the document. This RFC pins no storage
mechanism beyond that: what is normative is that both fields are part of the portable workflow
document, so a workflow exported from one host and imported into another keeps its arrangement.
Additive & backward-compatible. settings is a new optional field on Workflow and
position is a new optional field on Step; workflow definitions are JSON, so existing
manifestVersion: "2" workflows — none of which declares either — remain valid and unchanged with
no migration. A host that does not understand them ignores them, which is exactly the
pre-existing behavior; an engine is required to do precisely that. The one thing a host MUST NOT do
is read an omitted autoSave or savePosition as false.
Amendment — 2026-08-11: the multipart expression envelope (ExprValue) and the render part kind (F-3)
This section is additive to Expression markers above; it introduces no breaking change, no new host primitive, and no change to any existing marker or field. It does three things: it specifies the multipart expression envelope (
ExprValue) that authoring tools and engines already exchange but which this RFC has never named; it declares two run-scope roots —secretsanddocuments— that the pre-amendment scope block omits; and it adds exactly one new part kind,render. It is independent of, and does not interact with, Amendment — 2026-07-29: failure-conditioned edges and Amendment — 2026-07-29: authoring presentation — the three amend independent parts of this RFC. Where this section and the pre-amendment prose disagree, this section governs. That applies specifically to the three-memberRunScopeinterface under Expression markers and to the Expression scope bullet under Conformance, each of which enumeratesvars,stepsandtriggeralone: both are read as incomplete rather than exhaustive, extended by Run-scope roots below, and neither is edited. The companion node that loads a document by key at run time is Node Types RFC — the@w6w/documenthost node.
Expression markers pins two markers and the shape of the run scope, and says
as much in as many words: “This RFC only pins the marker convention and the scope shape.” A third
form is nevertheless already in the model — the multipart expression envelope an authoring tool
produces whenever one param value mixes literal text with references (Hi {{ vars.name }}, your order is ready). This section specifies that envelope, then adds one part kind to it.
The envelope
An ExprValue is a with value of the form { "type": "expr", "parts": [ … ] }: an ordered
list of parts, each resolved independently, concatenated into one string. It takes its place
alongside the two existing markers — the first two rows below are restated unchanged from
Expression markers for context; only the third is new:
| Marker | Meaning |
|---|---|
{ "$": "steps.fetch.output.items.0.title" } | Path lookup sugar over the run scope. |
{ "$expr": <JSONLogic> } | Full JSONLogic evaluation over the run scope. |
{ "type": "expr", "parts": [ … ] } | Multipart expression envelope — an ordered list of parts concatenated to a single string. |
A part is { "kind": <kind>, … }. Five kinds are defined, and each populates exactly one field:
kind | Field it populates | Resolves to |
|---|---|---|
text | value (string) | The literal chunk, verbatim. |
var | ref (path) | A path lookup against the generic data root, exactly as { "$": … }. |
secret | ref (secret name) | The plaintext of that named secret, read from the secrets root. The only production in the model that may read it. |
expr | expr (JSONLogic) | JSONLogic evaluated against the generic data root, exactly as { "$expr": … }. |
render | ref (path) | New. The string at ref, parsed as a {{ }} template and rendered — see The render part kind. |
A part carries no field beyond the one its kind names. On the two kinds that carry their own content
— text and expr — an absent value / expr is read as empty rather than as an error. On the
three kinds whose field names something to look up, that field is required: a var part’s
ref, a secret part’s ref, and a render part’s ref. A secret part whose ref names no
available secret fails the step rather than contributing empty, and a render part’s ref must
resolve to a string — see The render part kind.
Five kinds are defined here and no other is. A part whose kind is none of them is outside this
model; the reference engine contributes the empty string for it, which is a description of what that
engine does and not a requirement this RFC places on a host.
An ExprValue always resolves to a single string
An ExprValue resolves to one string — always. That includes the case where parts holds
exactly one part, and the case where that one part’s value is an object or an array, which is
stringified before it is concatenated. This is a stated limit of the model, not a discovered
one: it is what makes concatenation well defined for every combination of parts, and it is why the
envelope needs no result-type negotiation.
The consequence worth stating plainly, so that nobody meets it as a surprise: a document rendered
through a render part cannot be handed to an object-typed param. Whatever the document holds,
the envelope’s contribution is a string. The structured value of a json document is reached the
other way round: a { "$": "steps.<id>.output.content" } marker resolves to the value itself rather
than to a string form of it. Pointing a render part’s ref at that parsed object does not
stringify it either — it fails the step, per The render part kind.
The render part kind
{ "kind": "render", "ref": "<path>" } reuses the envelope’s existing ref field — no new field
enters the model. It resolves in four steps:
- Resolve
refagainst the generic data root — the run scope minussecrets, per Run-scope roots — to a value, exactly as avarpart would. - Require a string. The value from step 1 must be a string; there is no coercion here and
no fallback. A path that is absent, or that resolves to
nullorundefined, fails the step withrender_ref_unresolved, naming therefthat was asked for. A path that resolves to any other non-string — an object, an array, a number, a boolean — fails the step withrender_ref_not_a_string, naming therefand the type found. - Parse the resulting string as a
{{ }}template in render mode (below), yielding a list of parts. - Resolve each of those parts against that same generic data root and concatenate the results; the concatenation is this part’s contribution to the envelope.
Why step 2 fails rather than coerces. An absent path is otherwise byte-identical to an empty
template: a ref with a typo in it renders to nothing, the step succeeds, and the run mails a
blank body. That failure is invisible — nothing in the run record distinguishes it from a document
that really was empty. Failing by name is what makes it visible, and it is why the two error names
above are part of this contract rather than an implementation detail: the step’s outcome is the only
signal this model offers.
One pass. The parts produced in step 3 are resolved in step 4 and never re-enter step 1: a
reference produced by rendering is never itself rendered, whatever it resolves to. So content
containing {{ documents.other.body }} substitutes that other document’s text verbatim —
{{ }} sequences and all, un-rendered. There is no recursion, no depth limit and no cycle
detection, because there is no second pass to bound.
render has no {{ }} text spelling, in either parser mode. It is an authored part kind only:
{ "kind": "render", "ref": … }. Nothing an author can type between {{ and }}, and nothing a
document can contain, parses to a render part. That is what makes “one pass” a property of the
grammar rather than a rule an implementation must remember to apply.
Render mode, and why rendered content cannot reach a secret
The {{ }} template grammar has two parser modes, and render uses the narrower one:
| Mode | Productions | Used by |
|---|---|---|
| editor | {{ =<JSONLogic> }} → an expr part · {{ secrets.<name> }} → a secret part · {{ <path> }} → a var part | authoring tools, round-tripping an authored ExprValue to text and back |
| render | {{ =<JSONLogic> }} → an expr part · {{ <path> }} → a var part | step 3 of a render part |
Render mode’s grammar has exactly two productions, and neither one is a secret reference. It has
no production for a secret part and no production for a nested render part. This is a property of
the grammar itself, not a filter over its output: in render mode no input has a parse that yields
a secret part, so there is no such part in existence to be removed, rejected or skipped, and
nothing an implementer could forget to do.
{{ secrets.API_KEY }} inside rendered content is therefore not a secret reference at all. It
matches the ordinary path production and parses to { "kind": "var", "ref": "secrets.API_KEY" } — a
path lookup like any other. And the generic data root it is looked up in is the run scope minus
secrets (Run-scope roots), so the lookup finds nothing and contributes the
empty string, exactly as every unknown path does. Two independent structural barriers, each
sufficient on its own: the grammar cannot produce the part, and the root does not carry the data.
That the parse yields a var part naming the literal path secrets.API_KEY — rather than nothing
at all — is the observable difference between this mechanism and a post-parse filter, and it is what
a conformance test asserts on.
Why the barrier is needed. Rendered content is data, and the model does not guarantee it was
authored by the run’s caller: a conforming host’s document store may serve a run both the caller’s
own documents and documents shared across the tenant, so a run started by one subject can render
content written by another. If a rendered {{ secrets.X }} resolved to plaintext, writing a shared
document would be enough to read someone else’s vault through their own run — and on a host’s
single-step authoring path the injected secrets root is typically every secret the scope can see,
not merely the ones the definition names. The barrier is what keeps “a document is data” true.
The barrier is about secrets and nothing else. Rendered content reads the same generic data
root every other expression reads — vars, steps, trigger, documents, and whatever else the
host populates for that pass. render is not a sandbox and narrows no other read surface; it simply
cannot reach secrets.
Run-scope roots
The RunScope block under Expression markers enumerates three roots (vars,
steps, trigger). Two more are part of the model and are declared here; per the governing clause
above, that block is incomplete rather than exhaustive.
| Root | Contents | Reachable through the generic data root? |
|---|---|---|
secrets | Host-injected plaintext, keyed by secret name. | No — never. |
documents | The run’s project-scoped document store, keyed by document key. Ordinary data. | Yes. |
secrets is injected by the host from its vault before the run and is never persisted with run
state. Exactly one production in this model may read it: a secret part of an ExprValue, which
reads secrets[ref] directly, never through expression evaluation. It is excluded from the
generic data root — the root that { "$": … }, { "$expr": … }, var parts, expr parts and
every rendered reference are evaluated against is the run scope with secrets removed. So
{ "$": "secrets.X" } is an unknown path rather than a secret read and resolves to nothing, which
is what an unknown path always does. A host that puts secrets on the generic data root does not
conform to this RFC.
documents is ordinary data with no special handling: reachable as documents.<key> from any
expression, exactly like vars or steps. The host populates it under the run’s own
(tenant, subject, project) — a workflow reads its own project’s documents. Loading one lazily
by key at run time instead, so the key can come from a trigger input or an upstream step’s output,
is what the
@w6w/document node is for.
This section declares secrets and documents, and only those two. Any further root a host
populates for a particular pass is neither declared nor forbidden here; it remains unspecified by
this RFC.
Conformance (additive)
A host that implements the multipart expression envelope MUST:
- Resolve an
ExprValueto a single string, including whenpartsholds exactly one member, and including when that member’s value is an object or an array — which is stringified before concatenation, never handed through as a structured value. (Arenderpart’s ownrefis the one place this stringification does not apply: it requires a string, per the bullet below.) - Evaluate
varparts,exprparts,{ "$": … }and{ "$expr": … }against a data root from whichsecretshas been removed. Testably: for every run scope S and everywithblock that contains nosecretpart, resolving that block against S MUST produce a result identical to resolving it against{ ...S, secrets: {} }. - Read plaintext from
secretsfor asecretpart only, directly, and never through expression evaluation. - Parse a
renderpart’s resolved content in render mode, whose grammar has no production for a secret reference. Testably: for every content string T, parsing T in render mode MUST yield zero parts of kindsecret; and{{ secrets.X }}in particular MUST parse to avarpart whoserefis the literal pathsecrets.X, and MUST therefore render as the empty string. - Render in one pass: the parts produced by parsing a
renderpart’s content MUST NOT themselves be rendered. Testably: rendering content that contains{{ documents.other.body }}MUST substitute that document’s text verbatim, including any{{ }}sequences inside it. - Fail the step when a
renderpart’srefdoes not resolve to a string:render_ref_unresolvedwhen the path is absent or resolves tonull/undefined, andrender_ref_not_a_stringfor any other non-string, each naming theref. Testably: arenderpart whoserefnames a path the run scope does not carry MUST fail the step — it MUST NOT render as the empty string, and MUST NOT emit the literal textnull. - Make
documents.<key>reachable through the generic data root like any other data, populated under the run’s own project.
An absent or empty ref names nothing
A var, secret or render part’s ref is required (stated above). This clause says what
follows when one is absent or empty anyway, because “required” on its own leaves the case to each
host — and the reading a host is most likely to reach for is the dangerous one.
An absent or empty ref names nothing. It MUST NOT resolve the data root. The equivalence the
kinds table draws between a var part and { "$": … } is narrowed here to exactly this extent: a
var part whose ref is absent or "" contributes the empty string, not the run scope and not
a stringification of it. This is deliberate divergence from the JSONLogic identity { "var": "" },
which returns the whole data root — that production is reachable through expr parts and
{ "$expr": … }, where the author has written the empty path on purpose; it is not reachable
through the absence of a value the model already declared required.
The other two kinds inherit their existing consequences unchanged, because “names nothing” is
already the input those rules take: a secret part with an absent or empty ref names no available
secret and so fails the step, per the secret rule above; a render part with an absent or
empty ref has no resolved content and so fails the step with render_ref_unresolved, per
The render part kind.
Conformance (additive). A host that implements the multipart expression envelope MUST NOT expose
the run scope, or any root of it, through an empty path in an envelope part. Testably: for every run
scope S, resolving a with block whose only part is { "kind": "var" } or
{ "kind": "var", "ref": "" } MUST produce the empty string — it MUST NOT produce a serialization
of S, and in particular MUST NOT surface anything from secrets.
Amendment — 2026-08-14: root-anchored template strings in with values
This section is additive to Expression markers above, and it is the reconciling authority over four earlier passages that read as if a plain string
withvalue never resolves: the marker table’s| anything else |row (Expression markers), theStep.withfield row (“a literal, an object, an array, or an expression marker”), the Non-Goals bullet on the expression language and the Resolved questions “Expression language” row — each of which names only the two pre-existing markers — and the Conformance “Expression scope” bullet. Where any of those and this section disagree about whether a plain string resolves, this section governs. The marker-table row is the one pre-existing line this amendment rewrites (below); theStep.withfield row, the Non-Goals bullet, and the Resolved-questions row stand unedited and are read as incomplete rather than exhaustive — they enumerate the markers that existed before this amendment, not every value form awithvalue now takes. The Conformance “Expression scope” bullet is unaffected: it states which roots the engine populates in every evaluation, not which value forms resolve, and this amendment declares no new root. It is independent of, and does not interact with, Amendment — 2026-07-29: failure-conditioned edges and Amendment — 2026-07-29: authoring presentation — neither changes what awithvalue resolves to. Its relationship to Amendment — 2026-08-11: the multipart expression envelope and therenderpart kind (F-3) is narrower than independence: this section builds on it — reusing thesecretsanddocumentsroots it declares — and narrows one of its sentences (below), without editing that amendment’s text or changing anything it specifies about the multipart envelope,renderparts, or the two secret barriers.
Before this amendment, a plain string with value was pinned as unconditional literal
passthrough — the marker table’s anything else row. That is wrong the moment a host resolves
{{ … }} markers embedded in a plain string rather than only inside the two bracketed marker forms:
a workflow author who types "Bearer {{ vars.token }}" as a step’s with value expects the token
substituted, not the literal braces sent to the app. This section specifies exactly when that
substitution happens and when it does not.
§ The rule.
-
A string
withvalue resolves iff it contains at least one{{ … }}marker and every one of its markers names a run-scope root. Otherwise the string is passed through unchanged. -
All-or-nothing. A string mixing rooted and unrooted markers (
"Hi {{ name }}, {{ vars.c }}") is passed through whole and unchanged — the rooted marker{{ vars.c }}is not resolved on its own. Partial resolution would silently delete the unrooted{{ name }}marker from the output, and the grammar has no escape sequence an author could use to get it back; leaving the whole string alone is the only reading that never destroys characters the author wrote. -
The root set is CLOSED and fixed — it is not “whatever roots the host happens to have populated for this pass”. This is load-bearing: an open set makes which vendor placeholders survive depend on the host running the workflow, not on the workflow’s own text. The closed set is exactly the eight members of the exported
RunScopetype, which this RFC and its companions declare across three files and which this section gathers into one list:vars,steps,trigger— Expression markers of this RFC;secrets,documents— Amendment — 2026-08-11 § Run-scope roots, whose closing sentence — “Any further root a host populates for a particular pass is neither declared nor forbidden here; it remains unspecified by this RFC” — is narrowed here: an unspecified root is not a resolvable root for the rule in this section, whatever else it may be for a particular host’s pass;inputs,output— the Function RFC’s widenedRunScope(rfcs/function.md:226-243);foreach— the Engine RFC’s@w6w/control·foreachcontrol, which addsforeach.itemandforeach.indexto the sub-block’s run scope (rfcs/engine.md:210), and its Conformance item 6 (rfcs/engine.md:288).
The exported
RunScopetype in@w6w/workflowis canonical for this list: it is the single source of truth for which roots exist, and a future member added to it extends this rule’s root set automatically — no separate list to keep in step. -
Marker-kind handling, per the shared
{{ }}grammar of Render mode, and why rendered content cannot reach a secret:{{ =<JSONLogic> }}and{{ secrets.<name> }}are always ours, and a path marker qualifies as rooted iff its first dot-segment is one of the eight names above. Parsing runs in editor mode — the same mode an authoring tool round-trips anExprValuethrough — so a typed{{ secrets.API_KEY }}inside a plain string behaves identically to the same reference expressed as an inserted secret chip in anExprValue.
§ Why root-anchored — the constraint, stated in the spec. Root-anchoring is not a simplification the engine happens to make; it is what keeps this rule from
silently corrupting data that has nothing to do with @w6w/workflow’s own expression grammar. Of the
121 distinct {{ … }} spellings measured across the first-party app pack, 116 are vendor-side
placeholders that must reach the vendor verbatim — Mailjet’s {{var:name}}, Mandrill’s Handlebars
merge tags, Metabase’s SQL {{tag}} filters, Google Slides’ template placeholders, Apify’s actor
input templates, and lemlist’s personalization tags, among others. None of those names a run-scope
root. An unconditional string arm — resolve every {{ … }} it finds — would evaluate each of the 116
against a scope that has no matching key and silently delete it, and the grammar defines no
escape sequence an author could use to opt a vendor placeholder back out. Under the rule in this
section, every one of the 116 stays literal, because none of them is rooted; only the small set of
markers an author actually wrote against vars, steps, trigger, and the other five roots
resolves.
§ Resolved semantics. One normative clause each:
- Interpolation inside a larger string is allowed —
"Bearer {{ vars.token }}"is a valid, resolvingwithvalue; the rule does not require the marker to be the whole string. - The result is always a single string, inheriting An
ExprValuealways resolves to a single string:"{{ vars.count }}"withcount: 5resolves to"5", not the number5. An author who needs the typed value rather than its string form uses theExprValueenvelope’svarpart,{ "$": … }, or{ "$expr": … }instead. - A reference that resolves to nothing contributes the empty string, exactly as a
varpart does — an absent or unresolved path is not an error here. - A
{{ secrets.<name> }}naming no available secret fails the step, exactly as asecretpart does — this is the one marker kind for which “resolves to nothing” is a failure, not an empty string. - An unterminated
{{— no matching}}before the string ends — makes the whole string literal, and it is returned byte-identical to what was written, not re-concatenated to the same characters through a resolve-and-rejoin path that happens to reproduce them. {{ }}(an empty marker, naming no path) names no root, so the string carrying it is literal under the iff rule in clause 1 above — an empty marker is not itself a “root-anchored” marker.- There is no escape mechanism, and none is added by this amendment. A string that must deliver a
literal
{{ vars.x }}to an app — as opposed to having it resolved — is expressible only through theExprValueenvelope’stextpart, which never parses its content for markers. This is a known gap of the grammar, deliberately left open rather than an oversight: inventing an escape syntax such as\{{is itself a grammar change, tracked separately.
§ Reach beyond this RFC. This rule governs every with value the shared resolveWith walks, and several of those clauses live
in RFCs this section may not edit; each is read as incomplete rather than exhaustive, governed
here, exactly as the passages reconciled above:
- the Function RFC’s
impl.withfield row (“Each value is a literal or an expression marker”,rfcs/function.md:206) andoutputMaprow (rfcs/function.md:207) — both resolved by the sameresolveWiththe Function RFC’s own Adapter section names; - the Endpoint RFC’s
actionarmwith(rfcs/endpoint.md:358, sameresolveWith) — its “withomitted ⇒ the inbound payload is passed to the action as its params unchanged” rule is about an absentwithblock and is unaffected by this section, which only governs a string value that is present; - the Node Types RFC’s “literals pass through” clause (
rfcs/node-types.md:215) and its@w6w/documentkeyparam, “a literal string is the degenerate case” (rfcs/node-types.md:388); - the Auth RFC’s
connectionLabel: "{{user.name}} — {{team.name}}"(rfcs/auth.md:83) is unaffected, and named as such so it is not mistaken for a case this rule reaches: it is a connection-label template, never awithvalue, anduser/teamare not run-scope roots — it stays literal under this rule twice over, once because it is not awithvalue and once because neither of its markers names a root.
§ Conformance (additive). A host that implements this rule MUST:
- Resolve a string
withvalue iff it contains at least one{{ … }}marker and every one of its markers names one of the eight run-scope roots; otherwise pass it through unchanged. Testably: for every string containing only rooted markers, resolving it MUST differ from the input; for every string containing no marker, or containing at least one unrooted marker, resolving it MUST produce the byte-identical input string. - Treat a mix of rooted and unrooted markers as all-or-nothing. Testably: a string containing both a rooted and an unrooted marker MUST resolve to the byte-identical input — the rooted marker MUST NOT be substituted while the unrooted one is left as literal text.
- Pass a string through byte-identical when it carries no
{{ … }}marker, an unterminated{{, or an empty{{ }}marker. Testably: each of these three inputs, resolved, MUST equal itself. - Resolve a rooted string to a single string result. Testably: a string whose sole marker resolves to a non-string value (number, boolean, object, array) MUST resolve to that value’s string form, not the structured value.
- Contribute the empty string for a rooted path marker that resolves to nothing. Testably: a
rooted marker naming a path absent from the run scope MUST resolve to
"", not fail the step. - Fail the step for a
{{ secrets.<name> }}marker naming no available secret. Testably: such a marker MUST NOT resolve to the empty string or to the literal text. - Treat the eight-member
RunScoperoot set as closed for this rule:vars,steps,trigger,secrets,documents,inputs,output,foreach. Testably: a marker whose first dot-segment is none of the eight MUST leave the whole string unresolved, whatever else the host’s ambient scope for that pass happens to carry.
Amendment — 2026-08-20: infix fallback operators in {{ }}
This section is additive to Render mode, and why rendered content cannot reach a secret and to Amendment — 2026-08-14: root-anchored template strings in
withvalues. It adds one new production to the{{ }}grammar — a flat fallback chain spelled with the infix operators||and??— and that production exists in editor mode only. It changes no existing production, no field, and no run-scope root, and 0 lines are deleted from this file.Reconciled by grep, not memory (
/usr/bin/grep -n '=<JSONLogic>\|two productions\|Productions' rfcs/workflow.md, run against the pre-amendment text): the Mode/Productions table header (:634) and its two rows (:636 editor, :637 render); the sentence “Render mode’s grammar has exactly two productions, and neither one is a secret reference” (:639); and root-anchoring’s marker-kind handling, item 4 (:815-817). Every one of those five hits is addressed below, and none is edited or deleted. The table header (:634) and the editor row (:636) are read, from this amendment forward, as incomplete rather than exhaustive — they enumerate the two productions that predate this amendment, not the third this section adds — exactly the reading every prior amendment in this RFC gives the passages it extends. The render row (:637) and the two-productions sentence (:639) receive no such reading: they stand exactly as written, unweakened and exhaustive, because the new production is deliberately absent from render mode — see below. The marker-kind list (:815-817) is reconciled in full in its own section below. Where this section and any of the reconciled passages disagree about the editor-mode grammar, this section governs.
The production — editor mode only
Editor mode gains a third production, alongside the two the Mode/Productions table already names:
| Marker | Meaning |
|---|---|
{{ A || B || … }} | A flat fallback chain, left-to-right, JSONLogic or semantics (truthy short-circuit). |
{{ A ?? B ?? … }} | A flat fallback chain, left-to-right, absent-only semantics — null/undefined short-circuit; 0, "", false, [] are kept, never treated as absent. |
Both parse to the existing expr part kind that Amendment —
2026-08-11
already declares — { "kind": "expr", "expr": { "or": [...] } } for ||, { "kind": "expr", "expr": { "??": [...] } } for ??. No new part kind and no @w6w/types change. || reuses JSONLogic’s
existing or operator unchanged; ?? is a new @w6w/expr evaluator operator. Operator semantics —
per Expression markers’s own governing sentence, “Expression semantics …
are specified by @w6w/expr” — are specified there, not by this RFC; this section pins the marker
grammar and the part shape only.
The arm fires only for a genuinely flat chain, and refuses outright — falling through to the
ordinary <path> production instead of building an expr part — the moment any operand begins
secrets., or the chain mixes || and ??, or any operand is empty. That refusal is a property of
the parser, not a filter: a chain naming a secret operand is never built, so there is no expr part
in existence for a downstream evaluator to mishandle. This matters for the reason Run-scope
roots already gives for the ordinary secret part production: expr parts
evaluate against the run scope with secrets removed, so a hypothetical
{"or":[{"var":"secrets.K"},"x"]} would not throw or leak — it would silently resolve secrets.K to
nothing and take the fallback branch, the same invisible-failure shape this RFC elsewhere closes by
construction rather than by filter. Refusing to build the chain at all is that same discipline applied
here.
Render mode: still exactly two productions
The new production does not exist in render mode. The Productions table’s render row (:637) and
its governing sentence — “Render mode’s grammar has exactly two productions, and neither one is a
secret reference” (:639) — stand exactly as written, unweakened, and exhaustive: render mode
still has only the =<JSONLogic> production and the <path> production. parseTemplate dispatches
the chain arm mode-independently — exactly like the = arm — but the arm only ever builds an
expr part carrying ordinary JSONLogic, a payload render mode’s existing expr row already covers;
nothing new is added to render mode’s own table. A rendered {{ secrets.K || "x" }} is refused by the
operand rule above before it ever becomes a chain, and falls back to the ordinary <path> production,
which in render mode resolves against the secrets-free root exactly as every other path does.
Render mode’s “yields zero parts of kind secret” guarantee (:715) therefore continues to hold by
construction, over one more editor-mode production than existed when that guarantee was written — not
by a filter over the chain’s output.
Root-anchoring: a chain is validated per operand, all-or-nothing
Amendment — 2026-08-14’s
root-anchoring rule, item 4 (:815-817), enumerates marker-kind handling for the two productions that
existed when it was written: {{ =<JSONLogic> }} and {{ secrets.<name> }} are always ours, and
a path marker is rooted iff its first dot-segment is one of the eight run-scope roots. That list
did not, and could not, know about the chain production added here; it is read, from this amendment
forward, as incomplete rather than exhaustive on that point, for the same reason the Productions
table is.
A ||/?? chain is always ours, in the sense item 4 already uses for =<JSONLogic> — an author
who typed one of these operators wrote it against this grammar deliberately, and root-anchoring does
not reject the marker kind. But unlike =<JSONLogic>, whose payload is opaque and wholly exempt
from root-anchoring, a chain’s operands are var paths written inline, in the same syntactic position
an ordinary root-anchored <path> marker occupies. So: a chain resolves under the root-anchoring
rule iff every one of its operands that is a { "var": … } reference names a rooted path (its first
dot-segment one of the eight names item 3 lists) — literal operands impose no constraint of their own
— otherwise the whole chain is refused, mirroring clause 2’s all-or-nothing treatment of a plain
string that mixes rooted and unrooted markers. Testably: {{ vars.a || badroot.x }} is refused in
full, not just at its second operand — a check that only inspects the first operand under-enforces
this rule the same way a check that only inspected a string’s first marker would under-enforce clause
2.
@w6w/expr exports three pure predicates that make this rule mechanically checkable without a
consumer re-implementing the chain-shape walk: parseCoalesceChain (recognises the flat-chain shape
of a JSONLogic payload), coalesceOperandRefs (the var refs of a recognised chain’s operands, in
order — the set a consumer root-checks), and hasRefusedChainToken (true when a var ref still
carries a top-level ||/?? token — i.e. a chain the parser itself refused to build, which a
consumer’s existing path-rootedness check must also refuse, not silently pass as an ordinary unrooted
path).
Conformance (additive)
A host that implements the {{ }} grammar MUST:
- Build the chain production only in editor mode, and only for a marker containing exactly
one of
||/??at the top level (not inside a JSON string operand), two or more non-empty operands, and no operand beginningsecrets.. Testably:{{ vars.a || vars.b ?? vars.c }},{{ secrets.K || "x" }}, and{{ vars.a || }}each parse to a singlevarpart carrying the original text, never anexprpart. - Parse the identical marker text the same way in render mode as in editor mode — the arm is
mode-independent — while never causing render mode to gain a production it did not have before this
amendment: the result is always an
exprpart, the one production render mode already has a row for. Testably: parsing any content string T in render mode continues to yield zero parts of kindsecret, exactly as Conformance already requires. - Serialize a recognised flat-chain
exprpayload back to its infix{{ A || B }}/{{ A ?? B }}form — a{ "var": … }operand as its bare path, any other operand as its JSON text — and fall back to the existing{{ =<raw> }}form for every other JSONLogic payload. Testably: round-tripping a hand-written{{ ={"or":[{"var":"vars.a"},"x"]} }}promotes to{{ vars.a || "x" }}, the same class of promotion this RFC already documents for avarref that happens to startsecrets.. - Treat a chain as always ours for root-anchoring purposes, but resolve it under that rule iff
every
{ "var": … }operand names a rooted path; refuse the whole chain, all-or-nothing, otherwise. Testably:{{ vars.a || badroot.x }}is refused in full.
Amendment — 2026-08-21: the run-level error handler (Workflow.retry/onError/reroute)
A Workflow gains the same three optional fields a Fn and an Endpoint already carry
(RetryPolicy, CallableOnError, ErrorReroute — see rfcs/function.md and
rfcs/endpoint.md), read once a run reaches a terminal failed status:
interface Workflow {
// …
retry?: RetryPolicy;
onError?: CallableOnError;
reroute?: ErrorReroute;
}
Why the workflow needed one at all. The graph already has two failure mechanisms, and neither
is about the run: Step.retry/Step.onError decide the fate of ONE step, and Edge.when: "error"
routes to another step. Both live inside a run and both are spent by the time the run itself
fails. Nothing answered “and what if the whole thing fails?” — which is the question the other two
runnable resources had already answered, in a form authors had already learned.
Additive by construction. All three fields absent — which every workflow written before this
amendment is — reproduces today’s behaviour exactly: one attempt, no re-dispatch, the run keeps its
failed status. manifestVersion is unchanged and no migration exists.
Semantics
retryre-runs the whole graph, from the top, up tomaxAttemptstotal, with the same backoffStep.retryuses. It is not a resume: steps that already SUCCEEDED on the previous attempt run again. That is the honest reading of “retry the run”, and it is why the field is documented as the wrong tool for a non-idempotent workflow —Step.retryis the right one there.rerouteis consulted afterretryis exhausted, dispatched exactly once, through the same call-depth-bounded seam a@w6w/callstep uses, so a reroute chain is bounded like any other composition hop. Itswithresolves against{ inputs, error, trigger }, whereinputsis the run’s own variables seed. The target’s output becomes the run’s output.onErroris consulted last."fail"(and absent) leaves the run failed."continue"resolves the run with anulloutput.- Either handled outcome flips the run to
succeededand appends the original failure toRunState.stepErrors— the same channel a step-levelonError: "continue-record"already surfaces a swallowed failure through. A handled run is therefore never a silent one. - A
queuedrun (awaitsuspend) and acanceledrun are not failures: neither is retried and neither reads the policy.
Where it is read
At the host’s single run choke point, not at any caller. A run may be started by HTTP, the
scheduler, the event dispatcher, a @w6w/call step, or the queue worker; the author asked for the
policy to hold for the RUN, so reading it at any one entry point would leave the others unhandled.
This is the one place the workflow’s policy differs from a Function’s in shape rather than in spelling: a Function’s policy is read by its caller, because a Function call IS the caller’s frame.
A queue reclaim is not an authored retry. A host that redelivers a crashed run (its own
dead-letter bound) MUST NOT also run the authored retry ladder on that resumed execution, or the
two multiply.
Conformance (additive)
A host that implements this amendment MUST:
- Reproduce pre-amendment behaviour byte-for-byte when all three fields are absent. Testably: a
failing run with no policy drives the graph exactly once and returns
status: "failed". - Drive the graph
maxAttemptstimes, and no more, for a run that fails every attempt. Testably:retry: { maxAttempts: 3 }produces exactly three run-levelstatus: "running"transitions. - Resolve the run’s seed once for the whole ladder, not per attempt — an attempt re-runs the graph, it does not re-resolve the world around it.
- Never run the authored ladder on a resumed execution.
- Leave the persisted run row agreeing with the result the caller was handed: a handled failure
is
succeededin both, with the original error instepErrorsand the run-levelerrorcleared. - Leave the run failed, and propagate, when the reroute target itself fails. Reporting
succeededbecause the failure handler also failed is the one outcome no author could have meant.
Amendment — 2026-09-02: the Workflow key field
This section is additive to the Workflow field table above; it introduces no breaking change and no new host primitive. It adds one optional field,
key, and corrects one pre-existing sentence aboutnamethat was never true of any host. Enumerated by grep, not memory —/usr/bin/grep -n -i 'machine name\|unique' rfcs/workflow.md, run against the pre-amendment text, finds exactly three hits::100(thenamerow — corrected below, its host-wide-uniqueness claim removed because no host has ever enforced it),:113(Step.id— left alone, because it is a different concept: a step’s machine name is unique only within that workflow, never account-scoped, and no Step ever claims uniqueness against another Workflow’s steps), and:142(WorkflowVariable.key— left alone, because it is also a different concept: a variable’s reference name insidevars.<key>, scoped to the one workflow that declares it, not an account-wide address). The newkeyrow this amendment adds to the Workflow table is this section’s own insertion, not one of the three grep hits above (the grep was run against the pre-amendment text). The rest of this RFC, outside thenamerow and the newkeyrow, stands unedited.
A Workflow gains an optional key — the same machine-readable identifier a
Function and an Endpoint already
carry, minus the two things that make theirs mandatory: neither a Function nor an Endpoint can exist
without one, because both are addressed by it; a Workflow is addressed by neither name nor key,
so nothing forces one to exist.
Grammar. When present, key matches /^[a-z][a-z0-9-]{2,38}/ — a lowercase letter first,
lowercase letters/digits/hyphens after, 3 to 39 characters — plus two rules the regex alone does not
express: no -- anywhere, and no trailing -. _ is deliberately illegal; it is not a legal DNS
label character. This is the same isAccountSlug grammar a Function’s key already uses.
Uniqueness — (account, key), partial. A host MUST enforce uniqueness on (account, key),
exactly as Endpoint’s (account, key) rule
does, and refuse a colliding save as a caller-visible conflict. Unlike a Function’s and an
Endpoint’s key, which are always present, a Workflow’s key is optional (above), so the
enforcing index is partial — where key is not null — rather than total: two Workflows with no
key never collide with each other or with anything.
Validated once, on first assignment. The grammar is checked only at the moment a key moves
from absent/null to present, never again on a later save. A value that is already stored can never
be made un-saveable by a later tightening of the grammar. A Function gates this on !previous — the
row itself is new, because a Function always has a key from creation. A Workflow’s key is
optional, so its creation and its first key-assignment are two different moments; the gate here is
therefore the stored key being null, not the row being new.
Endpoint is the contrast, not a case this amendment touches. An Endpoint’s key is required and
immutable after first save, because it is baked
into the address /invoke/{account_slug}/{key}. A Workflow has no such address, so this amendment
pins nothing about whether a Workflow’s key may change after it is first set — only that the
grammar is not re-checked when it does. rfcs/endpoint.md itself is unedited by this amendment.
Corrected: name is not unique
The name row’s pre-amendment sentence claiming host-wide uniqueness was never true of any host
implementation — no uniqueness constraint on name exists, and the field that sentence meant to
describe is key, this section’s own subject. name keeps its pre-existing meaning and requiredness
unchanged: a required, human-authored, lowercase-kebab-case label. Only its uniqueness claim moves,
from name to key.
Conformance (additive)
A host that implements this amendment MUST:
- Enforce
key’s grammar —/^[a-z][a-z0-9-]{2,38}/, no--, no trailing-— only at the moment anull/absentkeyis first set to a non-null value. - Enforce
(account, key)uniqueness with a partial index over non-nullkeyvalues, refusing a colliding save as a caller-visible conflict. - Never re-validate a stored
keyagainst the grammar on a save that does not itself changekey. - Never enforce uniqueness on
name.
Amendment — 2026-10-04: RetryPolicy well-formedness and the per-wait ceiling
This section is additive to the RetryPolicy table above. It adds no field and changes no policy’s shape. It pins two things the table left open — what shape a host accepts and which step kinds ignore
Step.retry— and it caps a third the table left unbounded: the table’s exponential doubling has no limit, and this section bounds each computed wait. That ceiling is its one behaviour change to a well-formed policy: a computed wait above 300000 ms is cut to 300000 ms. It qualifies every site that reusesRetryPolicy, enumerated by grep —/usr/bin/grep -n 'RetryPolicy\|"retry"\|backoff' rfcs/workflow.md rfcs/function.md rfcs/endpoint.md, run against the pre-amendment text (line numbers are of that text), plus thedelayMsrow the pattern misses: the RetryPolicy table (:150-156, including thedelayMsrow’s “Exponential doubles each attempt”), the Stepretryrow (:120), the example’s"retry": { "maxAttempts": 3, "backoff": "exponential", "delayMs": 1000 }(:74), the 2026-08-21 amendment’sWorkflow.retry(:1038) and itsretrybullet, “with the same backoffStep.retryuses” (:1056-1057), and theretryrows of function.md (:483) and endpoint.md (:793), each of which “Reuses the workflow Step’sRetryPolicyverbatim” and so inherits this section without an edit of its own. Which failures are retried — the “retryable” paragraph under RetryPolicy — is outside this amendment and unchanged by it.
Well-formedness
A RetryPolicy is well-formed when:
maxAttemptsis an integer >= 1. It carries no upper bound: the per-wait ceiling below, not an attempt cap, is what keeps a long ladder from waiting unboundedly between two attempts.delayMs, when present, is a finite number >= 0.backoff, when present, is"fixed"or"exponential".
An absent or null retry is not a malformed policy: it means no retry — one attempt, exactly
as the Step retry row’s “Defaults to no retry” already says.
A host MUST reject a malformed policy on write, with a 400 whose error names the offending
field’s path, wherever a RetryPolicy can be saved: Step.retry (on every step of a saved
workflow), Function.retry, Endpoint.retry, Workflow.retry, and a spec-document import that
carries any of them. A malformed policy is refused at the door rather than discovered at run time,
where it would otherwise fail the run or be silently coerced into something its author did not
write — either way for a reason its author could have been told about when saving it.
The per-wait ceiling
Any single computed wait between two attempts is capped at 300000 ms (5 minutes): the host waits
the smaller of the wait the policy computes and 300000 ms. The cap applies to every author-configured
retry — Step.retry, Function.retry, Endpoint.retry, Workflow.retry — and to the platform’s
own trigger delivery constant (trigger.md’s Retry backoff). It bounds
each computed wait, not the ladder or the clock: a policy with many attempts still makes every one
of them, with no computed wait between them longer than five minutes. The actual spacing between two
attempts can be longer — an attempt takes time of its own, and the trigger dispatcher’s wait is a
minimum (it claims an event again only once its wait has elapsed), not a deadline.
@w6w/call and @w6w/control steps
These two step kinds run no retry loop, as the 2026-07-29 amendment’s “Retries come first”
paragraph already states (see Amendment — 2026-07-29: failure-conditioned
edges); that paragraph governs. A
retry declared on such a step is still subject to the well-formedness rules above, but it is never
read. Authoring tools SHOULD NOT offer retry on these step kinds.
Conformance (additive)
A host that implements this amendment MUST:
- Reject, on write, a
RetryPolicywhosemaxAttemptsis not an integer >= 1, whosedelayMsis present but not a finite number >= 0, or whosebackoffis present but neither"fixed"nor"exponential"— with a400naming the offending field’s path — at every save site listed above. - Accept an absent or
nullretryas no retry, and accept any integermaxAttempts>= 1 with no upper bound. - Never compute a wait longer than 300000 ms between two attempts of any retry ladder, authored or platform-owned: cap every computed wait at 300000 ms.
The rest of this RFC — the RetryPolicy table, the example, the 2026-08-21 amendment and the “Retries come first” paragraph — stands unedited, apart from the one pointer line to this section added under the RetryPolicy table; this section qualifies that text rather than replacing it.
Amendment — 2026-10-07: Step.fanOut and parallel scheduling
Additive. Adds one Step field and the scheduling semantics behind it. Nothing above is edited; this section supersedes the following base text, which is left in place:
- the Step table row for
ports(“Omitted ⇒{ in: 1, out: 1 }”) — the default is now{ in: 1, out: "many" }, see the Node Types RFC 2026-10-07 amendment;- the sentence “v0 executes them sequentially; parallel scheduling of independent branches is an engine-level enhancement that consumes the same plan” — a step opts into parallel execution of its successors with
fanOut: "parallel"; absent, execution stays sequential.Open question 1 (a workflow-level default concurrency) remains open.
fanOutis the per-step answer; it does not decide the workflow-level one.
Step field
| Field | Type | Req | Description |
|---|---|---|---|
fanOut | "sequential" | "parallel" | ⬜ | How the step’s direct successors run. Absent ⇒ "sequential". Governs the successors on both lanes (the normal lane and the Edge.when failure lane). |
Semantics
- Absent / sequential. A plan containing no
parallelstep executes exactly as before: the plan’sorder, one step at a time. Engines MUST keep this path observably identical. - Strands. Otherwise the engine runs strands. One strand runs the roots. A strand runs one step at a time — always the earliest-in-plan-order step that is ready and assigned to it. A step is ready when every inbound edge is resolved (its source reached a terminal status, or the edge was skipped).
- Opening strands. When a
parallelstep completes, its newly-ready direct successors, in plan order, S1…Sk: S1 continues the strand, S2…Sk each open a new strand. The newly-ready successors of a sequential step stay on its strand. - Join once. A step with indegree > 1 runs once, on the strand of its earliest-plan-order inbound source. There is no concurrency cap.
- Checkpoints are serialized: one call in flight at a time, in completion order.
RunStatePatchis unchanged. - Drain. On a plain
fail, awaitsuspension or an abort the engine stops launching new steps, lets in-flight steps finish, then finalizes. - Deterministic reporting. The reported error is the first failure by plan order. A suspension
returns one token — that of the first suspended step by plan order; other
waits re-run after resume.lastOutputis the output of the last succeeded step by plan order. These do not depend on timing. - Replay.
fromreplays that step plus all its graph descendants; the positional rule (“that step and every later plan step”) is the special case for a sequential plan. - Ports.
fanOutandports.outare independent:outbounds how many edges may leave a lane (enforced by editor and import only);fanOutdecides how those successors run.
Amendment — 2026-10-12: step execution metadata (steps.<id>.meta)
Additive. This section adds an optional, host-supplied record of which app version, action and stored connection a step actually ran with, readable beside the step’s
output. It edits no sentence above and adds no run-scope root. It qualifies the base text enumerated by grep —/usr/bin/grep -n 'steps\.\(<\|[a-z]\)\|StepExecution\|RunScope\|{ output' rfcs/workflow.md rfcs/engine.md, run against the pre-amendment text (line numbers are of that text). In this file: the checkpoint sentence (:47), thesteps.fetch.output.itemsexample (:82), the Stepidrow (:114), theRunState.stepsrow (:206), the StepExecution table (:212, which gains the optional fieldmeta), Replay (:235), the marker table’s path-sugar row (:243), theRunScopeblock (:253) and itsstepsline (:255, whose entry shape is widened to{ output; meta? }), the pointer line closing Expression markers (:261), the failure-lane paragraph that retains the failed step’sStepExecution(:424), the 2026-08-11 amendment’s governing clause (:535), marker table (:558) andsteps.<id>.output.contentsentence (:594), its Run-scope roots section (:673), and the 2026-08-14 amendment’s eight-memberRunScopelist and its canonical-type rule (:802,:810,:815) and closed root set (:901). In engine.md:engine.md:56(replay logic),engine.md:62(credentials and connections — reconciled explicitly below),engine.md:170(failed step retained),engine.md:194(ifoutput),engine.md:210(foreachsub-block records),engine.md:264andengine.md:274(aggregate),engine.md:286(invariant 4, replay),engine.md:292(error routing) andengine.md:362(mergeoutput). It also clarifies the Concept sentence “Whatever the action returns becomes the step’soutput” (:41):outputremains exactly the action’s return value, andmetais never part of it. The Action, Endpoint, Function, Node Types and Trigger RFCs inherit this section without an edit of their own. Every line above stands unedited, and no enumerated line is excluded from this amendment’s reach.
Shape
interface StepMeta {
app: string; // canonical app id
version: string; // the app version the host actually loaded
action: string; // the executed action key
connectionId: string | null; // the STORED connection id used; null when none
}
StepExecutiongains an optional fieldmeta?: StepMeta.- A scope entry becomes
steps: Record<string, { output: unknown; meta?: StepMeta }>. Thestepsroot is the same root;metalives under it, so{ "$": "steps.fetch.meta.version" }and{{ steps.fetch.meta.action }}are ordinary paths. - The host reports meta through an optional member of the
invokerequest onWorkflowContext:onExecuted?: (meta: StepMeta) => void.invokestill returns the action’s output, unchanged.
Fields
app— the canonical app id the step invoked.version— the app version the host actually loaded to run the step. It is not the workflow’s revision. Version-selection policy is unchanged by this section and aStepgains nouses.versionfield.action— the executed action key.connectionId— the stored connection id used for the invocation. It isnullwhen the step used no connection, a host-synthesised credential, or an inline credential.
Ownership and allowlist
- Meta is host-supplied only, through
onExecuted. An engine MUST NOT read it from the valueinvokereturns, and MUST NOT sniffmetaoroutputkeys in action data: an action whose own output has ametafield never collides withsteps.<id>.meta. - A
StepMetahas exactly these four keys; an engine drops anything else it receives. Meta MUST NOT carry credentials, tokens, headers or connection objects. - A connection id is data and grants nothing. This is consistent with engine.md “the engine never sees a credential or a connection”: the engine still sees no credential and no connection object, performs no decryption, and cannot use an id to obtain one; an opaque id string is an identifier the host chose to report, nothing more.
- Normalisation. The engine normalises what a host reports: a missing or non-string
connectionIdbecomesnull; a report lacking a stringapp,versionoractionis discarded and the step records no meta. A report whoseapp,versionoractionis not a string of at most 256 characters is likewise discarded; aconnectionIdthat is not a string of at most 256 characters becomesnull. A string of exactly 256 characters is kept; 257 is over the cap. - Within one attempt the host SHOULD call
onExecutedat most once, no later than the momentinvokesettles. If it calls more than once, the engine keeps the FIRST report and ignores later ones. This holds even when the first report is discarded by the normalisation above: the attempt then records no meta, and a later call in that attempt does not replace that outcome. Aninvokethat settles without calling it yields a step with no meta; that is not an error.
Visibility
- A succeeded app step publishes
metabesideoutputin the expression scope before downstream steps are evaluated. - A failed step MAY record its last attempt’s meta on its
StepExecution. Its scope entry stays exactly as it was before this amendment and carries nometa. - Skipped steps carry no meta. So do
@w6w/*host-internal steps, Function and Workflow callable steps, and engine control steps: they are not an app version run against a stored connection, and neither side reports for them (an engine need not attachonExecutedto a@w6w/*app id).
Absence
Absent meta reads like any absent path: { "$": "steps.<id>.meta.version" } and JSONLogic var
yield null. Records written before this amendment stay valid. Nothing is backfilled and no null
filler stands in for a missing meta. meta lives under the existing steps root; the closed root
set declared by the 2026-08-14 amendment is unchanged, and this section declares no new root.
Retries, recovery and concurrency
- Meta describes the attempt that produced the output. Each attempt has its own callback; a report from a superseded attempt is ignored.
- A checkpoint persists
metawith theStepExecutionit belongs to. - Resume and replay reuse the stored
metawherever they reuse the storedoutput; a step that is executed again captures fresh meta. - Parallel strands and
foreachiterations never share a callback: each invocation gets its own. - A step inside a sub-block publishes
metainto its inner scope exactly as it publishesoutput. This section makes no persistence claim for sub-block records beyond what already holds for theiroutput.
Conformance (additive)
An engine + host pair that implements step metadata MUST:
- take meta only from
onExecuted, never from the invoke result, and keep the four allowlisted keys; - apply the normalisation above (a non-string or over-256-character
connectionIdbecomesnull; an incomplete or over-cap report is discarded) and keep the first report of an attempt; - publish
metainto scope for succeeded app steps only, and reuse stored meta wherever stored output is reused.
Amendment — 2026-10-12: whole-value bindings for json and group params
Additive. Qualifies the Expression markers section’s treatment of a
withvalue for parameters of typejsonorgroup(see Param RFC). It adds no marker, noExprValuevariant and no scope root, and edits no sentence above.
A json or group param’s entire value may be a { "$" } marker (or a { "$expr" } marker).
It resolves type-preserved — an object, array, null, boolean or number stays itself — before
validation and before invocation, exactly as a marker resolves anywhere else in with.
- The whole-output form an editor writes is
{ "$": "steps.<id>.output" }. Meta is reached the same way, e.g.{ "$": "steps.<id>.meta" }(see the step metadata amendment). ExprValueand template strings are unchanged and stay string-producing: see AnExprValuealways resolves to a single string. A structured value is reached through a marker, never through an envelope or a{{ … }}template.- Literal JSON that holds an object with a single key
$or$expranywhere is a marker. There is no escape form. - Full-run semantics are unchanged. A
$binding to an absent, skipped or failed upstream path resolves tonull, as a marker always has. When a required-param failure then traces to such a binding, an engine SHOULD name the step the binding points at in the failure message.
Conformance (additive)
A host that offers a single-step test of a workflow step against supplied state MUST:
- resolve the step’s authored
$and$exprmarkers (andExprValues) as a run would, in one pass: values produced by resolution are never re-walked, so a payload that contains{ "$": … }or{{ … }}reaches the action verbatim; - fail before invocation, naming the step, when a
$binding references a step that has no output in the supplied state. This differs deliberately from a full run, which resolves the same binding tonull: a test has no run to fall back on, and a run’s behaviour is unchanged for compatibility; - resolve a recorded
nulloutput tonull; - never hand an unresolved marker or
ExprValueto the action in ajsonorgroupparam.