w6w docs

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 trigger field 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 in with refer 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

FieldTypeRequiredDescription
manifestVersionstring✅Core spec version. "2" for the workflow model.
idstring✅Host-issued opaque id. Stable across renames.
namestring✅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.
keystring⬜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.
displayNamestring⬜Human-facing name. Falls back to name.
descriptionstring⬜One-line summary.
triggerWorkflowTrigger⬜How this workflow starts. Absent means manual-only. See Trigger RFC.
variablesWorkflowVariable[]⬜Inputs collected from the caller / trigger event and made available as vars.*.
stepsStep[]✅The graph nodes. At least one.
edgesEdge[]⬜Directed dependencies. When absent, the engine treats steps as a linear chain in declared order.
settingsobject⬜{ 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

FieldTypeRequiredDescription
idstring✅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.
usesobject✅{ app, action, connection? }. Fully names the Invocation target.
uses.appstring✅App id (registry-resolved).
uses.actionstring✅Action key within the app.
uses.connectionstring | null⬜Connection id. Required when the action’s app declares auth and the action doesn’t opt out with requiresAuth: false.
withobject⬜Param values. Each value is a literal, an object, an array, or an expression marker. See Expression markers.
retryRetryPolicy⬜How to retry on failure. Defaults to no retry.
onErrorenum⬜"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.
portsobject⬜{ 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.
notesstring⬜Free-form author notes carried on the step. Declarative only — the engine ignores it.
positionobject⬜{ 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.action unset. A separate computed validity signal or a publish/invoke-time gate is what enforces runnability, not storage rejection.

Edge

FieldTypeRequiredDescription
fromstring✅Step id.
tostring✅Step id.
whenenum⬜"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

FieldTypeRequiredDescription
keystring✅Machine name. Referenced as vars.<key>.
typeenum⬜"string" | "number" | "boolean" | "object" | "array". Advisory; hosts SHOULD validate at trigger/run boundary.
requiredboolean⬜If true and no value provided at run start, the run is rejected.
defaultany⬜Used when the caller doesn’t supply a value.

Note: WorkflowVariable is 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

FieldTypeRequiredDescription
maxAttemptsnumber✅Total attempts including the first. 1 = no retry.
backoffenum⬜"fixed" (default) or "exponential".
delayMsnumber⬜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:

  1. Reject duplicate step ids.
  2. If edges is empty or absent → the plan is steps in declared order (implicit linear chain).
  3. Otherwise:
    • Reject edges referencing unknown step ids (from or to).
    • 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).

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"
    }
  }
}
FieldTypeDescription
runIdstringHost-issued. Stable across resumes and replays.
workflowIdstringThe workflow this run instantiates.
statusenum"queued" | "running" | "succeeded" | "failed" | "canceled".
variablesobjectThe resolved input scope for this run.
stepsobjectMap of step id → StepExecution. Only completed / running / failed steps appear.
outputanyFinal output when status === "succeeded". Optional.
errorStepErrorTerminal 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

FieldTypeDescription
stepIdstringThe step this record is for.
statusenum"pending" | "running" | "succeeded" | "failed" | "skipped".
attemptnumber1-based attempt count. Incremented per retry.
inputobjectResolved params passed to the Invocation. Recorded for replay + debugging.
outputanyAction’s return value. Present on succeeded.
errorStepErrorPresent on failed.
startedAt, finishedAtISO-8601When 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

FieldTypeDescription
codestringMachine code (e.g. unknown_action, network_error, param_invalid).
messagestringHuman-facing message.
phasestringInvocation phase the error came from: "resolution" | "auth" | "execute" | "output".
retryablebooleanEngine’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:

MarkerMeaning
{ "$": "steps.fetch.output.items.0.title" }Path lookup sugar over the run scope.
{ "$expr": <JSONLogic> }Full JSONLogic evaluation over the run scope.
plain object / arrayResolved recursively — nested markers evaluated in place.
anything elseLiteral 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;
}
MemberRequiredNotes
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 edges executes steps in declared order.
  • Checkpoint durability — a RunState read via state.load() after a process crash reflects the last successful state.checkpoint().
  • Retry classification — phase: "auth" errors are never retried; phase: "execute" errors are retried only when idempotent or retryable: true.
  • Replay determinism — replaying a run yields the same terminal status and, for succeeded steps, the same output, without calling execute again.
  • Expression scope — the engine populates vars, steps, and trigger in every expression evaluation as specified. See Amendment — 2026-08-11: the multipart expression envelope and the render part kind, which adds the secrets and documents roots 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 onExecuted remains conformant). See Amendment — 2026-10-12: step execution metadata (steps.<id>.meta): meta is 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

QuestionResolution
Step contractA step is an Invocation. No bespoke step shape — uses fully names an (app, action, connection) triple.
Graph shapeDAG 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 semanticsReplays reuse recorded step outputs verbatim. Do not re-invoke actions.
Retry classificationRuntime-declared. Auth errors never retried. Execute errors retried only when idempotent or explicitly retryable.
Control-flow step typesModeled 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

  1. Fan-out / parallel branches — default concurrency. The parallel canonical control expresses opt-in concurrency at the step level. Do we also allow a workflow-level concurrency field that lets independent branches of the base DAG run concurrently without a parallel wrapper? Adds ergonomics; adds engine responsibility.
  2. Variables convergence with Param. WorkflowVariable today is a shallow shape. Migrate to the full Param RFC so variables get validation, dynamic options, and dependsOn — at the cost of a manifest-version bump.
  3. Sub-workflows. Should a step be able to invoke another workflow (uses.workflow) as an alternative to uses.action? If so, how do sub-workflow retries and state nest into the parent run?
  4. Per-run TTL and cleanup. How long do completed RunState records live? Host-configurable; RFC-level default?
  5. Node placement. Should the host-level x-w6w-placement annotation — 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 for manifestVersion: "2". Breaking changes require a new RFC and a manifestVersion bump.
  • 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 Edge and 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 · if already uses. Where this section and the pre-amendment prose on onError disagree, 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:

ValueMeaning
"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.

FieldTypeRequiredDefaultDescription
settings.autoSaveboolean⬜trueWhether an authoring tool persists edits to this workflow without an explicit save action.
settings.savePositionboolean⬜trueWhether 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.viewportobject⬜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 — secrets and documents — 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-member RunScope interface under Expression markers and to the Expression scope bullet under Conformance, each of which enumerates vars, steps and trigger alone: 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/document host 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:

MarkerMeaning
{ "$": "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:

kindField it populatesResolves to
textvalue (string)The literal chunk, verbatim.
varref (path)A path lookup against the generic data root, exactly as { "$": … }.
secretref (secret name)The plaintext of that named secret, read from the secrets root. The only production in the model that may read it.
exprexpr (JSONLogic)JSONLogic evaluated against the generic data root, exactly as { "$expr": … }.
renderref (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:

  1. Resolve ref against the generic data root — the run scope minus secrets, per Run-scope roots — to a value, exactly as a var part would.
  2. 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 null or undefined, fails the step with render_ref_unresolved, naming the ref that was asked for. A path that resolves to any other non-string — an object, an array, a number, a boolean — fails the step with render_ref_not_a_string, naming the ref and the type found.
  3. Parse the resulting string as a {{ }} template in render mode (below), yielding a list of parts.
  4. 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:

ModeProductionsUsed by
editor{{ =<JSONLogic> }} → an expr part · {{ secrets.<name> }} → a secret part · {{ <path> }} → a var partauthoring tools, round-tripping an authored ExprValue to text and back
render{{ =<JSONLogic> }} → an expr part · {{ <path> }} → a var partstep 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.

RootContentsReachable through the generic data root?
secretsHost-injected plaintext, keyed by secret name.No — never.
documentsThe 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 ExprValue to a single string, including when parts holds 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. (A render part’s own ref is the one place this stringification does not apply: it requires a string, per the bullet below.)
  • Evaluate var parts, expr parts, { "$": … } and { "$expr": … } against a data root from which secrets has been removed. Testably: for every run scope S and every with block that contains no secret part, resolving that block against S MUST produce a result identical to resolving it against { ...S, secrets: {} }.
  • Read plaintext from secrets for a secret part only, directly, and never through expression evaluation.
  • Parse a render part’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 kind secret; and {{ secrets.X }} in particular MUST parse to a var part whose ref is the literal path secrets.X, and MUST therefore render as the empty string.
  • Render in one pass: the parts produced by parsing a render part’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 render part’s ref does not resolve to a string: render_ref_unresolved when the path is absent or resolves to null / undefined, and render_ref_not_a_string for any other non-string, each naming the ref. Testably: a render part whose ref names 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 text null.
  • 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 with value never resolves: the marker table’s | anything else | row (Expression markers), the Step.with field 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); the Step.with field 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 a with value 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 a with value resolves to. Its relationship to Amendment — 2026-08-11: the multipart expression envelope and the render part kind (F-3) is narrower than independence: this section builds on it — reusing the secrets and documents roots it declares — and narrows one of its sentences (below), without editing that amendment’s text or changing anything it specifies about the multipart envelope, render parts, 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.

  1. A string with value 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.

  2. 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.

  3. 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 RunScope type, 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 widened RunScope (rfcs/function.md:226-243);
    • foreach — the Engine RFC’s @w6w/control · foreach control, which adds foreach.item and foreach.index to the sub-block’s run scope (rfcs/engine.md:210), and its Conformance item 6 (rfcs/engine.md:288).

    The exported RunScope type in @w6w/workflow is 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.

  4. 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 an ExprValue through — so a typed {{ secrets.API_KEY }} inside a plain string behaves identically to the same reference expressed as an inserted secret chip in an ExprValue.

§ 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, resolving with value; the rule does not require the marker to be the whole string.
  • The result is always a single string, inheriting An ExprValue always resolves to a single string: "{{ vars.count }}" with count: 5 resolves to "5", not the number 5. An author who needs the typed value rather than its string form uses the ExprValue envelope’s var part, { "$": … }, or { "$expr": … } instead.
  • A reference that resolves to nothing contributes the empty string, exactly as a var part does — an absent or unresolved path is not an error here.
  • A {{ secrets.<name> }} naming no available secret fails the step, exactly as a secret part 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 the ExprValue envelope’s text part, 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.with field row (“Each value is a literal or an expression marker”, rfcs/function.md:206) and outputMap row (rfcs/function.md:207) — both resolved by the same resolveWith the Function RFC’s own Adapter section names;
  • the Endpoint RFC’s action arm with (rfcs/endpoint.md:358, same resolveWith) — its “with omitted ⇒ the inbound payload is passed to the action as its params unchanged” rule is about an absent with block 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/document key param, “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 a with value, and user/team are not run-scope roots — it stays literal under this rule twice over, once because it is not a with value and once because neither of its markers names a root.

§ Conformance (additive). A host that implements this rule MUST:

  • Resolve a string with value 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 RunScope root 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 with values. 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:

MarkerMeaning
{{ 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 beginning secrets.. Testably: {{ vars.a || vars.b ?? vars.c }}, {{ secrets.K || "x" }}, and {{ vars.a || }} each parse to a single var part carrying the original text, never an expr part.
  • 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 expr part, 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 kind secret, exactly as Conformance already requires.
  • Serialize a recognised flat-chain expr payload 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 a var ref that happens to start secrets..
  • 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

  • retry re-runs the whole graph, from the top, up to maxAttempts total, with the same backoff Step.retry uses. 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.retry is the right one there.
  • reroute is consulted after retry is exhausted, dispatched exactly once, through the same call-depth-bounded seam a @w6w/call step uses, so a reroute chain is bounded like any other composition hop. Its with resolves against { inputs, error, trigger }, where inputs is the run’s own variables seed. The target’s output becomes the run’s output.
  • onError is consulted last. "fail" (and absent) leaves the run failed. "continue" resolves the run with a null output.
  • Either handled outcome flips the run to succeeded and appends the original failure to RunState.stepErrors — the same channel a step-level onError: "continue-record" already surfaces a swallowed failure through. A handled run is therefore never a silent one.
  • A queued run (a wait suspend) and a canceled run 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 maxAttempts times, and no more, for a run that fails every attempt. Testably: retry: { maxAttempts: 3 } produces exactly three run-level status: "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 succeeded in both, with the original error in stepErrors and the run-level error cleared.
  • Leave the run failed, and propagate, when the reroute target itself fails. Reporting succeeded because 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 about name that 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 (the name row — 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 inside vars.<key>, scoped to the one workflow that declares it, not an account-wide address). The new key row 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 the name row and the new key row, 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 a null/absent key is first set to a non-null value.
  • Enforce (account, key) uniqueness with a partial index over non-null key values, refusing a colliding save as a caller-visible conflict.
  • Never re-validate a stored key against the grammar on a save that does not itself change key.
  • 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 reuses RetryPolicy, 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 the delayMs row the pattern misses: the RetryPolicy table (:150-156, including the delayMs row’s “Exponential doubles each attempt”), the Step retry row (:120), the example’s "retry": { "maxAttempts": 3, "backoff": "exponential", "delayMs": 1000 } (:74), the 2026-08-21 amendment’s Workflow.retry (:1038) and its retry bullet, “with the same backoff Step.retry uses” (:1056-1057), and the retry rows of function.md (:483) and endpoint.md (:793), each of which “Reuses the workflow Step’s RetryPolicy verbatim” 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:

  • maxAttempts is 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 RetryPolicy whose maxAttempts is not an integer >= 1, whose delayMs is present but not a finite number >= 0, or whose backoff is present but neither "fixed" nor "exponential" — with a 400 naming the offending field’s path — at every save site listed above.
  • Accept an absent or null retry as no retry, and accept any integer maxAttempts >= 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. fanOut is the per-step answer; it does not decide the workflow-level one.

Step field

FieldTypeReqDescription
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 parallel step executes exactly as before: the plan’s order, 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 parallel step 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. RunStatePatch is unchanged.
  • Drain. On a plain fail, a wait suspension 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. lastOutput is the output of the last succeeded step by plan order. These do not depend on timing.
  • Replay. from replays 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. fanOut and ports.out are independent: out bounds how many edges may leave a lane (enforced by editor and import only); fanOut decides 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), the steps.fetch.output.items example (:82), the Step id row (:114), the RunState.steps row (:206), the StepExecution table (:212, which gains the optional field meta), Replay (:235), the marker table’s path-sugar row (:243), the RunScope block (:253) and its steps line (: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’s StepExecution (:424), the 2026-08-11 amendment’s governing clause (:535), marker table (:558) and steps.<id>.output.content sentence (:594), its Run-scope roots section (:673), and the 2026-08-14 amendment’s eight-member RunScope list 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 (if output), engine.md:210 (foreach sub-block records), engine.md:264 and engine.md:274 (aggregate), engine.md:286 (invariant 4, replay), engine.md:292 (error routing) and engine.md:362 (merge output). It also clarifies the Concept sentence “Whatever the action returns becomes the step’s output” (:41): output remains exactly the action’s return value, and meta is 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
}
  • StepExecution gains an optional field meta?: StepMeta.
  • A scope entry becomes steps: Record<string, { output: unknown; meta?: StepMeta }>. The steps root is the same root; meta lives 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 invoke request on WorkflowContext: onExecuted?: (meta: StepMeta) => void. invoke still 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 a Step gains no uses.version field.
  • action — the executed action key.
  • connectionId — the stored connection id used for the invocation. It is null when 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 value invoke returns, and MUST NOT sniff meta or output keys in action data: an action whose own output has a meta field never collides with steps.<id>.meta.
  • A StepMeta has 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 connectionId becomes null; a report lacking a string app, version or action is discarded and the step records no meta. A report whose app, version or action is not a string of at most 256 characters is likewise discarded; a connectionId that is not a string of at most 256 characters becomes null. A string of exactly 256 characters is kept; 257 is over the cap.
  • Within one attempt the host SHOULD call onExecuted at most once, no later than the moment invoke settles. 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. An invoke that settles without calling it yields a step with no meta; that is not an error.

Visibility

  • A succeeded app step publishes meta beside output in 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 no meta.
  • 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 attach onExecuted to 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 meta with the StepExecution it belongs to.
  • Resume and replay reuse the stored meta wherever they reuse the stored output; a step that is executed again captures fresh meta.
  • Parallel strands and foreach iterations never share a callback: each invocation gets its own.
  • A step inside a sub-block publishes meta into its inner scope exactly as it publishes output. This section makes no persistence claim for sub-block records beyond what already holds for their output.

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 connectionId becomes null; an incomplete or over-cap report is discarded) and keep the first report of an attempt;
  • publish meta into 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 with value for parameters of type json or group (see Param RFC). It adds no marker, no ExprValue variant 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).
  • ExprValue and template strings are unchanged and stay string-producing: see An ExprValue always 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 $expr anywhere is a marker. There is no escape form.
  • Full-run semantics are unchanged. A $ binding to an absent, skipped or failed upstream path resolves to null, 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 $expr markers (and ExprValues) 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 to null: a test has no run to fall back on, and a run’s behaviour is unchanged for compatibility;
  • resolve a recorded null output to null;
  • never hand an unresolved marker or ExprValue to the action in a json or group param.