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:
| Namespace | Holds |
|---|---|
properties | the step's merged properties view (project → environment → asset, most specific wins) |
context | the step context document (change runs) |
steps | preceding 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 |
inputs | the 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 |
run | run.id, run.action (a workflow run's code), run.project (the owning project's code), run.metadata |
node | node.path, node.code — the run's target |
estate('<node path>') | another node's converged properties, and its .assets/.environments enumeration — see below |
item / index | the current fan-out element and its position, inside a for_each: scope only (or the as: alias and <as>_index) — see below |
metadata | the 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 |
error | the 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)(alsolist.chunk(n)) — splits a list into consecutive sublists of at mostnelements, in order;nmust be a positive integer.uniq([1,2,2]).chunk(2)⇒[[1,2]].uniq(list)(alsolist.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_verdictsattribute. 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):
| Function | True 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 additiveerror_handledattribute. - 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 separatefinally: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 ownerrornever fails its scope: the parent proceeds to the remaining siblings and the scope's failure accounting excludes the step (soerror()stays false and the run can still finalisesuccess), while the step itself honestly recordserror. One bad fan-out element never stops the sweep. Asystem_erroris 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_errorwithwhen: ${{ error('<step>') }}— spared by the abort cascade, dispatched at its turn, visible in the step tree, chains and plans ("runs on failure"),error_handledrecorded 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, thenthen:applies.then: fail(default) keeps the honesty rule — the scope still fails and the run finaliseserrorwitherror_handledrecorded;then: continuebehaves likeon_error: continueevaluated after the handler succeeded. A handler that itself fails is terminal for the scope regardless ofthen:. - No handlers-of-handlers: a failure-path step (its
when:referenceserror()/always()) may not carry an object-formon_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 — itswith: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). Readingerror.*outside a handler's scope is a loud evaluation failure. - Placement rules: in action files, only
run:steps carryon_error(and the object form takes notarget:— 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 withfor_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, andlist<string>also accepts a comma-separated value), the create APIs'inputsobject, or a trigger'saction_params.inputsmap — 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 declaredefault: '', 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) — andinputs.token != ''tests whether it was given. A calling step'swith: { 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 targetnode.*for a change's inputs.context.*,steps.*,estate()and the status functions are step-scoped — using one in adefault:is a named compile error at publish. The resolved value is recorded exactly like a supplied one. - Recorded and masked: the run's
inputsattribute carries the resolved values; asecret-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
secretinput's reference resolves through the configured secret backend (the localsecretstore for--localruns) when its step dispatches, so${{ inputs.<name> }}in arun: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_propertieswrites 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-scopeinputs.*namespace — a run-supplied value overrides the declared default there. Inside a composeduses:callee,inputs.*remains that call site'swith:bindings: run inputs never leak into a callee, andwith:stays the action-call namespace. A nested run does not inherit its parent's inputs — a workflow step passes them explicitly withwith:(below). Supplying inputs to an action with no declarative action file is a named 422: nothing declares inputs there. property_overridescoexist 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 unknownwith:name and a missing required input were always named errors; typed declarations now also type-check every literal value (a non-number for anumber, an enum violation, asecretvalue that is not asecret-vault://reference). A value carrying${{ … }}stays an expression, resolved at the consuming step's dispatch with the caller's scope — which is what carrieserror.*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 sameinputsobject a directruns 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_overridescoexist 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>).assetsand.environmentsare 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_readsattribute 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:withtargets:. - 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 ownstrategy:in action files) orders the expansion; sequential is the default. - Bindings: each child binds the element as
itemand its position asindex.as: <name>renames them to<name>and<name>_index. Bindings reach everything the child evaluates —target:,action:,with:/property_overridesvalues,run:commands, the child's ownwhen:and a nestedfor_each:— and propagate to every descendant of the expansion. Nested fan-outs must bind distinct names (inner shadows nothing): nesting two unaliasedfor_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 ofapps/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 onlytarget:/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 }}-databasesyields copiesbackup-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_limitsettings 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_outattribute —{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_statusrolls 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 literalstrue/falsedecode as booleans (sohas(steps.patch.outputs.reboot_required) && steps.patch.outputs.reboot_requiredreads naturally). Everything else — numbers included — stays the raw string: comparesteps.a.outputs.count == '2', or convert explicitly withint(). 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 itsas:alias),name,status(authored vocabulary:success|error|skipped|cancelled, or the live state mid-flight),asset_pathandcode(a change step's entry-resolved target,""otherwise) andoutputs(the step's own decoded outputs, exactly as published). A non-fan-out stage exposes the same flattened union of its executable descendants' outputs, withoutchildren.- 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), sohas(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 serializedoutputsattribute 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 andestate()reads (resolved with the requesting principal's own read authority) wherever properties/overrides/estate decide them — and typed inputs: the plan takes the sameinputsobject (--planwith-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 — thefan_outplan 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 resolvedtargetpath, its resolvedactionname and its rendered dynamic name, so the CLI tree prints✔ sap → /projects/p/environments/dev/assets/sap · sap:stoprather than a generic template name. An empty expansion plans aswill_skipand 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.