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 linttime (the advisory subset) - Validate gate –
scafctl validateruns lint as a pass/fail gate for CI and pre-commit;scafctl validate schemachecks 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#
| Constraint | Applies To | Helper Function | Description |
|---|---|---|---|
enum | strings, ints | WithEnum(vals...) | Restrict to allowed values |
pattern | strings | WithPattern(regex) | Must match regex |
minLength / maxLength | strings | WithMinLength(n) / WithMaxLength(n) | String length bounds |
minimum / maximum | numbers | WithMinimum(n) / WithMaximum(n) | Numeric range bounds |
minItems / maxItems | arrays | WithMinItems(n) / WithMaxItems(n) | Array size bounds |
format | strings | WithFormat(fmt) | Semantic format hint (uri, email, date, uuid) |
default | any | WithDefault(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.yamlscafctl lint my-solution.yamlERROR [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.yamlPattern: 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:
__selfrefers 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.bodyThis 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.yamlscafctl lint my-solution.yamlKey Validation Rules#
| Rule | Severity | What It Catches |
|---|---|---|
schema-violation | error | Unknown fields, type mismatches, pattern violations |
unknown-provider-input | error | Input key not in provider’s schema |
invalid-provider-input-type | error | Literal value wrong type for field |
invalid-expression | error | CEL syntax errors |
invalid-template | error | Go template syntax errors |
finally-with-foreach | error | forEach in finally block (unsupported) |
resolver-self-reference | error | Using _.name instead of __self in validate/transform |
permissive-result-schema | info | resultSchema without type constraint |
undefined-required-property | error | Required 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 --strictscafctl 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 2Common validation error patterns and their meaning:
| Error Pattern | Cause | Fix |
|---|---|---|
validation failed: match | Value didn’t match the regex in match | Check the regex or the input value |
validation failed: notMatch | Value matched the regex in notMatch | Ensure value doesn’t contain forbidden pattern |
validation failed: expression | CEL expression returned false | Review the expression and input data |
'field' is required for X operation | Missing required provider input | Add the field to your resolver input |
unknown operation | Invalid operation value | Check 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-west1Tip: Put dynamic messages under
inputs.message(with atmpl:orexpr: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-west1In 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#
- CEL Expressions Tutorial — dive deeper into CEL functions for validation
- Linting Tutorial — static analysis and auto-fix
- Functional Testing Tutorial — automated tests that verify validation behavior
- Provider Reference — full schema docs for all providers