Resolver Tutorial#

This tutorial walks you through using scafctl resolvers to dynamically resolve configuration values. You’ll learn how to define resolvers, use different providers, handle dependencies, and implement common patterns.

Prerequisites#

  • scafctl installed and available in your PATH
  • Basic familiarity with YAML syntax
  • Understanding of environment variables

Table of Contents#

  1. Your First Resolver
  2. Using Parameters
  3. Resolver Dependencies
  4. Transformations
  5. Validation
  6. Conditional Execution
  7. Error Handling
  8. Working with HTTP APIs
  9. Common Patterns

Your First Resolver#

Let’s create a simple solution with one resolver that returns a static value.

Step 1: Create the Solution File#

Create a file called hello.yaml:

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: hello-world
  version: 1.0.0

spec:
  resolvers:
    greeting:
      type: string
      resolve:
        with:
          - provider: static
            inputs:
              value: "Hello, World!"

Step 2: Run the Solution#

scafctl run resolver -f hello.yaml
scafctl run resolver -f hello.yaml

Output:

╭─ scafctl run resolver ─╮
│KEY       VALUE         │
│─────────────────────── │
│greeting  Hello, World! │
╰ _ ─────────── map: 1/1 ╯

Tip: Add -o json to get JSON output: scafctl run resolver -f hello.yaml -o json

Understanding the Structure#

  • apiVersion/kind: Identifies this as a scafctl Solution
  • metadata: Solution name, version, and description
  • spec.resolvers: Map of resolver definitions
  • greeting: The resolver name (used as the output key)
  • type: Expected output type (optional, defaults to any)
  • resolve.with: List of provider sources to try

Using Parameters#

Parameters let you pass values from the command line to your resolvers.

Step 1: Create a Parameterized Solution#

Create greet.yaml:

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: parameterized-greeting
  version: 1.0.0

spec:
  resolvers:
    name:
      type: string
      resolve:
        with:
          - provider: parameter
            continueOnError: true
            inputs:
              key: user_name
          - provider: static
            inputs:
              value: "World"
    
    greeting:
      type: string
      resolve:
        with:
          - provider: cel
            inputs:
              expression: "'Hello, ' + _.name + '!'"

Step 2: Run with Parameters#

scafctl run resolver -f greet.yaml
scafctl run resolver -f greet.yaml

Output:

╭─ scafctl run resolver ─╮
│KEY       VALUE         │
│─────────────────────── │
│greeting  Hello, World! │
│name      World         │
╰ _ ─────────── map: 1/2 ╯

Pass a parameter:

scafctl run resolver -f greet.yaml -r user_name=Alice
scafctl run resolver -f greet.yaml -r user_name=Alice

Output:

╭─ scafctl run resolver ─╮
│KEY       VALUE         │
│─────────────────────── │
│greeting  Hello, Alice! │
│name      Alice         │
╰ _ ─────────── map: 1/2 ╯

Using Parameter Files#

For complex parameter sets, use a parameter file:

Create params.yaml:

user_name: Charlie

Run with the file:

scafctl run resolver -f greet.yaml -r @params.yaml
# Wrap @file in single quotes to avoid splatting operator
scafctl run resolver -f greet.yaml -r '@params.yaml'

You can also pipe parameters from stdin using @-:

echo '{"user_name": "Charlie"}' | scafctl run resolver -f greet.yaml -r @-
cat params.yaml | scafctl run resolver -f greet.yaml -r @-
'{"user_name": "Charlie"}' | scafctl run resolver -f greet.yaml -r '@-'
Get-Content params.yaml | scafctl run resolver -f greet.yaml -r '@-'

To pipe raw text into a single parameter key, use key=@-:

# Raw stdin into a single key (not parsed as YAML/JSON)
echo Charlie | scafctl run resolver -f greet.yaml -r user_name=@-

# Read a file's content into a key
echo -n "Charlie" > name.txt
scafctl run resolver -f greet.yaml -r user_name=@name.txt
'Charlie' | scafctl run resolver -f greet.yaml -r 'user_name=@-'

# Create the file first
Set-Content -NoNewline -Path name.txt -Value 'Charlie'
scafctl run resolver -f greet.yaml -r 'user_name=@name.txt'

Output:

╭─ scafctl run resolver ──╮
│KEY       VALUE          │
│─────────────────────────│
│greeting  Hello, Charlie!│
│name      Charlie        │
╰ _ ──────────── map: 1/2 ╯

Resolver Dependencies#

Resolvers can reference other resolvers using _.resolver_name syntax in CEL expressions. For names with hyphens, use bracket notation: _["resolver-name"]. See the CEL tutorial for details.

Step 1: Create a Solution with Dependencies#

Create config.yaml:

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: config-builder
  version: 1.0.0

spec:
  resolvers:
    environment:
      type: string
      resolve:
        with:
          - provider: parameter
            continueOnError: true
            inputs:
              key: env
          - provider: static
            inputs:
              value: development
    
    port:
      type: int
      resolve:
        with:
          - provider: parameter
            continueOnError: true
            inputs:
              key: port
          - provider: static
            inputs:
              value: 8080
    
    base_url:
      type: string
      resolve:
        with:
          - provider: cel
            inputs:
              expression: |
                _.environment == 'production' 
                  ? 'https://api.example.com' 
                  : 'http://localhost:' + string(_.port)
    
    config:
      type: any
      resolve:
        with:
          - provider: cel
            inputs:
              expression: |
                {
                  'environment': _.environment,
                  'port': _.port,
                  'baseUrl': _.base_url,
                  'debug': _.environment != 'production'
                }

Step 2: Run and Observe Phases#

scafctl run resolver -f config.yaml --progress
scafctl run resolver -f config.yaml --progress

The --progress flag shows how resolvers execute in phases based on dependencies:

[1] environment  ✓   <time>
[1] port         ✓   <time>
[2] base_url     ✓   <time>
[3] config       ✓   <time>

Phase numbers in brackets show concurrent execution groups. Resolvers in the same phase run concurrently.

Dependency Rules#

  • Dependencies are auto-inferred from value references (expr:, rslvr:, tmpl:) – you do NOT need to declare dependsOn when a resolver references another by value
  • Resolvers in the same phase run concurrently
  • A resolver waits for all its dependencies to complete
  • Circular dependencies cause an error
  • Only add explicit dependsOn when a resolver must wait for another without referencing its value (pure ordering dependency)

Transformations#

Transform values after they’re resolved using the transform phase.

Example: String Manipulation#

Create a file called transform.yaml:

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: transform-example
  version: 1.0.0

spec:
  resolvers:
    raw_input:
      type: string
      resolve:
        with:
          - provider: parameter
            continueOnError: true
            inputs:
              key: input
          - provider: static
            inputs:
              value: "  Hello World  "
      transform:
        with:
          - provider: cel
            inputs:
              expression: "__self.trim()"
          - provider: cel
            inputs:
              expression: "__self.lowerAscii()"

Key Concept: In the transform phase, __self refers to the current value being transformed. Each transform step receives the output of the previous step.

Run it:

scafctl run resolver -f transform.yaml -o json
scafctl run resolver -f transform.yaml -o json

Output:

{
  "raw_input": "hello world"
}

Tip: scafctl run resolver -o json outputs only resolver values by default. Pass --show-execution to include __execution metadata (phases, timing, provider info).

The value was trimmed of whitespace, then lowercased — each transform step feeds into the next.

Example: Data Enrichment#

Create a file called enrich.yaml:

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: enrich-config
  version: 1.0.0

spec:
  resolvers:
    base_config:
      type: any
      resolve:
        with:
          - provider: static
            inputs:
              value:
                name: my-app
                version: "1.0.0"
      transform:
        with:
          # Add timestamp
          - provider: cel
            inputs:
              expression: "map.merge(__self, {'timestamp': time.now()})"
          # Add environment-specific settings
          - provider: cel
            inputs:
              expression: "map.merge(__self, {'debug': true, 'logLevel': 'info'})"

Run it:

scafctl run resolver -f enrich.yaml -o json
scafctl run resolver -f enrich.yaml -o json

Output (timestamp will vary):

{
  "base_config": {
    "debug": true,
    "logLevel": "info",
    "name": "my-app",
    "timestamp": "2026-02-16T12:00:00.000000-05:00",
    "version": "1.0.0"
  }
}

Validation#

Validate resolved values to ensure they meet requirements.

Example: Port Range Validation#

Create a file called validated-config.yaml:

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: validated-config
  version: 1.0.0

spec:
  resolvers:
    port:
      type: int
      resolve:
        with:
          - provider: parameter
            continueOnError: true
            inputs:
              key: port
          - provider: static
            inputs:
              value: 8080
      validate:
        with:
          - provider: validation
            inputs:
              expression: "__self >= 1024 && __self <= 65535"
              message: "Port must be between 1024 and 65535"

Run it with a valid port:

scafctl run resolver -f validated-config.yaml -r port=8080 -o json
scafctl run resolver -f validated-config.yaml -r port=8080 -o json

Output:

{
  "port": 8080
}

Run it with an invalid port to see the validation error:

scafctl run resolver -f validated-config.yaml -r port=80
scafctl run resolver -f validated-config.yaml -r port=80

Output:

❌ resolver execution failed: ... validation: Port must be between 1024 and 65535

Example: Multiple Validations#

Create a file called email-validator.yaml:

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: email-validator
  version: 1.0.0

spec:
  resolvers:
    email:
      type: string
      resolve:
        with:
          - provider: parameter
            continueOnError: true
            inputs:
              key: email
          - provider: static
            inputs:
              value: "user@example.com"
      validate:
        with:
          - provider: validation
            inputs:
              match: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"
              message: "Invalid email format"
          - provider: validation
            inputs:
              expression: "!__self.endsWith('.test')"
              message: "Test emails not allowed"

Note: All validation rules run and errors are aggregated. You’ll see all failures, not just the first one.

Run it:

scafctl run resolver -f email-validator.yaml -o json
scafctl run resolver -f email-validator.yaml -o json

Output:

{
  "email": "user@example.com"
}

Now try an invalid value that fails both validations — not a valid email format and ends with .test:

scafctl run resolver -f email-validator.yaml -r email="not-an-email.test" -o json
scafctl run resolver -f email-validator.yaml -r email="not-an-email.test" -o json

Output:

❌ resolver execution failed: phase 1 failed: resolver "email" failed: resolver "email" validation failed with 2 errors:
  - [rule 1] validation: Invalid email format
  - [rule 2] validation: Test emails not allowed

Both validation errors are reported together rather than failing on the first one.

Example: Conditional Validation Rule#

Each rule in validate.with can carry its own when condition. When the condition evaluates to false, that single rule is skipped while the other rules still run. The condition can reference __self (the resolved value) and any other resolver via _. This is useful when a rule only applies to certain values – for example, only enforcing a strict pattern in production.

Create a file called conditional-validation.yaml:

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: conditional-validation
  version: 1.0.0

spec:
  resolvers:
    appName:
      type: string
      resolve:
        with:
          - provider: parameter
            continueOnError: true
            inputs:
              key: appName
          - provider: static
            inputs:
              value: my-app
      validate:
        with:
          # Always enforced: lowercase, alphanumeric with hyphens.
          - provider: validation
            inputs:
              match: "^[a-z0-9-]+$"
              message: "Name must be lowercase alphanumeric with hyphens"
          # Only enforced in production: must start with "prod-".
          - provider: validation
            when:
              expr: "_.environment == 'production'"
            inputs:
              match: "^prod-"
              message: "Production app names must start with 'prod-'"

    environment:
      type: string
      resolve:
        with:
          - provider: parameter
            continueOnError: true
            inputs:
              key: env
          - provider: static
            inputs:
              value: development

In development (the default), the second rule is skipped, so my-app passes. In production, the second rule runs and a name without the prod- prefix fails. Put the when on the individual rule inside validate.with, not on the validate phase as a whole (a phase-level validate.when gates every rule at once).


Conditional Execution#

Skip resolvers or phases based on conditions.

Resolver-Level Condition#

Create a file called conditional.yaml:

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: conditional-example
  version: 1.0.0

spec:
  resolvers:
    environment:
      type: string
      resolve:
        with:
          - provider: parameter
            continueOnError: true
            inputs:
              key: env
          - provider: static
            inputs:
              value: development
    
    # Only runs in production
    prod_secrets:
      when:
        expr: "_.environment == 'production'"
      type: string
      resolve:
        with:
          - provider: static
            inputs:
              value: "prod-secret-value"

Run it with development (default) — the prod_secrets resolver is skipped:

scafctl run resolver -f conditional.yaml -o json
scafctl run resolver -f conditional.yaml -o json

Output (only environment is resolved; prod_secrets is skipped):

{
  "environment": "development"
}

Run it with production — the prod_secrets resolver executes:

scafctl run resolver -f conditional.yaml -r env=production -o json
scafctl run resolver -f conditional.yaml -r env=production -o json

Output:

{
  "environment": "production",
  "prod_secrets": "prod-secret-value"
}

Phase-Level Condition#

Create a file called phase-condition.yaml:

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: phase-condition
  version: 1.0.0

spec:
  resolvers:
    feature_flags:
      type: any
      resolve:
        with:
          - provider: static
            inputs:
              value:
                enable_transform: true
      transform:
        with:
          - provider: cel
            when:
              expr: "__self.enable_transform == true"
            inputs:
              expression: "map.merge(__self, {'transformed': true})"

Run it:

scafctl run resolver -f phase-condition.yaml -o json
scafctl run resolver -f phase-condition.yaml -o json

Output:

{
  "feature_flags": {
    "enable_transform": true,
    "transformed": true
  }
}

Error Handling#

Handle errors gracefully with fallback sources.

Fallback Pattern#

Create a file called fallback.yaml:

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: fallback-example
  version: 1.0.0

spec:
  resolvers:
    config:
      type: any
      resolve:
        with:
          # Try remote config first
          - provider: http
            inputs:
              url: https://config.example.com/settings
              timeout: 5s
          # Fall back to local file
          - provider: file
            inputs:
              operation: read
              path: ./config.json
          # Last resort: default values
          - provider: static
            inputs:
              value:
                debug: false
                timeout: 30

Run it (the HTTP and file providers will fail, so it falls back to static):

scafctl run resolver -f fallback.yaml -o json
scafctl run resolver -f fallback.yaml -o json

Output:

{
  "config": {
    "debug": false,
    "timeout": 30
  }
}

Controlling fallback with continueOnError (resolve phase):

The resolve phase falls through to the next source by default. Use continueOnError to control this explicitly. It accepts a boolean or a CEL condition (truthy recovers, falsy fails):

  • continueOnError: true (resolve default): Try the next source in the list. The resolve phase acts as an implicit fallback chain.
  • continueOnError: false: Stop execution immediately and return the error without trying remaining sources.

The legacy onError enum (continue/fail) is deprecated but still works. Migrate onError: continue to continueOnError: true and onError: fail to continueOnError: false. When both are set, continueOnError wins and the linter reports an error.

Conditional Recovery with continueOnError#

A boolean continueOnError is unconditional: it either always recovers or always fails. Pass a CEL condition instead to recover only for specific errors and fail for everything else. The condition is evaluated when the source (or transform step) fails, with the error text bound as __error:

  • If the condition is truthy, the error is recovered (resolve: try the next source; transform: skip the step and keep the current value).
  • If the condition is falsy, the resolver fails.
spec:
  resolvers:
    config:
      type: any
      resolve:
        with:
          # Recover only on transient timeouts; fail fast on anything else
          # (auth errors, bad config, etc.) instead of silently falling back.
          - provider: http
            inputs:
              url: https://config.example.com/settings
              timeout: 5s
            continueOnError: __error.contains("timeout")
          - provider: static
            inputs:
              value:
                debug: false

Inside continueOnError the error text is available as the top-level variable __error (the same way __self is exposed in transform/validate conditions). The condition must evaluate to a boolean.

Custom Error Messages#

Use the messages field to replace default error text with user-friendly messages. The messages.error field supports static strings, CEL expressions (expr:), and Go templates (tmpl:):

# messages-error.yaml
apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: custom-error-messages
  version: 1.0.0

spec:
  resolvers:
    port:
      description: Application port number
      messages:
        error: "Port must be between 1024 and 65535. Got: {{ .__error }}"
      resolve:
        with:
          - provider: parameter
            inputs:
              name: port
      validate:
        with:
          - provider: validation
            inputs:
              rules:
                - expr: "int(__self) >= 1024 && int(__self) <= 65535"
                  message: invalid port range

    apiEndpoint:
      description: API endpoint URL
      messages:
        error:
          expr: "'Failed to reach API endpoint. Error: ' + __error"
      resolve:
        with:
          - provider: http
            inputs:
              url: https://api.example.com/health
              method: GET

The messages.error value is evaluated when any phase fails (resolve, transform, or validate). Two variables are available:

VariableDescription
_The resolver data map (all resolved values so far)
__errorThe original error message string

When a custom error message is set, it replaces the default error text in all output — CLI, MCP tools, and snapshot captures.


Working with HTTP APIs#

Fetch configuration from remote APIs.

Basic HTTP Request#

Create a file called http-example.yaml:

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: http-example
  version: 1.0.0

spec:
  resolvers:
    api_data:
      type: any
      resolve:
        with:
          - provider: http
            inputs:
              url: https://httpbin.org/get
              method: GET
              headers:
                Accept: application/json
              timeout: 10s

Run it:

scafctl run resolver -f http-example.yaml -o json
scafctl run resolver -f http-example.yaml -o json

Output (body and headers will vary):

{
  "api_data": {
    "body": "...",
    "headers": { "...": "..." },
    "statusCode": 200,
    "success": true
  }
}

The success field is true for any 2xx status by default. A non-2xx response (for example 404) does not raise an error – the resolver still produces a value with success: false so you can branch on it in a transform or a downstream resolver.

Accepting Non-2xx Status Codes#

Sometimes a non-2xx status is a normal result. For example, a lookup that returns 404 when a resource does not yet exist. Use acceptableStatusCodes to declare which statuses count as successful. Each entry may be an exact integer (200), a class shorthand ("2xx"), or an inclusive range ("200-204"):

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: optional-lookup
  version: 1.0.0

spec:
  resolvers:
    existing:
      type: any
      resolve:
        with:
          - provider: http
            inputs:
              url: https://api.example.com/users/maybe-missing
              method: GET
              autoParseJson: true
              acceptableStatusCodes: [200, 404]

With acceptableStatusCodes set, a 404 yields success: true and the body is still returned (and parsed when autoParseJson is enabled). Any status outside the set – such as 500 – fails the source, which lets a continueOnError: true policy fall back to the next source:

spec:
  resolvers:
    config:
      type: any
      resolve:
        with:
          # Fails on any status other than 2xx or 404, falling through below.
          - provider: http
            continueOnError: true
            inputs:
              url: https://api.example.com/config
              method: GET
              autoParseJson: true
              acceptableStatusCodes: [200, 404]
          - provider: static
            inputs:
              value: { fallback: true }

With Authentication#

Create a file called auth-api.yaml:

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: authenticated-api
  version: 1.0.0

spec:
  resolvers:
    api_token:
      sensitive: true  # Redact in table output
      resolve:
        with:
          - provider: env
            inputs:
              operation: get
              name: API_TOKEN
    
    api_data:
      type: any
      resolve:
        with:
          - provider: http
            inputs:
              url: https://httpbin.org/headers
              headers:
                Authorization:
                  tmpl: "Bearer {{.api_token}}"

Run it (requires the API_TOKEN environment variable to be set):

export API_TOKEN=your-token-here
scafctl run resolver -f auth-api.yaml -o json
$env:API_TOKEN = "your-token-here"
scafctl run resolver -f auth-api.yaml -o json

Common Patterns#

The following patterns are complete, self-contained solution files. Create a new file for each pattern to try it out.

Pattern 1: Environment-Based Configuration#

Create a file called env-config.yaml:

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: env-config
  version: 1.0.0

spec:
  resolvers:
    environment:
      type: string
      resolve:
        with:
          - provider: parameter
            continueOnError: true
            inputs:
              key: env
          - provider: static
            inputs:
              value: development
    
    database_url:
      type: string
      sensitive: true
      resolve:
        with:
          - provider: cel
            inputs:
              expression: |
                _.environment == 'production' 
                  ? 'postgres://prod-db.example.com:5432/app'
                  : 'postgres://localhost:5432/app_dev'
scafctl run resolver -f env-config.yaml
scafctl run resolver -f env-config.yaml

Output:

╭─ scafctl run resolver ─╮
│KEY           VALUE     │
│─────────────────────── │
│database_url  [REDACTED]│
│environment   development│
╰ _ ─────────── map: 1/2 ╯

Note: Fields marked sensitive: true are shown as [REDACTED] in table output.

Structured output (JSON, YAML) reveals sensitive values for machine consumption:

scafctl run resolver -f env-config.yaml -o json
scafctl run resolver -f env-config.yaml -o json

Output:

{
  "database_url": "postgres://localhost:5432/app_dev",
  "environment": "development"
}

Use --show-sensitive to reveal values in table output:

scafctl run resolver -f env-config.yaml --show-sensitive
scafctl run resolver -f env-config.yaml --show-sensitive

Sensitive Redaction Behavior: Sensitive values are redacted in table/interactive output (human-facing) but revealed in JSON/YAML output (machine-facing), following the same model as Terraform. Use --show-sensitive to reveal values in all output formats.

Pattern 2: Feature Toggles#

Create a file called feature-toggles.yaml:

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: feature-toggles
  version: 1.0.0

spec:
  resolvers:
    features:
      type: any
      resolve:
        with:
          - provider: http
            continueOnError: true
            inputs:
              url: https://features.example.com/api/flags
          - provider: static
            inputs:
              value:
                new_ui: false
                dark_mode: true
    
    ui_config:
      type: any
      resolve:
        with:
          - provider: cel
            inputs:
              expression: |
                {
                  'theme': _.features.dark_mode ? 'dark' : 'light',
                  'version': _.features.new_ui ? 'v2' : 'v1'
                }
scafctl run resolver -f feature-toggles.yaml -o json
scafctl run resolver -f feature-toggles.yaml -o json

Output:

{
  "features": {
    "dark_mode": true,
    "new_ui": false
  },
  "ui_config": {
    "theme": "dark",
    "version": "v1"
  }
}

Pattern 3: Secret Management#

Create a file called secrets.yaml:

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: secrets
  version: 1.0.0

spec:
  resolvers:
    db_password:
      type: string
      sensitive: true
      resolve:
        with:
          # In practice, use env or file providers here
          # with continueOnError: true to fall through
          - provider: static
            inputs:
              value: "default-dev-password"
    
    connection_string:
      sensitive: true
      type: string
      resolve:
        with:
          - provider: cel
            inputs:
              expression: "'postgres://app:' + _.db_password + '@db.example.com:5432/app'"
scafctl run resolver -f secrets.yaml
scafctl run resolver -f secrets.yaml

Output:

╭─ scafctl run resolver ──────╮
│KEY                VALUE     │
│────────────────────────────  │
│connection_string  [REDACTED] │
│db_password        [REDACTED] │
╰ _ ──────────────── map: 1/2  ╯

Note: Both resolvers are marked sensitive: true, so their values are redacted in table output.

Structured output reveals the actual values:

scafctl run resolver -f secrets.yaml -o json
scafctl run resolver -f secrets.yaml -o json

Output:

{
  "connection_string": "postgres://app:default-dev-password@db.example.com:5432/app",
  "db_password": "default-dev-password"
}

Tip: Use table output (the default) when sharing your screen or in CI logs to avoid accidentally exposing secrets. Use -o json or -o yaml when piping to downstream tools that need the actual values.

Pattern 4: Multi-Stage Pipeline#

Create a file called pipeline.yaml:

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: data-pipeline
  version: 1.0.0

spec:
  resolvers:
    raw_data:
      type: any
      resolve:
        with:
          - provider: static
            inputs:
              value:
                - id: 1
                  name: "Alice"
                  email: "alice@example.com"
                  active: true
                - id: 2
                  name: "Bob"
                  email: "bob@example.com"
                  active: false
                - id: 3
                  name: "Charlie"
                  email: "charlie@example.com"
                  active: true
                - id: 4
                  name: "Diana"
                  email: "diana@example.com"
                  active: true
    
    parsed_data:
      type: any
      resolve:
        with:
          - provider: cel
            inputs:
              expression: "_.raw_data"
      transform:
        with:
          # Filter active users
          - provider: cel
            inputs:
              expression: "__self.filter(u, u.active == true)"
          # Select only needed fields
          - provider: cel
            inputs:
              expression: "__self.map(u, {'id': u.id, 'name': u.name, 'email': u.email})"
      validate:
        with:
          - provider: validation
            inputs:
              expression: "size(__self) > 0"
              message: "No active users found"
scafctl run resolver -f pipeline.yaml -o json
scafctl run resolver -f pipeline.yaml -o json

Output:

{
  "parsed_data": [
    { "email": "alice@example.com", "id": 1, "name": "Alice" },
    { "email": "charlie@example.com", "id": 3, "name": "Charlie" },
    { "email": "diana@example.com", "id": 4, "name": "Diana" }
  ],
  "raw_data": [
    { "active": true, "email": "alice@example.com", "id": 1, "name": "Alice" },
    { "active": false, "email": "bob@example.com", "id": 2, "name": "Bob" },
    { "active": true, "email": "charlie@example.com", "id": 3, "name": "Charlie" },
    { "active": true, "email": "diana@example.com", "id": 4, "name": "Diana" }
  ]
}

Bob was filtered out of parsed_data because active was false. The raw_data resolver is also included since run resolver returns all resolvers by default.


Array Iteration with forEach#

Transform Phase forEach#

When a resolver produces an array, the transform.with.forEach clause processes each element independently and collects results back into an array:

Save this as foreach-demo.yaml:

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: foreach-demo
  version: 1.0.0

spec:
  resolvers:
    doubled:
      type: array
      resolve:
        with:
          - provider: static
            inputs:
              value: [1, 2, 3, 4, 5]
      transform:
        with:
          - provider: cel
            forEach:
              item: num       # alias for the current element
              index: i        # alias for the current index
            inputs:
              expression: "num * 2"

Run it to see the result:

scafctl run resolver doubled -f foreach-demo.yaml -o json
scafctl run resolver doubled -f foreach-demo.yaml -o json

Filtering with when and forEach#

By default, items where the when condition evaluates to false are removed from the output array. This makes forEach + when a natural filter:

transform:
  with:
    - provider: cel
      forEach:
        item: num
      when:
        expr: "num % 2 == 0"    # only even numbers
      inputs:
        expression: "num * 2"

Input [1, 2, 3, 4, 5] → output [4, 8] (only even numbers, doubled).

To retain index alignment (nil in place of skipped items), set keepSkipped: true:

forEach:
  item: num
  keepSkipped: true    # output: [nil, 4, nil, 8, nil]

Transform Phase forEach with Filtering#

To filter an array produced by a resolver, use forEach in the transform phase with a when condition:

Save this as foreach-filter-demo.yaml:

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: foreach-filter-demo
  version: 1.0.0

spec:
  resolvers:
    allUsers:
      type: array
      resolve:
        with:
          - provider: static
            inputs:
              value:
                - {name: Alice, active: true}
                - {name: Bob,   active: false}
                - {name: Carol, active: true}

    activeUsers:
      type: array
      resolve:
        with:
          - provider: cel
            inputs:
              expression: _.allUsers
      transform:
        with:
          - provider: cel
            forEach:
              item: user
            when:
              expr: 'user.active == true'
            inputs:
              expression: 'user'

By default, items where the when condition is false are removed from the output array. The result contains only matched items:

[{"name": "Alice", "active": true}, {"name": "Carol", "active": true}]

To retain nil entries for skipped items (preserving index alignment), set keepSkipped: true:

forEach:
  item: user
  keepSkipped: true    # output: [{...Alice...}, nil, {...Carol...}]

Run it:

scafctl run resolver activeUsers -f foreach-filter-demo.yaml -o json
scafctl run resolver activeUsers -f foreach-filter-demo.yaml -o json

Troubleshooting#

Common Issues#

Circular dependency error

Error: circular dependency detected: a -> b -> a

Solution: Refactor to break the cycle, possibly by combining resolvers.

Type coercion error

Error: cannot coerce "hello" to int

Solution: Ensure your provider returns a value compatible with the declared type.

Timeout error

Error: resolver "slow_api" timed out after 30s

Solution: Increase the timeout in the resolver definition:

timeout: 60s

Validation failed

Error: validation failed: Port must be between 1024 and 65535

Solution: Check your input values meet the validation requirements.


Next Steps#