Validation Patterns Tutorial#

Learn how to validate resolver outputs, enforce constraints on inputs, and use schema validation to catch issues early.

Overview#

scafctl offers multiple validation layers that work together to ensure configuration correctness:

block-beta
  columns 4
  block:top:4
    A["Solution YAML"]
  end
  B["Lint<br/>Rules"] C["Schema<br/>Helper Tags"] D["Validate<br/>Provider"] E["Result<br/>Schema"]
  block:bottom:4
    F["Runtime Validation (CEL + Regex)"]
  end
  • Schema helper tags — provider-level input constraints (type, length, pattern, enum)
  • Validation provider — runtime regex and CEL-based checks on resolver values
  • Result schemas — JSON Schema validation of action outputs
  • Lint rules – static analysis at scafctl lint time (the advisory subset)
  • Validate gatescafctl validate runs lint as a pass/fail gate for CI and pre-commit; scafctl validate schema checks arbitrary data against a JSON Schema

1. Schema-Level Validation with Provider Inputs#

Provider schemas use JSON Schema constraints to reject invalid input before execution.

Available Constraints#

ConstraintApplies ToHelper FunctionDescription
enumstrings, intsWithEnum(vals...)Restrict to allowed values
patternstringsWithPattern(regex)Must match regex
minLength / maxLengthstringsWithMinLength(n) / WithMaxLength(n)String length bounds
minimum / maximumnumbersWithMinimum(n) / WithMaximum(n)Numeric range bounds
minItems / maxItemsarraysWithMinItems(n) / WithMaxItems(n)Array size bounds
formatstringsWithFormat(fmt)Semantic format hint (uri, email, date, uuid)
defaultanyWithDefault(val)Default value if omitted

Example: Provider Schema in Go#

schema := schemahelper.ObjectSchema(
    []string{"name", "environment"}, // required fields
    map[string]*jsonschema.Schema{
        "name": schemahelper.StringProp("Service name",
            schemahelper.WithPattern(`^[a-z][a-z0-9-]*$`),
            schemahelper.WithMinLength(3),
            schemahelper.WithMaxLength(63),
            schemahelper.WithExample("my-service"),
        ),
        "environment": schemahelper.StringProp("Target environment",
            schemahelper.WithEnum("development", "staging", "production"),
            schemahelper.WithExample("production"),
        ),
        "replicas": schemahelper.IntProp("Number of replicas",
            schemahelper.WithMinimum(1),
            schemahelper.WithMaximum(100),
            schemahelper.WithDefault(3),
        ),
        "tags": schemahelper.ArrayProp("Resource tags",
            schemahelper.WithMaxItems(20),
            schemahelper.WithItems(schemahelper.StringProp("tag value",
                schemahelper.WithMaxLength(128),
            )),
        ),
        "callback_url": schemahelper.StringProp("Webhook callback URL",
            schemahelper.WithFormat("uri"),
        ),
    },
)

When a resolver passes invalid input to this provider, the lint system catches it at analysis time:

scafctl lint my-solution.yaml
scafctl lint my-solution.yaml
ERROR [invalid-provider-input-type] resolver 'deploy': input 'replicas' expects integer, got string
ERROR [schema-violation] resolver 'deploy': input 'name' does not match pattern '^[a-z][a-z0-9-]*$'

2. Runtime Validation with the validation Provider#

The validation provider runs checks during the validate phase of a resolver, or as a standalone transform.

Pattern: Regex Matching#

Validate string values against regex patterns:

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: regex-validation-demo
  version: 1.0.0
spec:
  resolvers:
    email:
      resolve:
        with:
          - 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: "Must be a valid email address"

    semver:
      resolve:
        with:
          - provider: static
            inputs:
              value: "v1.2.3"
      validate:
        with:
          - provider: validation
            inputs:
              match: '^v?\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$'
            message: "Must be a valid semantic version"

    k8s_name:
      resolve:
        with:
          - provider: static
            inputs:
              value: "my-service-01"
      validate:
        with:
          - provider: validation
            inputs:
              match: '^[a-z][a-z0-9-]{0,61}[a-z0-9]$'
              notMatch: '--'
            message: "Must be a valid Kubernetes resource name"
▶ Try it

Save the snippet above as regex-patterns.yaml, then run:

scafctl run resolver -f regex-patterns.yaml

Pattern: CEL Expression Validation#

Use CEL expressions for complex, type-aware validation:

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: cel-validation-demo
  version: 1.0.0
spec:
  resolvers:
    port:
      type: int
      resolve:
        with:
          - provider: static
            inputs:
              value: 8080
      validate:
        with:
          - provider: validation
            inputs:
              expression: "__self >= 1024 && __self <= 65535"
            message: "Port must be in the unprivileged range (1024-65535)"

    environment:
      resolve:
        with:
          - provider: static
            inputs:
              value: "staging"
      validate:
        with:
          - provider: validation
            inputs:
              expression: "__self in ['development', 'staging', 'production']"
            message: "Environment must be one of: development, staging, production"

    password:
      resolve:
        with:
          - provider: static
            inputs:
              value: "SecureP@ss1"
      validate:
        with:
          - provider: validation
            inputs:
              match: '.{8,}'
            message: "Password must be at least 8 characters"
          - provider: validation
            inputs:
              match: '[A-Z]'
            message: "Password must contain an uppercase letter"
          - provider: validation
            inputs:
              match: '[0-9]'
            message: "Password must contain a digit"
          - provider: validation
            inputs:
              match: '[!@#$%^&*]'
            message: "Password must contain a special character"

Tip: __self refers to the resolver’s current value inside validate and transform phases. Use _ to access the full resolver data map for cross-field validation.

Pattern: Cross-Field Validation#

Validate relationships between different resolver values:

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: cross-field-validation
  version: 1.0.0
spec:
  resolvers:
    min_replicas:
      type: int
      resolve:
        with:
          - provider: static
            inputs:
              value: 2

    max_replicas:
      type: int
      dependsOn: [min_replicas]
      resolve:
        with:
          - provider: static
            inputs:
              value: 10
      validate:
        with:
          - provider: validation
            inputs:
              expression: "__self >= _.min_replicas"
            message: "max_replicas must be >= min_replicas"

    environment:
      resolve:
        with:
          - provider: static
            inputs:
              value: "production"

    replicas:
      type: int
      dependsOn: [environment]
      resolve:
        with:
          - provider: static
            inputs:
              value: 3
      validate:
        with:
          - provider: validation
            inputs:
              expression: |
                _.environment == 'production'
                  ? __self >= 3
                  : __self >= 1
            message: "Production requires at least 3 replicas"

Pattern: Validating a Raw Upstream Response (Split-Resolver)#

The validate phase runs after transform and type coercion, so __self is always the resolver’s final output – never the raw provider response. To assert against a raw upstream response (for example, checking connectivity or that a remote resource exists before reshaping it), split the work across two resolvers: a raw resolver that fetches and carries the connectivity/existence checks, and a downstream resolver that depends on it and reshapes the value.

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: split-resolver-validation
  version: 1.0.0
spec:
  resolvers:
    # 1. Raw fetch + connectivity/existence checks on the unshaped response.
    user_raw:
      resolve:
        with:
          - provider: http
            inputs:
              url: https://api.example.com/users/me
      validate:
        with:
          - provider: validation
            inputs:
              expression: "__self.statusCode == 200"
            message: "User endpoint unreachable or returned a non-200 status"

    # 2. Downstream reshape into the final output contract.
    user:
      dependsOn: [user_raw]
      type: object
      resolve:
        with:
          - provider: cel
            inputs:
              expression: _.user_raw.body

This keeps each resolver single-purpose: user_raw owns the fetch and its liveness checks, while user owns the final shape. It avoids inspecting a pre-transform value inside a single resolver and preserves the output-contract semantics of validate.


3. Action Result Schema Validation#

Actions can define a resultSchema to validate their output structure using JSON Schema:

apiVersion: scafctl.io/v1
kind: Solution
metadata:
  name: result-schema-demo
  version: 1.0.0
spec:
  resolvers:
    config:
      resolve:
        with:
          - provider: http
            inputs:
              method: GET
              url: https://api.example.com/config

  workflow:
    actions:
      deploy:
        resultSchema:
          type: object
          required:
            - status
            - deploymentId
          properties:
            status:
              type: string
              enum: [success, pending, failed]
            deploymentId:
              type: string
              pattern: "^deploy-[a-f0-9]{8}$"
            instances:
              type: array
              minItems: 1
              items:
                type: object
                required: [id, region]
                properties:
                  id:
                    type: string
                  region:
                    type: string
                    enum: [us-east-1, us-west-2, eu-west-1]
          additionalProperties: false
        provider: exec
        inputs:
          command: "echo '{\"status\":\"success\",\"deploymentId\":\"deploy-a1b2c3d4\",\"instances\":[{\"id\":\"i-1\",\"region\":\"us-east-1\"}]}'"

If the action output doesn’t match the schema, execution fails with a clear error:

ERROR: action 'deploy' result does not match schema: property 'status' must be one of [success, pending, failed]

4. Lint-Time Static Validation#

scafctl lint catches issues without executing the solution:

scafctl lint my-solution.yaml
scafctl lint my-solution.yaml

Key Validation Rules#

RuleSeverityWhat It Catches
schema-violationerrorUnknown fields, type mismatches, pattern violations
unknown-provider-inputerrorInput key not in provider’s schema
invalid-provider-input-typeerrorLiteral value wrong type for field
invalid-expressionerrorCEL syntax errors
invalid-templateerrorGo template syntax errors
finally-with-foreacherrorforEach in finally block (unsupported)
resolver-self-referenceerrorUsing _.name instead of __self in validate/transform
permissive-result-schemainforesultSchema without type constraint
undefined-required-propertyerrorRequired property not defined in properties
# Lint with auto-fix where possible
scafctl lint --fix my-solution.yaml

# Lint all solutions in a directory
scafctl lint ./solutions/
# Lint with auto-fix where possible
scafctl lint --fix my-solution.yaml

# Lint all solutions in a directory
scafctl lint ./solutions/

5. The validate Gate#

While scafctl lint is the advisory subset that reports authoring findings, scafctl validate is THE gate that turns those findings into a pass/fail result. It runs lint (which includes a JSON Schema conformance check) and exits non-zero when the definition is not ready – ideal for CI pipelines and pre-commit checks.

Validate a Solution#

Loads a solution and runs lint. Lint errors (including schema violations) fail; lint warnings are surfaced but do not fail unless --strict is passed:

# Errors fail, warnings surface (exit 0 if only warnings)
scafctl validate solution -f my-solution.yaml

# Treat warnings as fatal too
scafctl validate solution -f my-solution.yaml --strict
# Errors fail, warnings surface (exit 0 if only warnings)
scafctl validate solution -f my-solution.yaml

# Treat warnings as fatal too
scafctl validate solution -f my-solution.yaml --strict

scafctl validate resolver behaves the same way but first executes the resolver phases (resolve, transform, validate) and then runs lint as part of the gate; --strict makes lint warnings fatal there too.

Validate Arbitrary Data Against a JSON Schema#

scafctl validate schema validates any JSON or YAML document against a JSON Schema. Unlike validate solution, it does NOT run lint – it checks raw data conformance only, so it works for config files, API payloads, or any document. Pass --data - to read the data from stdin:

# Validate a data file against a schema (both JSON or YAML)
scafctl validate schema --schema schema.json --data data.json

# Read the data from stdin
cat data.yaml | scafctl validate schema --schema schema.json --data -
# Validate a data file against a schema (both JSON or YAML)
scafctl validate schema --schema schema.json --data data.json

# Read the data from stdin
Get-Content data.yaml | scafctl validate schema --schema schema.json --data -

Exit codes: 0 conforms, 2 violates the schema, 3 the schema itself is invalid, 4 a file was not found.


6. Common Validation Patterns Reference#

String Patterns#

# Email
match: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'

# URL (http/https)
match: '^https?://[^\s/$.?#].[^\s]*$'

# Semantic version
match: '^v?\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?(\+[a-zA-Z0-9.]+)?$'

# Kubernetes resource name (RFC 1123)
match: '^[a-z][a-z0-9-]{0,61}[a-z0-9]$'

# UUID
match: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'

# IPv4 address
match: '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$'

# CIDR notation
match: '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/\d{1,2}$'

# ISO 8601 date
match: '^\d{4}-\d{2}-\d{2}$'

Numeric Constraints with CEL#

# Port range
expression: "__self >= 1 && __self <= 65535"

# Unprivileged port
expression: "__self >= 1024 && __self <= 65535"

# Percentage
expression: "__self >= 0.0 && __self <= 100.0"

# Memory limit (MB, must be power of 2)
expression: "__self > 0 && __self % 2 == 0"

# Timeout (1s to 5min)
expression: "__self >= 1 && __self <= 300"

Collection Constraints with CEL#

# Non-empty array
expression: "size(__self) > 0"

# Array max size
expression: "size(__self) <= 50"

# All items match pattern
expression: "__self.all(item, item.matches('^[a-z-]+$'))"

# At least one item meets criteria
expression: "__self.exists(item, item.startsWith('prod-'))"

# No duplicates
expression: "size(__self) == size(__self.distinct())"

# Map has required keys
expression: "has(__self.region) && has(__self.zone)"

Multi-Rule Composition#

Apply multiple validation rules by chaining validation provider entries:

validate:
  with:
    - provider: validation
      inputs:
        match: '.{8,}'
      message: "Must be at least 8 characters"
    - provider: validation
      inputs:
        match: '[A-Z]'
      message: "Must contain an uppercase letter"
    - provider: validation
      inputs:
        notMatch: '\s'
      message: "Must not contain whitespace"
    - provider: validation
      inputs:
        expression: "__self != _.username"
      message: "Must not match the username"

7. Dynamic Validation Messages#

The message field supports dynamic evaluation using expr: (CEL) or tmpl: (Go template) prefixes. This lets you include the actual value or computed context in error messages.

CEL Expression Messages#

Prefix the message with expr: to evaluate it as a CEL expression. The expression has access to __self (the current value) and _ (all resolver data):

validate:
  with:
    - provider: validation
      inputs:
        match: "^[a-z][a-z0-9-]*$"
      message: "expr: 'Invalid name \"' + string(__self) + '\": must start with a lowercase letter and contain only [a-z0-9-]'"
    - provider: validation
      inputs:
        expression: "size(__self) <= 63"
      message: "expr: 'Name \"' + string(__self) + '\" is ' + string(size(__self)) + ' chars (max 63)'"

Go Template Messages#

Prefix the message with tmpl: to evaluate it as a Go template. The template data includes all resolver values plus __self:

validate:
  with:
    - provider: validation
      inputs:
        expression: "__self != _.environment"
      message: "tmpl: Name '{{.__self}}' must not match the environment name '{{.environment}}'"
    - provider: validation
      inputs:
        match: "^[a-z]"
      message: "tmpl: Value '{{.__self}}' must start with a lowercase letter"

Fallback Behavior#

If the dynamic expression fails to evaluate (syntax error, missing variable), the raw message content (minus the prefix) is used as the error message, and a debug log is emitted. This ensures validation never silently passes due to a message evaluation error.

Plain Static Messages#

Messages without a prefix are used as-is (unchanged behavior):

validate:
  with:
    - provider: validation
      inputs:
        match: "^[a-z]"
      message: "Must start with a lowercase letter"

8. Validation Failure Diagnostics#

When validation fails, scafctl provides structured error messages. Use the MCP explain_error tool or inspect the output directly:

# Run with verbose output to see validation details
scafctl run solution -f my-solution.yaml -v 2
# Run with verbose output to see validation details
scafctl run solution -f my-solution.yaml -v 2

Common validation error patterns and their meaning:

Error PatternCauseFix
validation failed: matchValue didn’t match the regex in matchCheck the regex or the input value
validation failed: notMatchValue matched the regex in notMatchEnsure value doesn’t contain forbidden pattern
validation failed: expressionCEL expression returned falseReview the expression and input data
'field' is required for X operationMissing required provider inputAdd the field to your resolver input
unknown operationInvalid operation valueCheck the provider’s enum list

9. Cross-Resolver (Two-Phase) Validation#

Most validation rules check a single value against itself with __self. Sometimes you need to validate one resolver against another – for example, ensuring a primary and backup region differ. scafctl handles this with two-phase validation:

  • Inline rules reference only __self. They run during resolution and fail fast.
  • Deferred rules reference another resolver (via _.other, or a message template like {{ .other }}). They run after every resolver has resolved, just before actions.

The phase is chosen automatically. Cross-resolver references inside a validate block do not create a resolution dependency, so two resolvers can safely validate against each other without triggering a circular dependency error.

spec:
  resolvers:
    region:
      resolve:
        with:
          - provider: parameter
            inputs:
              key: region
              default: us-east1
      validate:
        with:
          # INLINE: references only __self -> fails fast during resolution
          - provider: validation
            inputs:
              expression: "__self != ''"
              message: "region is required"
          # DEFERRED: references _.backupRegion -> runs after all resolvers
          - provider: validation
            inputs:
              expression: "_.region != _.backupRegion"
              message: "tmpl: region must differ from backupRegion (both were {{ .region }})"

    backupRegion:
      resolve:
        with:
          - provider: parameter
            inputs:
              key: backupRegion
              default: us-west1

Tip: Put dynamic messages under inputs.message (with a tmpl: or expr: prefix) so the template renders with resolver values.

Run it – equal regions fail, differing regions pass:

# Fails: regions equal
scafctl run resolver -f my-solution.yaml -r region=us-east1 -r backupRegion=us-east1
# Passes: regions differ
scafctl run resolver -f my-solution.yaml -r region=us-east1 -r backupRegion=us-west1
# Fails: regions equal
scafctl run resolver -f my-solution.yaml -r region=us-east1 -r backupRegion=us-east1
# Passes: regions differ
scafctl run resolver -f my-solution.yaml -r region=us-east1 -r backupRegion=us-west1

In run resolver (non-fatal) mode, a failing deferred rule still shows the resolved values, reports the failure on stderr, and skips state persistence. Add --fail-on-validation to exit non-zero. See the Two-Phase Validation design doc for the full execution order and skipped/errored semantics.


Next Steps#