Actions#
Purpose#
Actions describe side effects as a declarative execution graph. They exist to model what should be done, not how data is derived.
Actions consume resolved data, declare dependencies, and reference results from other actions in a structured way. Actions may be executed directly by scafctl or rendered for execution by another system.
Resolvers compute data. Actions perform work.
Responsibilities#
An action is responsible for:
- Declaring an executable operation
- Selecting a provider
- Declaring dependencies on other actions
- Consuming resolver values and action results
- Defining execution conditions
An action is not responsible for:
- Resolving or transforming data
- Mutating resolver values
- Performing implicit execution
- Managing shared state
Action Graph#
Actions form a directed acyclic graph.
Each action node contains:
- A provider
- Inputs
- Optional results
- Optional conditions
- Explicit dependencies
Commands and Modes#
Actions support two top-level commands.
run#
Executes the action graph directly.
scafctl run solution myappBehavior:
- Resolve all resolvers
- Evaluate all CEL and templates needed to render actions
- Execute actions in dependency order
- Perform side effects
render#
Renders a fully resolved action graph without executing any action providers.
scafctl render solution myapp --output=json
scafctl render solution myapp --output=yamlBehavior:
- Resolve all resolvers
- Evaluate all CEL and templates needed to render actions
- Emit an executor-ready action graph artifact (JSON by default, YAML optional)
- No action providers are executed
- No action results are produced at render time
Render produces an artifact, not new runtime data. Output files use .json or .yaml extensions.
Action Definition#
Actions are defined under spec.workflow, which contains two sections: actions for main execution and finally for cleanup.
spec:
workflow:
actions:
deploy:
provider: api
inputs:
endpoint: https://api.example.com/deploy
finally:
cleanup:
provider: exec
inputs:
command: "rm -rf /tmp/build-artifacts"Each action declares exactly one provider execution.
Full Action Schema#
spec:
workflow:
actions:
<actionName>:
# Metadata
description: "Human-readable description of what this action does"
displayName: "Deploy Application"
sensitive: false # Whether results should be redacted in table output
alias: deploy # Short alias for expression references (optional)
# Provider
provider: api
# Inputs (supports literal, rslvr, expr, tmpl)
inputs:
endpoint: https://api.example.com/deploy
# Dependencies
dependsOn: [build, test]
# Conditional execution
when:
expr: _.environment == "prod"
# Error handling
onError: fail # fail | continue
# Timeout
timeout: 30s
# Retry configuration
retry:
maxAttempts: 3
backoff: exponential # fixed | linear | exponential
initialDelay: 1s
maxDelay: 30s
# Mutual exclusion (not available in finally section)
exclusive: [migrateDatabase] # Cannot run in parallel with these actions
# Iteration (not available in finally section)
forEach:
item: region
index: i
in:
rslvr: regions
concurrency: 5
onError: continue # fail | continue (default: fail)Action names must match the pattern ^[a-zA-Z_][a-zA-Z0-9_-]*$. Names starting with __ are reserved for internal use.
Note: The forEach field is only available in workflow.actions, not in workflow.finally. Cleanup actions in the finally section cannot use iteration.
Provider Inputs#
Action inputs are materialized by scafctl before action execution, or before graph emission in render mode.
Supported input forms:
Literal#
inputs:
retries: 3Resolver Binding#
inputs:
image:
rslvr: imageExpression#
inputs:
tag:
expr: _.version + "-stable"Template#
inputs:
path:
tmpl: "./config/{{ _.environment }}/app.yaml"Providers never see CEL, templates, or resolver references. Providers receive concrete values.
Dependencies#
Actions declare dependencies explicitly using dependsOn.
workflow:
actions:
build:
provider: exec
deploy:
dependsOn: [build]
provider: apiRules:
- Dependencies form a DAG
- An action runs only after all dependencies complete (success or failure depending on
onError) - Cycles are rejected at validation time
Results#
Actions expose results implicitly from provider output. The provider’s Output.Data becomes the action’s results automatically.
workflow:
actions:
fetchConfig:
provider: api
inputs:
endpoint: https://api.example.com/config
# Results are implicitly Output.Data from the provider executionResults are available to dependent actions via the __actions namespace.
Consuming Results from Dependencies#
Actions consume results from dependencies using expressions or templates that reference the __actions namespace.
Using expressions#
workflow:
actions:
fetchConfig:
provider: api
inputs:
endpoint: https://api.example.com/config
deploy:
dependsOn: [fetchConfig]
provider: api
inputs:
# Reference entire results object
body:
expr: __actions.fetchConfig.results
# Reference nested field
timeout:
expr: __actions.fetchConfig.results.config.timeout
# Combine with resolver data
message:
expr: '"Deploying to " + _.environment + " with config v" + string(__actions.fetchConfig.results.version)'Using templates#
inputs:
body:
tmpl: "Config value: {{ .__actions.fetchConfig.results.configKey }}"Rules#
__actions.<name>must reference existing actions in the workflow- Dependencies are automatically inferred from
__actionsreferences during graph building. If your inputs orwhencondition reference__actions.<name>, an explicitdependsOn: [<name>]is not required for same-section ordering—the scheduler adds it automatically. UsedependsOnexplicitly only for ordering without a data dependency. dependsOninworkflow.finallycannot cross sections. To read results from a regular action inside afinallyaction, use__actions.<name>in inputs orwhen. The reference is not added todependencies(which drivesFinallyOrderphase computation) because cross-section ordering is guaranteed structurally—all main actions complete before any finally action starts. The reference is instead recorded incrossSectionRefson the rendered graph for traceability.- In render mode, expressions referencing
__actionsare preserved as deferred expressions - External executors must be CEL-capable to evaluate deferred expressions
The __actions Namespace#
After action execution, results and metadata are available in the __actions namespace:
__actions:
<actionName>:
inputs: <materialized inputs passed to provider>
results: <Output.Data from provider>
status: succeeded | failed | skipped | timeout | cancelled
skipReason: condition | dependency-failed # Only present when status is skipped
startTime: "2026-01-29T10:00:00Z"
endTime: "2026-01-29T10:00:05Z"
error: "error message" # See Error Field section belowError Field#
The error field presence depends on the action’s status:
| Status | error field |
|---|---|
succeeded | Not present |
failed | Required - contains the error message |
timeout | Required - contains timeout details |
skipped | Not present |
cancelled | Optional - may contain cancellation reason if available |
Inputs Field#
The inputs field contains the fully materialized inputs that were passed to the provider. This is useful for debugging and for finally actions that need to understand what values were used:
# Given this action definition:
workflow:
actions:
deploy:
provider: api
inputs:
endpoint:
tmpl: "https://{{ _.region }}.example.com/deploy"
body:
expr: '{"image": _.image}'
# The __actions namespace will contain:
__actions:
deploy:
inputs:
endpoint: "https://us-east.example.com/deploy"
body: {"image": "nginx:1.27"}
results: { ... }
status: succeededSkip Reason#
When an action has status: skipped, the skipReason field indicates why:
skipReason | Description |
|---|---|
condition | The when expression evaluated to false |
dependency-failed | A dependency failed with onError: fail |
Status Values#
| Status | Description |
|---|---|
pending | Action is waiting for dependencies to complete |
running | Action is currently executing |
succeeded | Action completed successfully |
failed | Action failed due to an error |
skipped | Action was skipped (see skipReason for details) |
timeout | Action exceeded its configured timeout |
cancelled | Action was cancelled before or during execution |
The pending and running statuses are transient and only observable during execution (e.g., via progress callbacks or real-time monitoring). In the final __actions namespace after execution completes, only terminal statuses appear.
This namespace is available in:
- CEL expressions (
expr) - Go templates (
tmpl) whenconditions of dependent actions
Conditions#
Actions may be conditionally enabled using when.
workflow:
actions:
deploy:
when:
expr: _.environment == "prod"
provider: apiConditions can also reference action results from dependencies:
workflow:
actions:
test:
provider: exec
inputs:
command: "npm test"
deploy:
dependsOn: [test]
when:
expr: __actions.test.status == "succeeded"
provider: apiExpression Evaluation Timing#
Expressions and templates are evaluated at different times depending on what they reference:
| References | Evaluation Time | Rendered Output |
|---|---|---|
Only _ (resolver data) | Render time | Concrete value (e.g., when: true) |
__actions (action results) | Runtime | Preserved expression (deferred) |
Mixed _ and __actions | Runtime | Preserved expression (deferred) |
Examples:
# Evaluated at render time → becomes: when: true
when:
expr: _.environment == "prod"
# Deferred to runtime (references action results)
when:
expr: __actions.test.status == "succeeded"
# Deferred to runtime (mixed references)
inputs:
message:
expr: '"Deploying " + _.appName + " (test: " + __actions.test.status + ")"'
# Combined condition (deferred - references __actions)
when:
expr: _.environment == "prod" && __actions.test.status == "succeeded"In render mode, deferred expressions are preserved in the output with a deferred: true marker:
{
"when": {
"expr": "__actions.test.status == \"succeeded\"",
"deferred": true
}
}Behavior:
when.expris evaluated during render (resolver values only) or at runtime (if referencing action results)- The rendered action includes a boolean condition or deferred expression
- In run mode, scafctl skips actions whose condition is false
- In render mode, the emitted graph includes evaluated values for resolver-only conditions, and preserves expressions for runtime evaluation
Error Handling#
Actions support onError to control behavior on failure.
actions:
notify:
provider: slack
onError: continue # Don't fail the whole graph if Slack is down
inputs:
message: "Starting deployment"
deploy:
provider: api
onError: fail # Default: stop everything if this fails
inputs:
endpoint: https://api.example.com/deployonError Value | Behavior |
|---|---|
fail (default) | Stop entire graph execution immediately |
continue | Mark action as failed, continue executing remaining actions |
When onError: continue:
- The action is marked as failed in
__actions.<name>.status - All remaining actions continue to execute
- Dependent actions are responsible for checking
__actions.<dependency>.statusand handling failures appropriately - The
__actions.<name>.errorfield contains the error message finallyactions always have access to__actions.<name>.errorregardless ofonErrorsetting
Timeout#
Actions support individual timeouts.
actions:
deploy:
provider: api
timeout: 30s
inputs:
endpoint: https://api.example.com/deployIf an action exceeds its timeout, it fails with a timeout error. The default timeout is inherited from global configuration.
Retry Configuration#
Actions support retry policies for transient failures.
actions:
deploy:
provider: api
retry:
maxAttempts: 3
backoff: exponential
initialDelay: 1s
maxDelay: 30s
inputs:
endpoint: https://api.example.com/deployRetry Fields#
| Field | Description | Default |
|---|---|---|
maxAttempts | Maximum number of attempts (including initial) | 1 (no retry) |
backoff | Backoff strategy: fixed, linear, exponential | fixed |
initialDelay | Delay before first retry | 1s |
maxDelay | Maximum delay between retries | 30s |
Retry and Timeout Interaction#
If an action times out, no further retries are attempted. A timeout is treated as a terminal failure. The action is marked with status: timeout and execution moves on.
Example worst-case timing for a successful retry scenario:
timeout: 30s
retry:
maxAttempts: 3
initialDelay: 5s
backoff: exponential- Attempt 1: fails at 10s → wait 5s
- Attempt 2: fails at 15s → wait 10s
- Attempt 3: succeeds at 20s
- Total: 60s
If attempt 2 times out (30s), the action fails immediately with status: timeout.
Backoff Strategies#
- fixed: Always wait
initialDelaybetween attempts - linear: Delay increases by
initialDelayeach attempt (1s, 2s, 3s, …) - exponential: Delay doubles each attempt (1s, 2s, 4s, …) up to
maxDelay
Providers can declare retryable: false in their descriptor if retries are never appropriate (e.g., destructive operations).
Sensitive Actions#
Actions can be marked as sensitive to control result visibility.
actions:
getSecret:
provider: vault
sensitive: true
inputs:
path: secret/data/api-keyWhen sensitive: true:
- Results are redacted in table/interactive output (human-facing)
- JSON and YAML output reveals values for machine consumption (Terraform model)
- Use
--show-sensitiveto reveal values in all output formats - The value is still available to dependent actions
- Error messages are sanitized to prevent leaking sensitive data (e.g., secrets in request bodies)
- The
__actions.<name>.errorfield contains a sanitized error message
Iteration#
Actions may be expanded declaratively using forEach.
workflow:
actions:
deploy:
forEach:
item: region
index: i
in:
rslvr: regions
concurrency: 5
onError: continue
provider: api
inputs:
endpoint:
tmpl: "https://{{ .region.api }}/deploy"ForEach Fields#
| Field | Description | Default |
|---|---|---|
item | Variable name for current element | __item |
index | Variable name for current index | __index |
in | Array to iterate (ValueRef: literal, rslvr, expr, tmpl) | Required |
concurrency | Max parallel iterations (0 = unlimited) | 0 |
onError | Error handling: fail or continue | fail |
ForEach Error Handling#
When onError: continue (explicit):
- All iterations execute regardless of individual failures
- Failed iterations are marked with
status: failed - Results from successful iterations are still available
When onError: fail (default):
- Execution stops after the first iteration fails
- Pending iterations are marked with
status: cancelled - Already-running iterations continue to completion (no mid-execution cancellation)
Expansion Behavior#
- Iteration is expanded during render
- Produces multiple action nodes with index-based naming:
deploy[0],deploy[1],deploy[2], … - Each iteration is independent
- All expanded actions inherit the original action’s
dependsOn - Dependents of the original action depend on all expanded instances (waits for all to complete before starting)
- Action names containing
[or]are reserved and rejected at validation time to prevent naming collisions
ForEach Dependency Expansion Example#
When an action with forEach has dependents, those dependents wait for all expanded instances:
# Original definition:
workflow:
actions:
deploy:
forEach:
in:
rslvr: regions # ["us-east", "us-west"]
provider: api
notify:
dependsOn: [deploy] # Depends on the forEach action
provider: slack
# Rendered graph (simplified):
workflow:
actions:
deploy[0]:
provider: api
deploy[1]:
provider: api
notify:
dependsOn: [deploy[0], deploy[1]] # Expanded to all instances
provider: slackThe notify action will only start after both deploy[0] and deploy[1] complete.
Accessing ForEach Results#
ForEach results are accessible both individually and as an aggregate:
# Individual iteration results (by expanded action name)
__actions["deploy[0]"].results # First iteration result
__actions["deploy[1]"].results # Second iteration result
__actions["deploy[0]"].status # Status of first iteration
# Aggregate results (array of all iteration results)
__actions.deploy.results # [result0, result1, result2, ...]
__actions.deploy.iterations # Full iteration metadata (see below)The iterations field provides detailed metadata for each expansion:
__actions.deploy.iterations:
- index: 0
name: "deploy[0]"
results: { ... } # Output.Data from this iteration
status: succeeded
startTime: "2026-01-29T10:00:00Z"
endTime: "2026-01-29T10:00:05Z"
- index: 1
name: "deploy[1]"
results: { ... }
status: failed
error: "connection timeout"
# ...This enables filtering and aggregation in expressions:
# Count failed iterations
expr: __actions.deploy.iterations.filter(i, i.status == "failed").size()
# Get all successful results
expr: __actions.deploy.iterations.filter(i, i.status == "succeeded").map(i, i.results)Variables Available in ForEach#
During forEach iteration, the following variables are available in expressions and templates:
# Given: regions = ["us-east", "us-west", "eu-central"]
# For iteration index 1:
__item: "us-west" # Current element (always available)
__index: 1 # Current 0-based index (always available)
region: "us-west" # Custom alias (from item: region)
i: 1 # Custom alias (from index: i)The built-in __item and __index variables are always available regardless of custom aliases. Custom aliases (item and index fields) provide more readable names for use in expressions and templates.
Example using built-in variables:
forEach:
in:
rslvr: servers
# Using defaults: __item and __index
provider: exec
inputs:
command:
expr: '"deploy to " + __item.hostname + " (" + string(__index) + ")"'Exclusive Actions#
Actions that share resources (databases, files, external APIs) may be scheduled concurrently by the DAG executor. Use the exclusive field to declare which other actions an action cannot run in parallel with.
spec:
workflow:
actions:
updateDatabase:
provider: sql
exclusive: [migrateDatabase]
inputs:
query: "UPDATE users SET status = 'active'"
migrateDatabase:
provider: sql
inputs:
script: "./migrations/001.sql"
sendNotification:
provider: slack
inputs:
message: "Update complete"Exclusive Behavior#
exclusiveis one-way: declaringexclusive: [X]on action A prevents X from running in parallel with A- The other action does not need to declare the same exclusivity (though it can for documentation clarity)
- If both A and B are ready to run and A declares
exclusive: [B], the executor runs one then the other (order is determined by declaration order, not by which action holds the exclusion) exclusivedoes not implydependsOn— actions may run in any order, just not simultaneously- Applies to expanded forEach actions: if
deploydeclaresexclusive: [migrate], thendeploy[0],deploy[1], etc. all excludemigrate exclusiveis only available inworkflow.actions(not inworkflow.finally)
Exclusive Use Cases#
- Resource contention: Two actions that access the same database, file, or service
- Rate limiting: Avoid overwhelming an external API with concurrent requests
- Data consistency: Prevent concurrent modifications to shared state
Finally Actions#
Cleanup actions are defined in the workflow.finally section, separate from regular actions for clear visual separation between main execution and cleanup.
spec:
workflow:
actions:
build:
provider: exec
inputs:
command: "make build"
deploy:
dependsOn: [build]
provider: api
inputs:
endpoint: https://api.example.com/deploy
finally:
cleanup:
provider: exec
inputs:
command: "rm -rf /tmp/build-artifacts"
notify:
dependsOn: [cleanup] # Can depend on other finally actions
provider: slack
inputs:
message:
expr: '"Build " + (__actions.deploy.status == "succeeded" ? "succeeded" : "failed")'Finally Behavior#
- Finally actions run after all regular actions complete (success, failure, or skip)
- Finally actions have access to
__actionsresults from all regular actions, including failed ones - Finally actions can declare
dependsOnother finally actions (for ordering within the finally phase) - Finally actions cannot
dependsOnregular actions (they implicitly wait for all regular actions) - Finally actions can reference
__actions.<regularAction>.resultsand__actions.<regularAction>.statusin expressions forEachis not available in the finally section (enforced at validation time)- Finally actions do not block regular actions
Cross-Section References#
Finally actions can read from regular actions but cannot declare a scheduling dependsOn on them.
spec:
workflow:
actions:
deploy:
provider: api
inputs:
endpoint: https://api.example.com/deploy
finally:
report:
# ✅ Valid: Reference results/status via expressions.
# "deploy" appears in crossSectionRefs on the rendered graph (informational).
# It does NOT appear in dependencies because cross-section ordering is
# guaranteed structurally — all main actions complete first.
provider: slack
inputs:
message:
expr: '"Deploy status: " + __actions.deploy.status'
details:
expr: __actions.deploy.error # Available if deploy failed
cleanup:
# ❌ Invalid: Cannot use dependsOn to reference regular actions from finally.
# dependsOn: [deploy] # Validation error: dep not found in finally section
provider: exec
inputs:
command: "rm -rf /tmp/build"dependencies vs crossSectionRefs in the rendered graph#
The rendered ActionGraph distinguishes two categories of relationships for a finally action:
| Field | Content | Used for scheduling? |
|---|---|---|
dependencies | Same-section __actions refs + explicit dependsOn | ✅ Yes — drives FinallyOrder phase ordering |
crossSectionRefs | __actions refs pointing at actions in workflow.actions | ❌ No — informational only |
Cross-section ordering does not need to appear in FinallyOrder because the executor already guarantees all main actions finish before the finally section begins. Recording them in crossSectionRefs preserves traceability (e.g. for graph visualisation and audit) without introducing phantom in-degrees into the phase scheduler.
Execution Order#
- All regular actions (in
workflow.actions) execute according to their DAG - Once all regular actions complete (success, failure, or skip), finally actions begin
- Finally actions execute in their own dependency order within the finally section
Progress Callbacks#
Action execution supports progress callbacks for real-time feedback during execution. This enables progress bars, live logging, and monitoring integrations.
Callback Events#
| Event | Description |
|---|---|
OnActionStart | Fired when an action begins execution |
OnActionComplete | Fired when an action completes successfully |
OnActionFailed | Fired when an action fails (includes error) |
OnActionSkipped | Fired when an action is skipped (includes skip reason) |
OnActionTimeout | Fired when an action times out |
OnActionCancelled | Fired when an action is cancelled |
OnRetryAttempt | Fired before a retry attempt (includes attempt number) |
OnForEachProgress | Fired as forEach iterations complete (includes completed/total counts) |
OnPhaseStart | Fired when a new execution phase begins |
OnPhaseComplete | Fired when an execution phase completes |
OnFinallyStart | Fired when the finally section begins |
OnFinallyComplete | Fired when the finally section completes |
Callback Interface#
// ProgressCallback receives execution progress events for actions.
type ProgressCallback interface {
OnActionStart(actionName string)
OnActionComplete(actionName string, results any)
OnActionFailed(actionName string, err error)
OnActionSkipped(actionName string, reason string)
OnActionTimeout(actionName string, timeout time.Duration)
OnActionCancelled(actionName string)
OnRetryAttempt(actionName string, attempt int, maxAttempts int, err error)
OnForEachProgress(actionName string, completed int, total int)
OnPhaseStart(phase int, actionNames []string)
OnPhaseComplete(phase int)
OnFinallyStart()
OnFinallyComplete()
}Progress callbacks are optional and do not affect execution semantics.
Rendered Graph Shape#
After rendering, scafctl emits a graph that contains only concrete inputs and explicit references.
{
"apiVersion": "scafctl.oakwood-commons.github.io/v1alpha1",
"kind": "ActionGraph",
"executionOrder": [
["fetchConfig"],
["deploy"],
["deploy-regions[0]", "deploy-regions[1]"]
],
"finallyOrder": [
["cleanup"]
],
"actions": {
"fetchConfig": {
"provider": "api",
"inputs": {
"endpoint": "https://api.example.com/config"
},
"onError": "fail",
"timeout": "30s"
},
"deploy": {
"provider": "api",
"dependsOn": ["fetchConfig"],
"when": {
"expr": "__actions.fetchConfig.status == \"succeeded\"",
"deferred": true
},
"onError": "fail",
"timeout": "60s",
"inputs": {
"body": {
"expr": "__actions.fetchConfig.results",
"deferred": true
}
}
},
"cleanup": {
"provider": "shell",
"section": "finally",
"crossSectionRefs": ["deploy"],
"inputs": {
"command": "rm -rf /tmp/build"
}
},
"deploy-regions[0]": {
"provider": "api",
"dependsOn": ["deploy"],
"inputs": {
"endpoint": "https://us-east.example.com/deploy",
"region": "us-east"
},
"forEach": {
"expandedFrom": "deploy-regions",
"index": 0
}
},
"deploy-regions[1]": {
"provider": "api",
"dependsOn": ["deploy"],
"inputs": {
"endpoint": "https://us-west.example.com/deploy",
"region": "us-west"
},
"forEach": {
"expandedFrom": "deploy-regions",
"index": 1
}
}
}
}The same graph in YAML format (--output=yaml):
apiVersion: scafctl.oakwood-commons.github.io/v1alpha1
kind: ActionGraph
executionOrder:
- [fetchConfig]
- [deploy]
- [deploy-regions[0], deploy-regions[1]]
finallyOrder:
- [cleanup]
actions:
fetchConfig:
provider: api
inputs:
endpoint: https://api.example.com/config
onError: fail
timeout: 30s
deploy:
provider: api
dependsOn: [fetchConfig]
when:
expr: __actions.fetchConfig.status == "succeeded"
deferred: true
onError: fail
timeout: 60s
inputs:
body:
expr: __actions.fetchConfig.results
deferred: true
cleanup:
provider: exec
section: finally
crossSectionRefs: [deploy]
inputs:
command: rm -rf /tmp/build
deploy-regions[0]:
provider: api
dependsOn: [deploy]
inputs:
endpoint: https://us-east.example.com/deploy
region: us-east
forEach:
expandedFrom: deploy-regions
index: 0
deploy-regions[1]:
provider: api
dependsOn: [deploy]
inputs:
endpoint: https://us-west.example.com/deploy
region: us-west
forEach:
expandedFrom: deploy-regions
index: 1Rendered Graph Notes#
- CEL expressions and templates referencing only resolver data are evaluated to concrete values
- Expressions referencing
__actionsare preserved as deferred expressions for runtime evaluation whenconditions based on resolver values are evaluated to booleanswhenconditions referencing__actionsare preserved as deferred expressionsforEachactions are expanded to individual action nodes- External executors must be CEL-capable to evaluate deferred expressions
- No runtime action result values exist until execution time
executionOrderis an array of phases, where each phase is an array of action names that can execute concurrentlyfinallyOrderis a separate array of phases for finally actions- Finally actions include
"section": "finally"in the rendered output - Finally actions that read from
workflow.actionsvia__actionsreferences includecrossSectionRefslisting those action names (informational — does not affectFinallyOrderphase computation)
Design Constraints#
- Actions never feed resolvers
- Resolvers always run before actions
- All CEL and templates (that don’t reference
__actions) are resolved before action execution or graph emission - Action-to-action data flow is explicit via expressions referencing
__actions.<name>.results - Side effects are restricted to actions
- Providers are execution primitives used by actions
- Providers must have
CapabilityActionto be used in actions - Providers with
CapabilityActionmust defineOutputSchemas[action]with at least:success(bool): Whether the action succeededdata(any): The result data (becomes__actions.<name>.results)
- External executors must be CEL-capable to evaluate deferred expressions
Validation Rules#
The following are validated at parse/load time:
- Action names must match
^[a-zA-Z_][a-zA-Z0-9_-]*$ - Action names starting with
__are reserved - Action names containing
[or]are reserved (used for forEach expansion) - Action names must be unique across both
workflow.actionsandworkflow.finallysections dependsOninworkflow.actionsmust reference existing actions inworkflow.actionsdependsOninworkflow.finallymust reference existing actions inworkflow.finallyonly (cannot depend on regular actions viadependsOn). To read results from a regular action inside afinallyaction, reference it via__actions.<name>in inputs orwhen— the reference is recorded incrossSectionRefson the rendered graph for traceability; cross-section ordering is guaranteed structurally.dependsOnmust not create cycles (within each section)- Provider must exist and have
CapabilityAction __actions.<name>references in expressions/templates must reference existing actions:- In
workflow.actions: must reference actions defined inworkflow.actions - In
workflow.finally: must reference actions defined inworkflow.actionsorworkflow.finally - Same-section
__actionsreferences are automatically added todependenciesduring graph building (dependsOnis optional for those). Cross-section references (afinallyaction reading fromworkflow.actions) are recorded incrossSectionRefsonly — they do not appear independencies. UsedependsOnexplicitly only for same-section ordering without a data dependency.
- In
forEachis only allowed inworkflow.actions, not inworkflow.finallyretry.maxAttemptsmust be >= 1timeoutmust be a valid durationforEach.onErrormust befailorcontinueif specifiedforEach.concurrencymust be >= 0 if specifiedexclusivereferenced actions must exist in the same section (workflow.actionsorworkflow.finally)exclusiveself-reference is invalid (an action cannot list itself)exclusiveis only allowed inworkflow.actions, not inworkflow.finally
Future Enhancements#
The following features are planned for future implementation:
Result Schema Validation#
Actions could optionally declare an expected result schema for validation and documentation:
actions:
fetchConfig:
provider: api
inputs:
endpoint: https://api.example.com/config
results:
schema:
properties:
version:
type: string
description: Configuration version
settings:
type: object
description: Application settings
required: [version, settings]Benefits:
- Validation: Verify provider output matches expected shape at runtime
- Documentation: Self-documenting result structures for solution readers
- Type hints: Better CEL/template autocomplete in editors for
__actions.<name>.results.* - Contract enforcement: Catch provider output changes that break dependent actions early
Behavior:
- When
results.schemais defined,Output.Datafrom the provider is validated against it - Schema validation errors cause the action to fail (unless
onError: continue) - Schema is optional—actions without it pass through
Output.Dataunchanged - The schema uses standard JSON Schema format (
*jsonschema.Schema), the same as provider input schemas
Conditional Retry#
Retry policies could support conditions to retry only on specific error types:
actions:
deploy:
provider: api
retry:
maxAttempts: 3
backoff: exponential
initialDelay: 1s
retryIf:
expr: __error.statusCode == 429 || __error.statusCode >= 500
inputs:
endpoint: https://api.example.com/deployMotivation:
Not all failures are transient. Retrying a 400 Bad Request wastes time and resources. Conditional retry enables:
- Selective retry: Only retry rate limits (429) and server errors (5xx)
- Fail fast: Immediately fail on client errors (4xx except 429)
- Custom logic: Retry based on error message patterns or custom error codes
The __error Namespace:
During retry evaluation, __error provides context about the failure:
__error:
message: "Service temporarily unavailable" # Error message
statusCode: 503 # HTTP status (if applicable)
code: "UNAVAILABLE" # Provider-specific error code
retryable: true # Provider's retryability hint
attempt: 2 # Current attempt number (1-based)Default behavior:
When retryIf is not specified:
- If provider sets
retryable: falsein error → no retry - Otherwise → retry on any failure (current behavior)
Interaction with provider retryable: false:
Providers can declare retryable: false in their descriptor for destructive operations. The retryIf condition takes precedence—if a user explicitly defines a condition, it overrides the provider hint. This allows advanced users to retry even “non-retryable” operations when they know it’s safe.
Matrix Strategy#
A matrix strategy for parallel expansion across multiple dimensions:
actions:
deploy:
matrix:
region: [us-east, us-west, eu-central]
env: [staging, prod]
provider: api
inputs:
endpoint:
tmpl: "https://{{ .region }}.example.com/{{ .env }}/deploy"Behavior:
- Expands to all combinations (6 actions in the example above)
- Each combination runs as an independent action
- Naming convention:
deploy-0,deploy-1, …,deploy-5 - Supports
excludeto skip specific combinations:
actions:
deploy:
matrix:
region: [us-east, us-west, eu-central]
env: [staging, prod]
exclude:
- region: eu-central
env: staging # Don't deploy staging to EU
provider: api- Supports
includeto add specific combinations with extra variables:
actions:
deploy:
matrix:
region: [us-east, us-west]
env: [staging, prod]
include:
- region: ap-south
env: prod
extra: "asia-specific-config" # Additional variable
provider: apimatrixis only available inworkflow.actions, not inworkflow.finally(same asforEach)
Action Alias#
Actions can declare an alias for shorter, more readable references in expressions.
When set, the action’s result data is available as a top-level CEL variable under the alias name, in addition to the standard __actions.<actionName> reference.
spec:
workflow:
actions:
fetchConfiguration:
provider: api
alias: config # Short alias for this action
inputs:
endpoint: https://api.example.com/config
deploy:
dependsOn: [fetchConfiguration]
provider: api
inputs:
# Instead of: __actions.fetchConfiguration.results.endpoint
# Use the shorter alias:
endpoint:
expr: config.results.endpoint
version:
expr: config.results.version
# The original __actions form still works:
status:
expr: __actions.fetchConfiguration.statusBenefits:
- Readability: Shorter, more meaningful names in expressions
- Refactoring: Change action names without updating all expressions (alias stays the same)
- Consistency: Use domain-specific terminology in expressions
Rules:
- Alias must be unique across all actions and aliases (both
actionsandfinallysections) - Alias cannot conflict with any action name
- Alias cannot conflict with reserved names (
_,__actions,__item,__index,__error,__self,true,false,null) - Alias cannot start with
__(reserved prefix) - Alias follows the same naming pattern as action names:
^[a-zA-Z_][a-zA-Z0-9_-]*$ - The original
__actions.<actionName>reference remains valid alongside the alias - Alias references in inputs are deferred (resolved at runtime) just like
__actionsreferences
Action Concurrency Limit#
A CLI parameter to limit the maximum number of actions executing concurrently:
scafctl run solution myapp --max-action-concurrency=5Motivation:
Solution authors don’t know what machine will execute the solution. The operator running the solution knows their machine’s capabilities (CPU, memory, network connections, API rate limits). A CLI parameter allows runtime tuning without modifying the solution.
Behavior:
0(default): Unlimited concurrency within each phaseN > 0: At most N actions execute simultaneously, even if more are ready- Applies globally across all phases, not per-phase
- Does not affect
forEach.concurrency(that’s per-iteration within a single action)
Cancellation Behavior#
When execution is cancelled (e.g., user interrupt, external signal):
Running Actions#
- Receive a cancellation signal (context cancellation)
- Given a grace period (configurable, default: 30s) to clean up
- After grace period, forcibly terminated
- Marked with
status: cancelledin__actions
Pending Actions#
- Not started
- Marked with
status: cancelledin__actions
Finally Actions (workflow.finally)#
- Always execute regardless of cancellation (they are designed for cleanup)
- Have access to
__actionsincluding cancelled action statuses from regular actions - Execute in their own dependency order within the finally section
- Can be forcibly terminated only with a second cancellation signal (force kill)
Status Values (Complete)#
| Status | Description |
|---|---|
pending | Action is waiting for dependencies (transient) |
running | Action is currently executing (transient) |
succeeded | Action completed successfully |
failed | Action failed due to an error |
skipped | Action was skipped (see skipReason for details) |
timeout | Action exceeded its configured timeout |
cancelled | Action was cancelled before or during execution |
Summary#
Actions in scafctl follow a Tekton-inspired model: explicit dependencies, implicit results (from provider output), and expression-based result references. scafctl can execute the graph with run or compile an executor-ready graph artifact with render. Features include:
- Workflow structure:
spec.workflowcontainingactionsfor main execution andfinallyfor cleanup - Error handling:
onErrorwithfailorcontinuesemantics (applies to both actions and forEach iterations) - Retries: Configurable retry policies with backoff strategies
- Timeouts: Per-action timeout configuration
- Conditions:
whenexpressions supporting both resolver and action result references - Iteration:
forEachexpansion withconcurrencyandonErroroptions (inworkflow.actionsonly) - Mutual exclusion:
exclusivefield prevents two actions from running in parallel without requiring an explicit dependency - Cleanup: Dedicated
workflow.finallysection for cleanup actions that always run - Cancellation: Graceful shutdown with guaranteed finally execution
- Progress callbacks: Real-time execution feedback for UIs and monitoring
External executors consuming rendered graphs must be CEL-capable to evaluate deferred expressions that reference action results.
This keeps data flow explicit, execution predictable, and integration with external orchestrators straightforward.