Solution Provider#
Date: February 9, 2026
Design Philosophy#
Three guiding principles:
Fail loud by default, opt into silence. Sub-solution failures should be Go errors unless the user explicitly asks for envelope-only reporting. This prevents the most common mistake (forgetting to check
status).Reuse existing types everywhere. The codebase already has
ValueRef,get.Interface,resolver.Context,action.ExecutionResult, etc. The solution provider should compose these, not reinvent them.Separation of loading vs execution. The provider should do one thing — execute a loaded solution. Loading is delegated entirely to
get.Interface. This makes testing trivial and keeps the provider focused.
Purpose#
The solution provider enables solution composition — executing a sub-solution as an opaque unit of work and consuming its results. The parent solution never sees the sub-solution’s internal resolvers, actions, or DAG; it receives only the final output.
This supports a composition model where reusable solutions are published to the catalog and consumed as building blocks. A team maintains deploy-to-k8s@2.0.0 in the catalog, and any parent solution invokes it without knowing its internals.
What This Provider Does#
- Loads a sub-solution from a local file, catalog reference, or URL
- Executes the sub-solution’s resolvers (and optionally its workflow) as a self-contained unit
- Returns the sub-solution’s results to the parent as structured data
- Passes parameters from the parent into the sub-solution
- Detects circular references and enforces depth limits
What This Provider Does Not Do#
- Expose the sub-solution’s internal resolvers or actions to the parent
- Interleave sub-solution execution with parent execution
- Modify the sub-solution in any way
- Manage sub-solution lifecycle or state beyond a single execution
Capabilities#
| Capability | Context | Behavior |
|---|---|---|
from | Resolver resolve.with | Runs resolvers only, returns resolver values |
action | Workflow actions | Runs resolvers + workflow, returns full result |
Solution Sources#
The provider resolves sub-solutions from multiple locations, using the same resolution logic as scafctl run solution via get.Getter:
| Source Type | Example source Value | Resolution |
|---|---|---|
| Local file | ./infra/database.yaml, /absolute/path.yaml | Loaded from the filesystem relative to the working directory |
| Catalog | deploy-to-k8s, deploy-to-k8s@2.0.0 | Searches local catalog first, then configured remote catalogs |
| URL | https://example.com/solution.yaml | Fetched via HTTP(S) |
Resolution priority follows get.Getter behavior: catalog bare name → filesystem → URL.
Note: When a parent solution referencing local file paths (e.g.,
source: "./child.yaml") is built and pushed to a catalog, all referenced files, sub-solutions, and catalog dependencies are bundled into the artifact automatically. See catalog-build-bundling.md for the full design covering file bundling, multi-file composition (compose), catalog vendoring (solution.lock), and plugin dependencies (bundle.plugins).
Catalog Version Resolution#
Bare catalog names (e.g., deploy-to-k8s) resolve to the highest semver version at execution time. This is non-deterministic — if a new version is published between runs, the newer version is used silently.
For reproducible builds, use explicit versions: deploy-to-k8s@2.0.0. Digest pinning (deploy-to-k8s@sha256:...) provides the strongest guarantee by resolving via content hash.
Future Enhancement: The
solution.lockfile generated byscafctl build solutionrecords resolved catalog versions and digests for reproducible builds. At build time, bare catalog references are resolved and vendored into the bundle. See catalog-build-bundling.md for details on the lock file and vendoring mechanism.
Input Schema#
sourceis aValueRef— allowing dynamic resolution via CEL. This unlocks patterns like selecting a catalog solution based on a resolved environment value.inputsis explicitlymap[string]*spec.ValueRef— the same type used byProviderSource.InputsandAction.Inputs, giving us expr/tmpl/rslvr/literal support for free.propagateErrors— controls error behavior, defaulting totrue(Go error on failure). Setfalsefor envelope-only reporting.maxDepth— a hard recursion limit as defense in depth beyond circular detection.
source:
type: ValueRef
required: true
description: >
Location of the sub-solution. Supports literal string, CEL expression,
resolver reference, or Go template. Resolved value must be a string
(file path, catalog reference, or URL).
example:
literal: "deploy-to-k8s@2.0.0"
# or dynamically:
# expr: "'deploy-to-' + _.target + '@2.0.0'"
inputs:
type: map[string]*ValueRef
required: false
description: >
Key-value map passed as resolver parameters to the sub-solution.
Each value supports the full ValueRef surface (literal, expr, rslvr, tmpl).
Injected via provider.WithParameters() — consumed by `parameter` provider
resolvers in the sub-solution.
propagateErrors:
type: boolean
required: false
default: true
description: >
When true (default), sub-solution failures cause the provider to return
a Go error. When false, failures are reported only in the output envelope's
`status` and `errors` fields.
maxDepth:
type: integer
required: false
default: 10
description: >
Maximum nesting depth for recursive solution composition.
Prevents runaway recursion even without direct cycles.
resolvers:
type: array
required: false
items:
type: string
description: >
List of resolver names to execute from the child solution.
When omitted or empty, all resolvers in the child solution run.
When specified, only the listed resolvers are executed, which reduces
execution time when only a subset of the child's values is needed.
An error is returned if any listed name does not exist in the child.
timeout:
type: string
required: false
description: >
Maximum duration for sub-solution execution, as a Go duration string
(e.g. "30s", "5m", "1h"). When set, a context deadline is applied to
the child's resolver and workflow execution. If the timeout expires,
the child execution is cancelled and an error is returned (or reported
in the envelope if propagateErrors is false).
example: "30s"Dynamic source#
Making source a ValueRef enables composition patterns like:
infra-solution:
type: any
resolve:
with:
- provider: solution
inputs:
source:
expr: "'infra-' + _.region + '@1.0.0'"Since the existing InputResolver already handles ValueRef resolution for all input fields, this is free — no extra implementation work.
The ExtractDependencies function on the descriptor handles source as well, ensuring that if source contains _.someResolver, the DAG correctly orders it.
Output Shaping#
The provider returns the full output envelope. To extract or reshape specific values, use the resolver’s transform phase:
infra-config:
type: any
resolve:
with:
- provider: solution
inputs:
source: "infra-config@1.0.0"
transform:
with:
- provider: cel
inputs:
expression: "__self.resolvers"This keeps the provider focused on execution and delegates post-processing to the existing framework-level mechanism. The transform phase supports CEL, Go templates, and any other transform-capable provider.
Output Envelope#
from Capability#
{
"resolvers": {
"resolver-name": "<value>"
},
"status": "success | failed",
"errors": [
{"resolver": "name", "message": "description"}
]
}action Capability#
{
"resolvers": {
"resolver-name": "<value>"
},
"workflow": {
"finalStatus": "succeeded | failed | cancelled | partial-success",
"failedActions": [],
"skippedActions": []
},
"status": "success | failed",
"errors": [],
"success": true
}The workflow field provides only aggregate status — finalStatus, failedActions, and skippedActions. Individual sub-action results are not included, maintaining the opacity boundary. If a sub-solution author wants to expose specific results to the parent, they should do so through the sub-solution’s resolvers (visible via resolvers in the envelope).
If the parent truly needs sub-action details (escape hatch), a verbose: true input could include the full workflow.actions map. But the default is opaque.
Error Handling#
Default: Propagate Errors#
When propagateErrors is true (the default), the provider:
- Executes the sub-solution.
- If any resolver fails or the workflow has
finalStatus != "succeeded", returns a Go error with a descriptive message:solution "deploy-k8s@2.0.0": resolver "db-host" failed: connection refused - The parent’s own
onErrormechanism handles it (fallback to next source,continue, orfail).
This integrates naturally with the existing resolver fallback chain. A resolver using the solution provider with onError: continue can fall back to another source if the sub-solution fails.
Opt-in: Envelope-Only#
When propagateErrors: false, the provider always returns *Output{Data: envelope} — never a Go error (except for circular references and context cancellation). The provider adds a warning via Output.Warnings when status == "failed":
sub-solution "deploy-k8s@2.0.0" failed: 1 resolver error(s). Check _.status and _.errors fields.This ensures failures appear in logs even if the parent doesn’t inspect the envelope.
Always a Go Error#
Circular reference detection and max depth violations always return a Go error regardless of propagateErrors, since these are programming mistakes.
Circular Reference & Depth Detection#
Sub-solutions can themselves use the solution provider, enabling multi-level composition. Two mechanisms prevent runaway recursion:
Ancestor Stack#
A []string ancestor stack in context, using the existing With*/From* pattern:
// pkg/provider/builtin/solutionprovider/context.go
type ancestorStackKey struct{}
func WithAncestorStack(ctx context.Context, stack []string) context.Context
func AncestorStackFromContext(ctx context.Context) []string
func PushAncestor(ctx context.Context, name string) (context.Context, error)PushAncestor checks for duplicates and returns an error with the full chain if found.
Max Depth#
In addition to cycle detection, a hard depth limit (default 10, configurable via maxDepth input) catches non-cyclic but pathologically deep composition (A → B → C → D → … → Z).
The current depth is derived from len(AncestorStackFromContext(ctx)). If adding the new solution would exceed maxDepth, return an error.
Canonical Names#
| Source Type | Canonical Name |
|---|---|
| File path | Absolute path (e.g., /home/user/infra.yaml) |
| Catalog | name@version (e.g., deploy-to-k8s@2.0.0) |
| URL | Full URL (e.g., https://example.com/solution.yaml) |
Error example:
solution: circular reference detected: parent-solution → infra-config@1.0.0 → parent-solutionContext Isolation#
Each sub-solution runs in an isolated context. This is critical for correctness — the sub-solution must not see the parent’s resolver values or parameters.
Propagated (parent → sub-solution)#
| Value | Mechanism | Rationale |
|---|---|---|
| Logger | logger.WithLogger(ctx, scoped) | Scoped with sub-solution name prefix |
| Writer | writer.FromContext(ctx) (pass through) | Sub-solution output goes to same terminal |
| Dry-run | N/A (actions are never invoked during dry-run) | WhatIf descriptions used instead |
| Auth registry | auth.WithRegistry(ctx, reg) | Sub-solutions need the same auth |
| App config | config.WithConfig(ctx, cfg) | Sub-solutions read the same config |
| Ancestor stack | Custom context key | For recursion detection |
Replaced (parent context stripped, sub-solution gets fresh values)#
| Value | Mechanism | Rationale |
|---|---|---|
| Resolver context | provider.WithResolverContext(ctx, map{}) | Sub-solution starts fresh; parent’s _ is not visible |
| Parameters | provider.WithParameters(ctx, inputs) | inputs map replaces parent’s -r params |
| Iteration context | Not propagated | Sub-solution is not part of parent’s forEach |
| Action context | Not shared | Sub-solution builds its own action.Context |
NOT Propagated#
| Value | Rationale |
|---|---|
Parent resolver.Context | Would leak parent resolver names into sub-solution’s _ namespace |
Parent __actions | Sub-solution’s actions are independent |
| Execution mode | Set independently based on whether from or action capability is active |
| Metrics | Sub-solution records its own metrics; aggregated at output |
Logger Scoping#
The logger passed to the sub-solution is scoped with the solution name:
subLogger := logger.FromContext(ctx).WithName("solution:" + canonicalName)
ctx = logger.WithLogger(ctx, subLogger)This produces log output like:
solution:deploy-k8s@2.0.0 Resolving 5 resolvers in 3 phases
solution:deploy-k8s@2.0.0 Phase 1: executing 2 resolvers concurrentlyExecution Model#
from Capability (Resolver Context)#
Execute(ctx, input) → *Output
1. Resolve `source` → string path
2. Resolve `inputs` → map[string]any params
3. Check ancestor stack + depth
4. Push ancestor
5. Load solution via get.Interface.Get(ctx, path)
6. Build isolated context:
- Fresh resolver context (empty)
- Parameters from `inputs`
- Scoped logger
7. Create resolver.Executor with shared registry
8. Execute resolvers: executor.Execute(ctx, solution.Spec.Resolvers, params)
9. Extract resolver.Context → build envelope
10. If sub-solution failed AND propagateErrors → return Go error
11. Return &Output{Data: envelope, Warnings: warnings}action Capability (Workflow Context)#
Same as above through step 8, then:
9. Extract resolverData from resolver.Context
10. Build action.Graph from solution.Spec.Workflow
11. Create action.Executor with shared registry, resolverData, and progress callback
12. Execute workflow: actionExecutor.Execute(ctx, workflow)
13. Build envelope with resolver values + workflow summary
14. If sub-solution failed AND propagateErrors → return Go error
15. Return &Output{Data: envelope, Warnings: warnings}Progress Reporting#
The action executor supports WithProgressCallback for reporting execution progress. When the solution provider runs in action capability mode, it propagates progress callbacks from the sub-solution’s action executor to the parent. Sub-solution progress events are prefixed with the solution’s canonical name to distinguish them from parent-level progress:
[solution:deploy-k8s@2.0.0] Action "provision-cluster" succeeded (3/5)
[solution:deploy-k8s@2.0.0] Action "configure-networking" running (4/5)This ensures the parent does not block silently during long-running sub-solution workflows.
Timeout#
The provider accepts an optional timeout input as a Go duration string (e.g. "30s", "5m"). When specified, a context.WithTimeout deadline is applied to the child solution’s resolver and workflow execution. If the timeout expires, child execution is cancelled and the error is either returned (default) or reported in the envelope (when propagateErrors: false).
The parent resolver/action Timeout field also applies to the entire provider execution via context deadline propagation, so the provider-level timeout acts as an inner bound within the outer deadline.
Resolver Filtering#
The provider accepts an optional resolvers input — a list of resolver names to execute from the child solution. When omitted or empty, all resolvers in the child solution run (default behaviour). When specified, only the listed resolvers are executed, which reduces execution time when only a subset of the child’s values is needed.
An error is returned if any listed name does not exist in the child solution. This catches typos early rather than silently returning partial results.
child-config:
type: any
resolve:
with:
- provider: solution
inputs:
source: "infra-config@1.0.0"
resolvers:
- database-url
- cache-ttlDry Run (WhatIf)#
In dry-run mode, the solution provider’s WhatIf function generates a description of what execution would do (e.g., Would execute solution "infra-config@1.0.0"). The provider is not invoked during dry-run — only its WhatIf description is included in the report.
The WhatIf function extracts the source from the inputs to provide a context-specific message. When source information is unavailable, it falls back to a generic description.
Example WhatIf output in the dry-run report:
// action capability (in actionPlan):
{
"name": "run-child",
"provider": "solution",
"wouldDo": "Would execute solution \"infra-config@1.0.0\"",
"phase": 1,
"section": "actions"
}Usage Examples#
Resolver: Import Values from a Catalog Solution#
spec:
resolvers:
environment:
type: string
resolve:
with:
- provider: static
inputs:
value: "production"
infra-config:
type: any
resolve:
with:
- provider: solution
inputs:
source: "infra-config@1.0.0"
inputs:
environment:
expr: "_.environment"
transform:
with:
- provider: cel
inputs:
expression: "__self.resolvers"Result: _.infra-config = {"db_host": "db.prod.internal", "db_port": 5432}
Resolver: Extract a Specific Value with CEL#
db-connection:
type: string
resolve:
with:
- provider: solution
inputs:
source: "infra-config@1.0.0"
inputs:
environment:
expr: "_.environment"
transform:
with:
- provider: cel
inputs:
expression: "__self.resolvers.db_host + ':' + string(__self.resolvers.db_port)"Result: _.db-connection = "db.prod.internal:5432"
Resolver: Dynamic Source#
infra:
type: any
resolve:
with:
- provider: solution
inputs:
source:
expr: "'infra-' + _.region + '@1.0.0'"
transform:
with:
- provider: cel
inputs:
expression: "__self.resolvers"Resolver: Local File Reference#
local-config:
type: any
resolve:
with:
- provider: solution
inputs:
source: "./shared/common-config.yaml"
transform:
with:
- provider: cel
inputs:
expression: "__self.resolvers"Workflow: Compose Deployment Steps#
spec:
resolvers:
region:
type: string
resolve:
with:
- provider: static
inputs:
value: "us-east-1"
workflow:
actions:
setup:
provider: exec
inputs:
command: "echo 'Preparing...'"
deploy-infra:
provider: solution
dependsOn:
- setup
inputs:
source: "deploy-k8s@2.0.0"
inputs:
region:
expr: "_.region"
cluster: "main-cluster"
verify:
provider: exec
dependsOn:
- deploy-infra
inputs:
command:
expr: "'Deploy status: ' + string(__actions['deploy-infra'].results.workflow.finalStatus)"Error Handling in Parent (envelope mode)#
infra:
type: any
resolve:
with:
- provider: solution
inputs:
source: "infra-config@1.0.0"
propagateErrors: false
app-config:
type: any
dependsOn:
- infra
when:
expr: "_.infra.status == 'success'"
resolve:
with:
- provider: static
inputs:
value:
expr: "_.infra.resolvers.db_host"Error Handling in Parent (fallback chain)#
infra:
type: any
resolve:
with:
- provider: solution
inputs:
source: "infra-config@1.0.0"
onError: continue
- provider: static
inputs:
value:
db_host: "localhost"
db_port: 5432Implementation#
Key Files#
| File | Purpose |
|---|---|
pkg/provider/builtin/solutionprovider/solution.go | Provider struct, Descriptor(), Execute() |
pkg/provider/builtin/solutionprovider/solution_test.go | Unit tests with mock loader |
pkg/provider/builtin/solutionprovider/context.go | Ancestor stack + depth context helpers |
pkg/provider/builtin/solutionprovider/envelope.go | Envelope construction for from and action |
pkg/cmd/scafctl/run/solution.go | Registration wiring |
tests/integration/cli_test.go | Integration tests |
Provider Structure#
// pkg/provider/builtin/solutionprovider/solution.go
type SolutionProvider struct {
loader get.Interface
registry *provider.Registry
}
type Option func(*SolutionProvider)
func WithLoader(l get.Interface) Option
func WithRegistry(r *provider.Registry) Option
func New(opts ...Option) *SolutionProviderThe provider uses get.Interface directly for solution loading. get.Interface is a small, focused interface and get.MockGetter already exists for testing. If decoupling from the get package is desired, a local interface can be defined — Go’s structural typing means get.Getter satisfies it automatically:
type Loader interface {
Get(ctx context.Context, path string) (*solution.Solution, error)
}The provider also receives a reference to the provider registry so sub-solutions have access to all built-in and plugin providers.
Registration#
The solution provider is not registered in builtin.go. It depends on get.Interface (which needs catalog config) and a reference to the *provider.Registry itself — both are available only at CLI wiring time.
Registration happens in the CLI run command:
// In pkg/cmd/scafctl/run/solution.go, inside Run():
getter := get.NewGetter(...)
registry, err := builtin.DefaultRegistry(ctx)
if err != nil {
return err
}
solutionProv := solutionprovider.New(
solutionprovider.WithLoader(getter),
solutionprovider.WithRegistry(registry),
)
registry.Register(solutionProv)Custom Dependency Extraction#
The provider implements ExtractDependencies on its descriptor to scan source and nested inputs for resolver references:
desc.ExtractDependencies = func(inputs map[string]any) []string {
var deps []string
if source, ok := inputs["source"]; ok {
deps = append(deps, extractRefsFromValue(source)...)
}
if subInputs, ok := inputs["inputs"].(map[string]any); ok {
for _, v := range subInputs {
deps = append(deps, extractRefsFromValue(v)...)
}
}
return deps
}Schema Definition#
Input schema:
type: object
required: [source]
properties:
source:
description: "Sub-solution location (file path, catalog reference, or URL)"
inputs:
type: object
description: "Parameters passed to the sub-solution's parameter provider"
additionalProperties: true
propagateErrors:
type: boolean
default: true
description: "Whether sub-solution failures cause a Go error"
maxDepth:
type: integer
default: 10
minimum: 1
maximum: 100
description: "Maximum nesting depth for recursive composition"
resolvers:
type: array
items:
type: string
description: "Resolver names to execute; when empty all resolvers run"
timeout:
type: string
description: "Maximum duration for sub-solution execution (e.g. 30s, 5m)"
example: "30s"Output schemas per capability:
from:
type: object
properties:
resolvers:
type: object
status:
type: string
errors:
type: array
action:
type: object
required: [success]
properties:
success:
type: boolean
resolvers:
type: object
workflow:
type: object
status:
type: string
errors:
type: arrayNote: action capability requires a success boolean field in the output schema (enforced by ValidateDescriptor). The provider sets this to status == "success".
Test Strategy#
Unit Tests (solution_test.go)#
- Basic
from— success: Mock loader returns solution with 2 resolvers. Assert envelope has correctresolversmap andstatus: success. - Basic
action— success: Mock loader returns solution with resolvers + workflow. Assert envelope includesworkflow.finalStatusandsuccess: true. propagateErrors: true(default): Mock loader returns solution whose resolvers fail. AssertExecutereturns a Go error.propagateErrors: false: Same failing solution, but nowExecutereturns*Outputwithstatus: failedandOutput.Warningspopulated.- Circular detection — direct: A → A. Assert error message includes chain.
- Circular detection — indirect: A → B → A. Push “A” onto ancestor stack in context, then execute with source “A”. Assert error.
- Max depth exceeded: Push 10 ancestors onto stack, execute with
maxDepth: 10. Assert depth error. - WhatIf: Call
DescribeWhatIf(ctx, input)with source set. Assert message contains the source name. Also test fallback when source is missing. - Context isolation: Assert sub-solution does not see parent’s resolver values or parameters.
- Dynamic source:
sourcewith CEL expression resolving to a catalog name. Assert correct solution is loaded.
Integration Tests (cli_test.go)#
- End-to-end resolver composition: Parent solution with a resolver using
provider: solutionpointing to a child solution file. Assert parent resolver gets child’s resolver values. - End-to-end workflow composition: Parent workflow invokes child solution as an action. Assert
__actionscontains envelope with workflow status. - Circular reference: Two solution files referencing each other. Assert clear error message.
- Parameter passthrough: Parent passes
inputsto child. Child’sparameterresolver receives the value. Assert child resolver resolves correctly. - Transform phase integration: Parent uses transform to extract specific values from the solution provider envelope.