Skip to main content
Version: v5.6.1

Expressions

Included from Basic tier

${{ … }} spans are CEL expressions. The engine compiles every span when an action is compiled or a workflow version is published/run — an expression that does not compile is a named 422 naming the step, the expression and the position — and evaluates them cost-bounded (no I/O, no time, no randomness; evaluation past the cost limit fails loudly). This page is the full shipped surface; the orchestration page maps the language — document kinds, primitives and the integration lanes — around it.

Spans: legacy bare references vs CEL​

A span whose whole content is one bare path in the original grammar — ${{ properties.<path> }}, ${{ context.<path> }}, ${{ steps.<name>.outputs.<key> }} — keeps the exact pre-CEL behaviour: resolution by path (dashes allowed in segments), a missing path fails the step loudly, a JSON null renders as the empty string. ${{ pack.dir }} likewise stays the executor's own span and passes through to its pack-dir resolution untouched.

Every other span is CEL over the declared namespaces:

NamespaceHolds
propertiesthe step's merged properties view (project → environment → asset, most specific wins)
contextthe step context document (change runs)
stepspreceding steps' published outputs: steps.<name>.outputs.<key> (string values; composite scoping applies) — and, in when: only, a sibling's steps.<name>.status (below). At workflow scope (below) the outputs are the producing step's nested run rolled up, JSON-shaped values decode, and a fan-out parent exposes outputs.children
inputsthe run's typed inputs at workflow scope and an action's root scope; inside a composed uses: callee, that call site's with: bindings. Bound in run:/emit/set_properties spans at dispatch too, so one span may mix inputs.* with properties.* (a ternary fallback). A bound input's VALUE is inert data: it renders verbatim, never re-parsed as a template — an error.message containing ${{ passes through untouched. The rendered text is still ordinary shell source inside run:, so transport a value that may carry $, braces or quotes through a quoted heredoc (<<'EOF') rather than bare interpolation
runrun.id, run.action (a workflow run's code), run.project (the owning project's code), run.metadata
nodenode.path, node.code — the run's target
estate('<node path>')another node's converged properties, and its .assets/.environments enumeration — see below
item / indexthe current fan-out element and its position, inside a for_each: scope only (or the as: alias and <as>_index) — see below
metadatathe target node's EFFECTIVE node metadata, metadata.<family>.<field> — when: conditions only: templates, for_each: and input defaults reject it at compile (invariant I1: metadata never feeds execution). Values are inheritance-resolved with bool/integer types preserved; an unset field is absent, so guard with has() where absence is expected; an x-<org> family is not a CEL identifier, so index it: metadata['x-org'].field. Server lane only — local mode binds no metadata
errorthe failed step's recorded error (error.step, error.status, error.message) — bound only inside a desugared on_error handler's scope (below); reading it anywhere else is a loud evaluation failure

Using item/index (or an alias) outside a for_each: scope is a named compile error.

Gains over the path grammar: comparisons, boolean logic, ternaries (a ? b : c), has(), the comprehension macros (filter/map/exists/all), the CEL strings and lists extensions, and the OpsChain stdlib:

  • chunk(list, n) (also list.chunk(n)) — splits a list into consecutive sublists of at most n elements, in order; n must be a positive integer. uniq([1,2,2]).chunk(2) ⇒ [[1,2]].
  • uniq(list) (also list.uniq()) — removes duplicates, keeping first occurrences in order.

Rendering into a command string: strings splice verbatim, booleans and numbers format plainly, null renders empty, lists and maps render as JSON. Evaluation failures (a missing key, a type error, the cost limit) fail the step loudly, exactly like an unresolved bare reference.

when: conditions​

Any step — YAML action steps of every kind, and workflow steps of every type — may declare when:. The canonical shape is a bare CEL boolean expression; a single ${{ … }} wrapper is accepted and means the same thing. A text template is refused at compile: when: is one boolean, never a rendered string.

- name: snapshot
run: dbctl snapshot
when: steps.precheck.outputs.role == 'primary'

Semantics (ratified):

  • Evaluated at step entry, against the live context, by the engine's step-entry gate. One verdict per entry; a retry's re-entry re-evaluates and records again.
  • False → the step records as skipped through the existing skip machinery (its children skip with it; a sequential parent moves on), with the verdict — expression, boolean, evaluated operand values, attempt — persisted on the step row and served as the step's condition_verdicts attribute. An operand value matching the run's mask set (a sensitive step output) records as ******; evaluation itself always sees the real value.
  • True → the verdict is recorded and the step proceeds.
  • Evaluation failure → a loud step failure, with the error recorded on the verdict.
  • Verdicts persist with the step row, so restarts keep them and a restarted engine neither re-evaluates nor revives a skipped step.

What when: can see: change-run steps see properties/context/steps/inputs/run/node/estate. Workflow steps see properties, the run's typed inputs (below), run, node, estate and the workflow-scope steps.* outputs (below). A workflow change step with a static target resolves properties (for its when: and with: values alike) as the TARGET's converged view — project → environment → asset, the git-committed layers below the stored database layers, the same resolution the spawned change's own steps interpolate with — deep-merged with the run's property_overrides; wait, stage and dynamic-target steps see the project's properties deep-merged with the run's property_overrides. A workflow when: referencing an input the workflow does not declare is a named 422 at publish and at run creation. The status functions and steps.<name>.status (next section) are available in both lanes.

Status functions and failure paths​

Four zero-argument status functions are available only inside when: — in a run: command, with:, emit data or set_properties value they are a named 422 at compile. Each evaluates against the enclosing scope's state so far at the step's entry (its siblings under the same parent):

FunctionTrue when
success()no sibling has finished unsuccessfully
error()a sibling finished in error (or system_error) — an on_error: continue sibling's ignored failure does not count (see on_error below)
error('<step>')the named sibling (by reference name) finished in error/system_error, whatever the scope's own verdict — the condition a desugared on_error handler carries
cancelled()reserved: handlers never run during a user cancel in this release (holds the cancel-sweep semantics), so it always evaluates false
always()always

Every step carries an implicit success() today in behavioural terms — a failed sibling aborts the pending successors. Writing when: error() (or always()) replaces that: the step becomes a failure-path step, which the abort cascade spares and dispatches at its turn — in a sequential scope immediately after the cascade settles, in a parallel scope only once every non-handler sibling has finished (so the functions see the scope's real outcome). The recorded verdict shows the function values as operands (error()=true).

Semantics that never bend:

  • The run's status stays honest. A run whose failure path executed still finalises error; the handler's success never masks it. The run and the handler step both record the additive error_handled attribute.
  • No handlers-of-handlers. A failure inside a handler is terminal for that handler; remaining always() steps still get their turn, and the scope finalises in its error state.
  • Cancel aborts handlers. A user cancel sweeps failure-path steps like any other pending step — cancelled()/always()-on-cancel is held for its own ruling.
  • A trailing when: always() step is the finally block; there is no separate finally: syntax.

steps.<name>.status reads a sibling's status for surgical conditions: 'success' | 'error' | 'skipped' | 'cancelled' once the sibling finished (system_error reads as error, aborted as cancelled); a parallel sibling still in flight reads as its live state. <name> is the sibling's reference name — its name: (display name), else its action.

- name: rollback
run: dbctl rollback --to ${{ steps.patch.outputs.snapshot_id }}
when: error() && steps.patch.status == 'error'

Per-step retries​

retries: {attempts: N, backoff: <duration>} on a run: step (and on workflow change/workflow steps) re-executes the same step in place on error: up to attempts total attempts (attempts: 1 = no retries), backoff between them, each retry recorded on the step's retry_attempts attribute with the deadline and the policy level that drove it. The backoff deadline is persisted, so a restart honours the remaining wait. Retries exhaust first; only then does the failure roll up (ignore_failure and the failure path included). A step timeout does not auto-retry.

A step with no retries: of its own inherits the orchestration.retries settings-ladder default — global → project → environment → asset, nearest wins, resolved and frozen at run creation — with the winning level recorded as each retry's policy_source. See settings.

on_error — the inline error policy​

Action-file run: steps and workflow change steps may declare on_error:. The string form is the thin, declarative face over ignore_failure semantics:

  • on_error: fail — the default: the failure rolls up normally.
  • on_error: continue — the step's own error never fails its scope: the parent proceeds to the remaining siblings and the scope's failure accounting excludes the step (so error() stays false and the run can still finalise success), while the step itself honestly records error. One bad fan-out element never stops the sweep. A system_error is never swallowed, and a run-level cancel keeps ordinary cancel semantics.

The object form invokes a compensating action when the step fails:

- name: patch_infrastructure
type: change
target: ${{ '/projects/' + inputs.project + '/assets/infrastructure' }}
action: infrastructure:patch
retries: { attempts: 2, backoff: 30s }
on_error:
uses: infrastructure:rollback # any published action, composites included
with:
snapshot: ${{ steps.patch_infrastructure.outputs.snapshot_id }}
reason: ${{ error.message }}
target: ${{ node.path }} # workflow steps only; defaults to the failed step's target
then: fail # fail (default) | continue

Semantics, all inherited from the primitives rather than invented:

  • Desugaring, not a new execution path: at compile (action compile / workflow publish) the object form expands into a sibling handler step named <step>.on_error with when: ${{ error('<step>') }} — spared by the abort cascade, dispatched at its turn, visible in the step tree, chains and plans ("runs on failure"), error_handled recorded on the step and the run. Writing that handler step by hand remains the tool for stage-/estate-wide compensation.
  • Ordering: retries: exhaust first, then the handler runs, then then: applies. then: fail (default) keeps the honesty rule — the scope still fails and the run finalises error with error_handled recorded; then: continue behaves like on_error: continue evaluated after the handler succeeded. A handler that itself fails is terminal for the scope regardless of then:.
  • No handlers-of-handlers: a failure-path step (its when: references error()/always()) may not carry an object-form on_error, and an action invoked as a handler may not carry one on its own steps — both are named compile errors.
  • The error.* namespace binds only inside the handler's scope — its with: values, its own expressions, and everything the invoked action's steps run: error.step (the failed step's reference name), error.status (error), error.message (the recorded failure) — alongside the ordinary namespaces, the failed step's published outputs included (the saga pattern's data flow). Reading error.* outside a handler's scope is a loud evaluation failure.
  • Placement rules: in action files, only run: steps carry on_error (and the object form takes no target: — the handler runs on the step's own change); in workflows, only change steps do, and a multi-target (targets:) change step accepts the string form only — fan out with for_each: for per-element compensation.

Typed run inputs​

Workflows and top-level declarative actions declare inputs: beside their steps:

inputs:
environment: { type: enum, values: [dev, staging, production], required: true }
batch_size: { type: number, default: 2 }
dry_run: { type: boolean, default: false }
project: { type: string, default: '${{ run.project }}' }
regions: { type: list<string>, default: [ap, eu] }
token: { type: secret, description: "vault reference to the API token" }

Each declaration takes type (string | number | boolean | enum | secret | list<string>; string when omitted), values (the enum vocabulary), default, required and description. Declarations are validated where the document is validated — workflow publish, action compile — with named 422s: a bad type, an enum without values, a default that violates its own type or enum, a secret default that is not a vault reference.

  • Supply at start: opschain runs create workflow <p>/<code> -i key=value (repeatable; also --input; a repeated key builds a list, and list<string> also accepts a comma-separated value), the create APIs' inputs object, or a trigger's action_params.inputs map — whose values may carry ${{ event.* }} templates, resolved per firing (the trigger contract). CLI values travel as strings and coerce against the declared types.
  • Secret inputs take vault references only (secret-vault://<path>); a non-empty value of any other shape is refused by name. A secret's absence is the EMPTY string: a secret cannot declare default: '', so an optional secret left unset binds "" (unlike other types, which bind nothing and fail loudly when referenced), an empty value supplied for one is "not supplied" — the declared default applies or it binds "" (a required secret left empty is the missing-required refusal) — and inputs.token != '' tests whether it was given. A calling step's with: { token: '${{ inputs.token }}' } therefore passes an optional secret the caller did not set as not supplied, without refusing the nested run. The run record shows "" for it, never ******.
  • Validated before the run exists: an unknown input name, a missing required input, a type mismatch or an enum violation is a named 422 pointing at inputs; nothing is created.
  • Defaults evaluate ONCE at run creation, over the run-creation scope only: run.* (a workflow is project-owned, so '${{ run.project }}' binds the owning project's code), plus the target node.* for a change's inputs. context.*, steps.*, estate() and the status functions are step-scoped — using one in a default: is a named compile error at publish. The resolved value is recorded exactly like a supplied one.
  • Recorded and masked: the run's inputs attribute carries the resolved values; a secret-typed input accepts vault references only (secret-vault://… — a raw secret value is refused) and its recorded value serializes as ****** everywhere — run documents, the native read models, condition-verdict operands and plan payloads.
  • Secret inputs resolve at dispatch: a secret input's reference resolves through the configured secret backend (the local secret store for --local runs) when its step dispatches, so ${{ inputs.<name> }} in a run: command renders the real value inside the executing step — with the resolved value registered in the run's mask set before any output flows, on the executor and the log pipe alike. Every other surface keeps the reference: plans, the step context document, the recorded inputs, set_properties writes and emitted event data. An unresolvable reference is a failure named after the input; the literal reference is never handed to the step as if it were the value.
  • Scope and precedence: run inputs are the workflow's inputs.* namespace and the invoked action's ROOT-scope inputs.* namespace — a run-supplied value overrides the declared default there. Inside a composed uses: callee, inputs.* remains that call site's with: bindings: run inputs never leak into a callee, and with: stays the action-call namespace. A nested run does not inherit its parent's inputs — a workflow step passes them explicitly with with: (below). Supplying inputs to an action with no declarative action file is a named 422: nothing declares inputs there.
  • property_overrides coexist unchanged (a later ruling owns any deprecation).

Legacy untyped action inputs (no type:) keep their textual-splice semantics unchanged, defaults included.

with: — binding declared inputs​

with: is how an invocation supplies a callee's declared inputs:, in both composition lanes:

  • Composite actions (uses: + with:): the call site's bindings validate against the callee's declarations when the action compiles — an unknown with: name and a missing required input were always named errors; typed declarations now also type-check every literal value (a non-number for a number, an enum violation, a secret value that is not a secret-vault:// reference). A value carrying ${{ … }} stays an expression, resolved at the consuming step's dispatch with the caller's scope — which is what carries error.* into a handler's callee. Binding remains textual and per call site (P7 scoping is unchanged), and composition keeps its existing cycle detection and depth cap.
  • Workflow change and workflow steps: with: supplies the nested run's typed inputs — the same inputs object a direct runs create … -i/API call would carry. Values may be any JSON shape; string values may carry expressions over the step's entry scope (inputs.*, steps.*.outputs.*, fan-out bindings, estate(), error.* in a handler), rendered at the step's entry (snapshot-at-entry, like dynamic targets). The nested run's own creation then validates the resolved object against the invoked action's (or nested workflow's) declarations — an unknown name, a missing required input or a type mismatch is that creation's named 422, surfacing as the step's loud failure. property_overrides coexist unchanged: with: is the typed, declared lane.

estate('<node path>') — cross-node properties​

estate('/projects/core/environments/prod/assets/db-01').properties.listener.host reads another node's converged properties view (the same merge the converged-properties screen shows: per level the git-committed repository layer under its database layer — repo_common → project → environment → template layers → asset, most specific wins) from anywhere an expression runs. On the server the repository layers load from the referenced node's own pinned template identity (its pinned template version's commit), so a node with no pinned git identity — a project, an environment, a non-templated asset — converges its database layers only; locally they load at the run's own snapshot.

  • Static and dynamic paths: a literal path is extracted at compile and pre-resolved at step entry. The argument may also be a computed expression — `estate('/projects/' + inputs.project
    • '/environments/' + env)` — resolved live during the entry evaluation itself with the same RBAC and audit; a computed path that resolves to no readable node is the same loud entry failure. Dispatch-time interpolation binds only what the entry gate recorded, so entry and dispatch can never disagree.
  • Enumeration (.assets / .environments): estate(<path>).assets and .environments are the node's direct children as descriptor documents {code, name, path, properties} — each child's converged properties view, so property-tag filters (a.properties.Asset_Type == 'Data_Store') compose with the CEL macros. The lists are RBAC-filtered with the run principal's own read authority, fail-closed: a child the principal may not read never appears. Enumeration follows the snapshot-at-entry rule, and the consumed-data audit records each listed child's path and property-document version.
  • No I/O at evaluation: the engine extracts the referenced paths at compile, resolves each node's snapshot once at step entry (live resolution of computed paths happens inside that same entry evaluation), and binds the snapshots into the evaluation — the when: verdict and the dispatched command read the same instant (snapshot-at-entry, read-committed).
  • Audited: each entry records the path, the resolved values and the referenced property documents' versions on the step row; the serialized estate_reads attribute carries path + versions (plus the enumerated children's paths and versions), so staleness is diagnosable from the run record ("db-01 properties v41 @ entry").
  • RBAC at entry: the run's principal must hold read on the node's converged-properties path; a denied node is indistinguishable from a missing one (no readable node at <path>) and the step fails loudly.
  • Secrets never cross: resolution never touches the vault — a stored secret-vault://… value arrives as the reference string.
  • Ordering across runs is unchanged: tree order is the happens-before guarantee inside one workflow; across independent runs, serialise deliberately. Writes still land via set_properties/the properties API; estate() only reads.

Fan-out (for_each:)​

A step may declare for_each: — a list-valued CEL expression (one ${{ … }} span, or a bare expression) evaluated at the step's entry with the same timing and audit as when:. The step becomes a fan-out parent whose children are the expansion elements: ordinary steps of the tree, so chains, pause/resume, per-step retries, skip masks and failure handlers apply to them unchanged.

- name: apps
type: change
for_each: ${{ estate('/projects/demo/environments/' + env).assets
.filter(a, a.properties.Asset_Type == 'Software_Application') }}
as: app
run_as: parallel
target: ${{ app.path }}
action: ${{ app.code + ':shutdown' }}
  • Where it is legal: workflow change and stage steps, and action-file run: and stage steps. A wait step gates people and a nested-workflow step pins one run, so neither fans out — wrap them in a stage (a named compile error says so). A change step cannot combine for_each: with targets:.
  • What expands: a fanned-out change/run step instantiates itself once per element; a fanned-out stage instantiates its declared steps per element (several template steps wrap in a sequential stage — named by the fan-out stage's own rendered name: where it is dynamic, else after the element). run_as: parallel|sequential (a stage's own strategy: in action files) orders the expansion; sequential is the default.
  • Bindings: each child binds the element as item and its position as index. as: <name> renames them to <name> and <name>_index. Bindings reach everything the child evaluates — target:, action:, with:/property_overrides values, run: commands, the child's own when: and a nested for_each: — and propagate to every descendant of the expansion. Nested fan-outs must bind distinct names (inner shadows nothing): nesting two unaliased for_each: scopes, or reusing an alias, is a named compile error — alias at least one.
  • Dynamic child names: a template step's name: may carry ${{ }} over the fan-out bindings — name: ${{ env }}-apps, name: stop-${{ app.code }} — rendered at expansion beside the dynamic target/action, so each execution identifies its element at a glance instead of apps/apps-1/apps-2. The rendered name is what serializes and displays (the sibling dedup suffixes still apply after rendering), and the authored expression is preserved on the step's graph document (name_expression). A name that renders empty — or that references a binding the expansion does not bind, the dual-role shape where the same template step is the outer expansion's parent copy and the inner expansion's leaf — falls back to the engine-default (action) name for that copy, never a step failure: names are labels, so only target:/action: keep the loud entry semantics. Expressions compile-check at publish at their deepest binding scope.
  • Stage-copy names: a fan-out stage's own dynamic name: renders onto each expansion copy over the stage's own binding — name: backup-${{ env.code }}-databases yields copies backup-dev-databases, backup-prod-databases — while the declared parent keeps the authored literal (no element is bound at the parent). A copy's own rendered name wins where it renders; a stage name that references an out-of-scope (inner) binding, or a static one, leaves the engine-default copy naming in place — the dual-role posture is unchanged.
  • Empty expansion = recorded skip (the ruling): the step lands skipped through the ordinary skip machinery, with the evaluated expression in the audit.
  • Cap: the expansion is bounded by the orchestration.fan_out_limit settings ladder (global → project → environment → asset, nearest wins; engine default 50), resolved and frozen at run creation. A breach is a loud entry failure naming the count, the cap and the level that supplied it. See settings.
  • Audit: the parent records each expansion as the additive fan_out attribute — {expression, as?, count, cap, cap_source, evaluated_at} — appended per entry evaluation (a retry's re-entry evaluates again; children carried by a retry are re-run, never re-expanded).
  • when: on a fan-out step gates the parent, evaluated before the expansion with no element bound — a false verdict skips the step and nothing expands. Give a per-element condition to a step inside the template.
  • A non-list result is a loud entry failure, and timeout: is not supported on steps inside an action-file expansion (per-step timeouts freeze at run creation, before the children exist).

Dynamic target: and action: (workflow steps)​

A workflow change/workflow step's target: and action: (and its property_overrides values) may carry ${{ … }} expressions — typically over fan-out bindings (${{ app.path }}, ${{ app.code + ':shutdown' }}), but any entry-scope expression works (target: ${{ '/projects/' + inputs.project + '/assets/infrastructure' }}). They render and validate at the step's entry: the target must resolve to a readable existing asset (denied and missing are indistinguishable — the posture) and a dynamic action must exist in the target's own generated action catalog; each failure is a named loud entry failure. The step row then carries the resolved values, with the authored expressions preserved (target_expression / action_expression) for the audit. Plans render unresolved dynamic names symbolically (tier 2) and concretely where the inputs/estate decide them (tier 1).

Workflow-scope step outputs​

A workflow change step surfaces its nested run's published outputs as steps.<step-name>.outputs.* to every LATER workflow step — when:, for_each:, dynamic target:/action:, dynamic names and property_overrides values all read them. <step-name> is the step's reference name (name:, else its action), resolved from the consuming step's own scope outward: siblings first, then each enclosing stage's siblings (nearest claims a duplicated name; the enclosing stages themselves are excluded — their aggregate does not exist mid-flight).

  • The roll-up rule: when the nested change finalises (success or error — compensation reads a failed step's outputs; cancelled and system-error outcomes roll up nothing), the union of its ROOT-scope published outputs — composite call sites contribute their declared exports, internal steps stay invisible, exactly as a root-scope consumer resolves them — merges in tree order, a later step winning a key collision, and persists as the workflow step's own step-output rows. The implicit exit_status rolls up like any output (the last root-scope step's wins). The rows are the restart contract: a resumed run re-reads them at each later step's entry, and a run-level retry carries the rows of its carried successful steps onto the duplicate — no re-execution.
  • Value decoding: decision-058 output values are strings; at workflow scope a value that parses as a JSON object or array decodes (so steps.discover.outputs.targets.filter(t, …) composes), and the exact literals true/false decode as booleans (so has(steps.patch.outputs.reboot_required) && steps.patch.outputs.reboot_required reads naturally). Everything else — numbers included — stays the raw string: compare steps.a.outputs.count == '2', or convert explicitly with int().
  • outputs.children — a fan-out parent's aggregation: the ordered list (expansion = tree order, regardless of completion order) of the expansion's EXECUTABLE steps' outputs objects, wrapper stages and nested fan-out levels flattened so 's data-flow shapes (.map(c, c.asset_path), .filter(c, c.result == 'patched')) compose at any depth. Each element carries the step's decoded outputs spread at the top level — a key published by any sibling but not this step defaults to "", so .filter(c, c.result == 'patched') and its negation evaluate for every element, errored ones included — over the echoed fan-out element's fields (when the element is a map), under the engine keys: item/index (the parent's binding values, whatever its as: alias), name, status (authored vocabulary: success | error | skipped | cancelled, or the live state mid-flight), asset_path and code (a change step's entry-resolved target, "" otherwise) and outputs (the step's own decoded outputs, exactly as published). A non-fan-out stage exposes the same flattened union of its executable descendants' outputs, without children.
  • Missing data is loud; absence is guarded with has(): every step in scope resolves to an outputs document (empty when it published nothing — skipped steps expose no outputs), so has(steps.<name>.outputs.<key>) answers false for an unpublished key while reading the key directly stays the loud missing-path failure. Sequential happens-before applies: reference a step that has finalised; an unknown step name is the same loud failure.
  • Masking: a sensitive output stays sensitive through the roll-up — encrypted at rest, redacted (******) in the serialized outputs attribute and in recorded verdict operands; evaluation sees the real value.

Within one change run the contract is unchanged: steps.<name>.outputs.<key> string values, composite scoping, outputs.children does not exist there. Run outputs never cross runs — the estate (properties) and the artifact lane remain the cross-run hand-offs.

Plans (tiers 1–2)​

opschain runs create change|workflow … --plan against a server (and POST /api/changes/plan / POST /api/workflow_runs/plan, same body as create) compiles the request and answers the step tree with no run row created:

  • Tier 1 — concrete: when: verdicts (will_run/will_skip, with the evaluated operands), skip-mask hits, run-command interpolations and estate() reads (resolved with the requesting principal's own read authority) wherever properties/overrides/estate decide them — and typed inputs: the plan takes the same inputs object (--plan with -i), resolves and validates it exactly like create (a bad input is the same named 422), and evaluates input-driven conditions concretely. Secret input values evaluate for real — verdict truth matches step entry — and mask as ****** in everything the plan answers (rendered commands, verdict operands, fan-out children, with:-forwarded splices), so a plan payload never carries one.
  • Tier 2 — symbolic: anything reading steps.*.outputs — or otherwise undecidable before execution — renders as the expression span with the reason, which names the producing step where the expression makes it attributable ("produced by step "discover""). A status-function condition renders as its branch ("runs on failure", "runs always (cleanup)"): the scope's outcome exists only once the run executes.
  • Fan-out: a creation-resolvable for_each: (inputs, properties, estate enumerated with the requester's own read authority) renders a concrete expansion — the fan_out plan attribute carries the count and cap, and the children appear once per element with their bindings evaluated and their per-element identity RESOLVED: each child row carries its resolved target path, its resolved action name and its rendered dynamic name, so the CLI tree prints ✔ sap → /projects/p/environments/dev/assets/sap · sap:stop rather than a generic template name. An empty expansion plans as will_skip and a cap breach is called out. An output-dependent list renders the parent symbolically with its bounds ("expands from …, bounded ≤ cap") and the template once, symbolically — symbolic spans keep showing their expressions.

The payload says it is advisory: the run re-evaluates every condition at step entry and the recorded verdicts are authoritative.