Skip to content

feat(iac): plan-time JIT resolver against state outputs#576

Merged
intel352 merged 8 commits into
mainfrom
feat/jit-plan-time-resolver
May 7, 2026
Merged

feat(iac): plan-time JIT resolver against state outputs#576
intel352 merged 8 commits into
mainfrom
feat/jit-plan-time-resolver

Conversation

@intel352

@intel352 intel352 commented May 7, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds jitsubst.TryResolveSpec — lenient sibling of ResolveSpec for plan-time use. Substitutes resolvable ${MODULE.field} and ${VAR} refs; leaves unresolvable refs verbatim with diagnostics. Malformed refs remain hard errors.
  • Adds cmd/wfctl/infra_resolve_state.go::resolveSpecsAgainstState — wires TryResolveSpec into runInfraPlan and applyInfraModules. Resolves MODULE.field refs against state outputs and infra_output-typed secrets at plan time so driver Diff sees real values instead of literal templates.
  • Eliminates the TC2 spurious-replace class: DropletDriver.Diff (and similar) comparing ${STAGING_VPC_UUID} vs real UUID now sees the resolved UUID on both sides → no spurious replace.

Refs whose source module is not yet in state stay templated for apply-time JIT (existing W-5 path unchanged). ADR 0013 added.

Test plan

  • go test ./iac/jitsubst/ ./cmd/wfctl/ -v passes (all 5 tasks covered)
  • golangci-lint run ./iac/jitsubst/ ./cmd/wfctl/ clean (pre-existing gosec G115 only)
  • TestRunInfraPlan_TC2RegressionScenario — mock DropletDriver-style Diff sees resolved UUID → 0 actions
  • TestRunInfraApply_ResolvesSpecsBeforeComputePlan — apply path mirrors plan, no spurious replace
  • TestResolveSpecsAgainstState_HashByteStable — 5 iterations produce identical desiredStateHash
  • Existing TestInfraPlan_SchemaVersion* tests all pass — resolver doesn't break JIT detection

References

  • ADR 0013: decisions/0013-jit-plan-time-resolver-against-state.md
  • core-dump decisions/0009: canonical design decision
  • Fixes TC2 dispatch runs 25476341708, 25478458395, 25479374975

🤖 Generated via writing-plans → executing-plans pipeline

intel352 and others added 5 commits May 7, 2026 15:52
Adds TryResolveSpec that substitutes resolvable references while leaving
unresolvable ones verbatim. Malformed refs (${}, ${.x}, ${x.}) remain
hard errors matching ResolveSpec's contract. Returns sorted unresolved
list for plan-time diagnostics.

Also adds lookupModuleField helper shared by strict and lenient paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds resolveSpecsAgainstState that applies lenient JIT substitution
against existing state outputs at plan time. MODULE.field refs in state
collapse to literals; infra_output-typed secrets are also pre-resolved.
Unresolvable refs return ResolutionDiagnostic entries for plan output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Inserts resolveSpecsAgainstState between loadCurrentState and
computePlanForInfraSpecs in runInfraPlan. MODULE.field refs that exist
in state collapse to literals before Diff, eliminating the TC2
spurious-replace class. Pending-JIT refs surface in plan output.

Includes TC2 regression test with a mock DropletDriver that reproduces
the field-compare spurious-replace behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Inserts resolveSpecsAgainstState after infraSpecs filtering and before
provider grouping in applyInfraModules. Mirrors the plan-path change so
the apply path sees real values for MODULE.field refs when the source
module is already in state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the Unreleased CHANGELOG entry describing plan-time JIT resolver
and TryResolveSpec. Adds ADR 0013 (workflow-side copy of core-dump
decisions/0009) documenting the design rationale and alternatives.
Also cleans up unused countingStateStore from apply test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 7, 2026 20:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a plan-time “lenient JIT” substitution pass for IaC specs so wfctl infra plan / wfctl infra apply can resolve ${MODULE.field} and infra_output-typed ${VAR} references from existing state before diffing, reducing spurious replace actions.

Changes:

  • Introduces jitsubst.TryResolveSpec to resolve what’s available while preserving unresolved references (with diagnostics) and still hard-failing malformed refs.
  • Wires plan-time resolution into runInfraPlan and applyInfraModules via a new resolveSpecsAgainstState helper.
  • Adds ADR 0013 + changelog entry and regression tests covering the TC2 spurious-replace scenario and determinism of hashing inputs.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
iac/jitsubst/jitsubst.go Adds lenient spec resolver (TryResolveSpec) plus shared lookup helper.
iac/jitsubst/jitsubst_test.go Adds unit tests for lenient resolution behavior and malformed-ref rejection.
cmd/wfctl/infra_resolve_state.go Implements plan-time state/env-based resolution + diagnostics collection.
cmd/wfctl/infra.go Invokes plan-time resolution during wfctl infra plan prior to diff planning.
cmd/wfctl/infra_apply.go Mirrors the same resolution step in the non---plan apply path.
cmd/wfctl/infra_resolve_state_test.go Tests resolver behavior (module refs, infra_output secrets, determinism).
cmd/wfctl/infra_plan_resolve_state_test.go Regression test ensuring TC2 scenario yields no spurious replace during planning.
cmd/wfctl/infra_apply_resolve_state_test.go Regression test for apply path (no spurious replace before diff plan).
decisions/0013-jit-plan-time-resolver-against-state.md ADR documenting the motivation, approach, and consequences.
CHANGELOG.md Documents the new plan-time resolver behavior and new API surface.

Comment thread cmd/wfctl/infra.go
Comment on lines +267 to +275
wfCfgForResolver, cfgLoadErr := config.LoadFromFile(cfgFile)
if cfgLoadErr != nil {
return fmt.Errorf("load config for plan-time resolver: %w", cfgLoadErr)
}
var resolutionDiags []ResolutionDiagnostic
desired, resolutionDiags, err = resolveSpecsAgainstState(desired, current, wfCfgForResolver, envName)
if err != nil {
return fmt.Errorf("resolve specs against state: %w", err)
}
Comment thread cmd/wfctl/infra.go Outdated
Comment on lines +276 to +285
if len(resolutionDiags) > 0 {
// Deferred-resolution notice printed after plan table (see below).
// Store in closure so the print lands after the plan table.
defer func() {
fmt.Println()
fmt.Println("Pending JIT resolution (apply-time):")
for _, d := range resolutionDiags {
fmt.Printf(" %s: ${%s}\n", d.ResourceName, d.Ref)
}
}()
Comment thread iac/jitsubst/jitsubst.go Outdated
Comment on lines +157 to +160
// Used by cmd/wfctl/infra.go::resolveSpecsAgainstState (PR-1) to
// substitute state-output and env-var references at plan time so that
// driver.Diff sees real values instead of literal templates. Surviving
// unresolved refs flow through to apply-time strict ResolveSpec.
Comment thread iac/jitsubst/jitsubst.go
Comment on lines +284 to +312
// lookupModuleField factors the ${MODULE.id|field} resolution from
// resolveRef so both strict and lenient paths share it. Returns
// (value, true) when found; (_, false) when missing.
func lookupModuleField(
module, field string,
replaceIDMap map[string]string,
syncedOutputs map[string]map[string]any,
) (string, bool) {
if field == "id" {
if id, ok := replaceIDMap[module]; ok {
return id, true
}
if outs, ok := syncedOutputs[module]; ok {
if v, ok := outs["id"]; ok {
return fmt.Sprintf("%v", v), true
}
}
return "", false
}
outs, ok := syncedOutputs[module]
if !ok {
return "", false
}
v, ok := outs[field]
if !ok {
return "", false
}
return fmt.Sprintf("%v", v), true
}
Comment on lines +53 to +60
const vpcID = "14badc41-1234-5678-abcd-ef0123456789"
t.Setenv("STAGING_VPC_UUID", vpcID)
writeApplyStateFile(t, stateDir, "core-dump-vpc", "infra.vpc", vpcID,
map[string]any{"id": vpcID, "cidr": "10.0.0.0/16"},
map[string]any{"provider": "do-provider", "cidr": "10.0.0.0/16"})
writeApplyStateFile(t, stateDir, "coredump-staging-pg", "infra.droplet", "droplet-99",
map[string]any{"vpc_uuid": vpcID},
map[string]any{"provider": "do-provider", "vpc_uuid": vpcID, "size": "s-1vcpu-2gb"})
Comment on lines +178 to +182
planFile := filepath.Join(dir, "plan.json")
if err := runInfraPlan([]string{"--config", cfgPath, "--output", planFile}); err != nil {
t.Fatalf("runInfraPlan: %v", err)
}

@github-actions

github-actions Bot commented May 7, 2026

Copy link
Copy Markdown

⏱ Benchmark Results

No significant performance regressions detected.

benchstat comparison (baseline → PR)
## benchstat: baseline → PR
baseline-bench.txt:260: parsing iteration count: invalid syntax
baseline-bench.txt:333924: parsing iteration count: invalid syntax
baseline-bench.txt:651796: parsing iteration count: invalid syntax
baseline-bench.txt:998756: parsing iteration count: invalid syntax
baseline-bench.txt:1273998: parsing iteration count: invalid syntax
baseline-bench.txt:1592401: parsing iteration count: invalid syntax
benchmark-results.txt:260: parsing iteration count: invalid syntax
benchmark-results.txt:336804: parsing iteration count: invalid syntax
benchmark-results.txt:669115: parsing iteration count: invalid syntax
benchmark-results.txt:996449: parsing iteration count: invalid syntax
benchmark-results.txt:1322532: parsing iteration count: invalid syntax
benchmark-results.txt:1625368: parsing iteration count: invalid syntax
goos: linux
goarch: amd64
pkg: github.com/GoCodeAlone/workflow/dynamic
cpu: AMD EPYC 7763 64-Core Processor                
                            │ baseline-bench.txt │
                            │       sec/op       │
InterpreterCreation-4              3.204m ± 201%
ComponentLoad-4                    3.653m ±  20%
ComponentExecute-4                 1.922µ ±   1%
PoolContention/workers-1-4         1.093µ ±   2%
PoolContention/workers-2-4         1.090µ ±   2%
PoolContention/workers-4-4         1.107µ ±   1%
PoolContention/workers-8-4         1.090µ ±   2%
PoolContention/workers-16-4        1.102µ ±   4%
ComponentLifecycle-4               3.686m ±   2%
SourceValidation-4                 2.277µ ±   1%
RegistryConcurrent-4               799.2n ±   5%
LoaderLoadFromString-4             3.679m ±   2%
geomean                            17.59µ

                            │ baseline-bench.txt │
                            │        B/op        │
InterpreterCreation-4               2.027Mi ± 0%
ComponentLoad-4                     2.180Mi ± 0%
ComponentExecute-4                  1.203Ki ± 0%
PoolContention/workers-1-4          1.203Ki ± 0%
PoolContention/workers-2-4          1.203Ki ± 0%
PoolContention/workers-4-4          1.203Ki ± 0%
PoolContention/workers-8-4          1.203Ki ± 0%
PoolContention/workers-16-4         1.203Ki ± 0%
ComponentLifecycle-4                2.183Mi ± 0%
SourceValidation-4                  1.984Ki ± 0%
RegistryConcurrent-4                1.133Ki ± 0%
LoaderLoadFromString-4              2.182Mi ± 0%
geomean                             15.25Ki

                            │ baseline-bench.txt │
                            │     allocs/op      │
InterpreterCreation-4                15.68k ± 0%
ComponentLoad-4                      18.02k ± 0%
ComponentExecute-4                    25.00 ± 0%
PoolContention/workers-1-4            25.00 ± 0%
PoolContention/workers-2-4            25.00 ± 0%
PoolContention/workers-4-4            25.00 ± 0%
PoolContention/workers-8-4            25.00 ± 0%
PoolContention/workers-16-4           25.00 ± 0%
ComponentLifecycle-4                 18.07k ± 0%
SourceValidation-4                    32.00 ± 0%
RegistryConcurrent-4                  2.000 ± 0%
LoaderLoadFromString-4               18.06k ± 0%
geomean                               183.3

cpu: AMD EPYC 9V74 80-Core Processor                
                            │ benchmark-results.txt │
                            │        sec/op         │
InterpreterCreation-4                  5.195m ± 87%
ComponentLoad-4                        3.529m ±  2%
ComponentExecute-4                     1.821µ ±  1%
PoolContention/workers-1-4             1.028µ ±  1%
PoolContention/workers-2-4             1.026µ ±  3%
PoolContention/workers-4-4             1.021µ ±  2%
PoolContention/workers-8-4             1.022µ ±  1%
PoolContention/workers-16-4            1.026µ ±  1%
ComponentLifecycle-4                   3.562m ±  1%
SourceValidation-4                     2.101µ ±  1%
RegistryConcurrent-4                   766.6n ±  5%
LoaderLoadFromString-4                 3.701m ±  3%
geomean                                17.45µ

                            │ benchmark-results.txt │
                            │         B/op          │
InterpreterCreation-4                  2.027Mi ± 0%
ComponentLoad-4                        2.180Mi ± 0%
ComponentExecute-4                     1.203Ki ± 0%
PoolContention/workers-1-4             1.203Ki ± 0%
PoolContention/workers-2-4             1.203Ki ± 0%
PoolContention/workers-4-4             1.203Ki ± 0%
PoolContention/workers-8-4             1.203Ki ± 0%
PoolContention/workers-16-4            1.203Ki ± 0%
ComponentLifecycle-4                   2.183Mi ± 0%
SourceValidation-4                     1.984Ki ± 0%
RegistryConcurrent-4                   1.133Ki ± 0%
LoaderLoadFromString-4                 2.182Mi ± 0%
geomean                                15.25Ki

                            │ benchmark-results.txt │
                            │       allocs/op       │
InterpreterCreation-4                   15.68k ± 0%
ComponentLoad-4                         18.02k ± 0%
ComponentExecute-4                       25.00 ± 0%
PoolContention/workers-1-4               25.00 ± 0%
PoolContention/workers-2-4               25.00 ± 0%
PoolContention/workers-4-4               25.00 ± 0%
PoolContention/workers-8-4               25.00 ± 0%
PoolContention/workers-16-4              25.00 ± 0%
ComponentLifecycle-4                    18.07k ± 0%
SourceValidation-4                       32.00 ± 0%
RegistryConcurrent-4                     2.000 ± 0%
LoaderLoadFromString-4                  18.06k ± 0%
geomean                                  183.3

pkg: github.com/GoCodeAlone/workflow/middleware
cpu: AMD EPYC 7763 64-Core Processor                
                                  │ baseline-bench.txt │
                                  │       sec/op       │
CircuitBreakerDetection-4                  287.4n ± 7%
CircuitBreakerExecution_Success-4          21.51n ± 0%
CircuitBreakerExecution_Failure-4          66.19n ± 0%
geomean                                    74.24n

                                  │ baseline-bench.txt │
                                  │        B/op        │
CircuitBreakerDetection-4                 144.0 ± 0%
CircuitBreakerExecution_Success-4         0.000 ± 0%
CircuitBreakerExecution_Failure-4         0.000 ± 0%
geomean                                              ¹
¹ summaries must be >0 to compute geomean

                                  │ baseline-bench.txt │
                                  │     allocs/op      │
CircuitBreakerDetection-4                 1.000 ± 0%
CircuitBreakerExecution_Success-4         0.000 ± 0%
CircuitBreakerExecution_Failure-4         0.000 ± 0%
geomean                                              ¹
¹ summaries must be >0 to compute geomean

cpu: AMD EPYC 9V74 80-Core Processor                
                                  │ benchmark-results.txt │
                                  │        sec/op         │
CircuitBreakerDetection-4                     298.4n ± 5%
CircuitBreakerExecution_Success-4             22.68n ± 1%
CircuitBreakerExecution_Failure-4             71.09n ± 1%
geomean                                       78.37n

                                  │ benchmark-results.txt │
                                  │         B/op          │
CircuitBreakerDetection-4                    144.0 ± 0%
CircuitBreakerExecution_Success-4            0.000 ± 0%
CircuitBreakerExecution_Failure-4            0.000 ± 0%
geomean                                                 ¹
¹ summaries must be >0 to compute geomean

                                  │ benchmark-results.txt │
                                  │       allocs/op       │
CircuitBreakerDetection-4                    1.000 ± 0%
CircuitBreakerExecution_Success-4            0.000 ± 0%
CircuitBreakerExecution_Failure-4            0.000 ± 0%
geomean                                                 ¹
¹ summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/module
cpu: AMD EPYC 7763 64-Core Processor                
                                 │ baseline-bench.txt │
                                 │       sec/op       │
JQTransform_Simple-4                     900.4n ± 31%
JQTransform_ObjectConstruction-4         1.524µ ±  1%
JQTransform_ArraySelect-4                3.443µ ±  2%
JQTransform_Complex-4                    39.46µ ±  2%
JQTransform_Throughput-4                 1.810µ ±  1%
SSEPublishDelivery-4                     71.38n ±  4%
geomean                                  1.699µ

                                 │ baseline-bench.txt │
                                 │        B/op        │
JQTransform_Simple-4                   1.273Ki ± 0%
JQTransform_ObjectConstruction-4       1.773Ki ± 0%
JQTransform_ArraySelect-4              2.625Ki ± 0%
JQTransform_Complex-4                  16.22Ki ± 0%
JQTransform_Throughput-4               1.984Ki ± 0%
SSEPublishDelivery-4                     0.000 ± 0%
geomean                                             ¹
¹ summaries must be >0 to compute geomean

                                 │ baseline-bench.txt │
                                 │     allocs/op      │
JQTransform_Simple-4                     10.00 ± 0%
JQTransform_ObjectConstruction-4         15.00 ± 0%
JQTransform_ArraySelect-4                30.00 ± 0%
JQTransform_Complex-4                    324.0 ± 0%
JQTransform_Throughput-4                 17.00 ± 0%
SSEPublishDelivery-4                     0.000 ± 0%
geomean                                             ¹
¹ summaries must be >0 to compute geomean

cpu: AMD EPYC 9V74 80-Core Processor                
                                 │ benchmark-results.txt │
                                 │        sec/op         │
JQTransform_Simple-4                        924.4n ± 16%
JQTransform_ObjectConstruction-4            1.427µ ±  1%
JQTransform_ArraySelect-4                   3.436µ ±  1%
JQTransform_Complex-4                       41.77µ ±  1%
JQTransform_Throughput-4                    1.738µ ±  3%
SSEPublishDelivery-4                        64.21n ±  2%
geomean                                     1.663µ

                                 │ benchmark-results.txt │
                                 │         B/op          │
JQTransform_Simple-4                      1.273Ki ± 0%
JQTransform_ObjectConstruction-4          1.773Ki ± 0%
JQTransform_ArraySelect-4                 2.625Ki ± 0%
JQTransform_Complex-4                     16.22Ki ± 0%
JQTransform_Throughput-4                  1.984Ki ± 0%
SSEPublishDelivery-4                        0.000 ± 0%
geomean                                                ¹
¹ summaries must be >0 to compute geomean

                                 │ benchmark-results.txt │
                                 │       allocs/op       │
JQTransform_Simple-4                        10.00 ± 0%
JQTransform_ObjectConstruction-4            15.00 ± 0%
JQTransform_ArraySelect-4                   30.00 ± 0%
JQTransform_Complex-4                       324.0 ± 0%
JQTransform_Throughput-4                    17.00 ± 0%
SSEPublishDelivery-4                        0.000 ± 0%
geomean                                                ¹
¹ summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/schema
cpu: AMD EPYC 7763 64-Core Processor                
                                    │ baseline-bench.txt │
                                    │       sec/op       │
SchemaValidation_Simple-4                    1.109µ ± 4%
SchemaValidation_AllFields-4                 1.672µ ± 3%
SchemaValidation_FormatValidation-4          1.609µ ± 4%
SchemaValidation_ManySchemas-4               1.810µ ± 2%
geomean                                      1.524µ

                                    │ baseline-bench.txt │
                                    │        B/op        │
SchemaValidation_Simple-4                   0.000 ± 0%
SchemaValidation_AllFields-4                0.000 ± 0%
SchemaValidation_FormatValidation-4         0.000 ± 0%
SchemaValidation_ManySchemas-4              0.000 ± 0%
geomean                                                ¹
¹ summaries must be >0 to compute geomean

                                    │ baseline-bench.txt │
                                    │     allocs/op      │
SchemaValidation_Simple-4                   0.000 ± 0%
SchemaValidation_AllFields-4                0.000 ± 0%
SchemaValidation_FormatValidation-4         0.000 ± 0%
SchemaValidation_ManySchemas-4              0.000 ± 0%
geomean                                                ¹
¹ summaries must be >0 to compute geomean

cpu: AMD EPYC 9V74 80-Core Processor                
                                    │ benchmark-results.txt │
                                    │        sec/op         │
SchemaValidation_Simple-4                       1.131µ ± 2%
SchemaValidation_AllFields-4                    1.650µ ± 4%
SchemaValidation_FormatValidation-4             1.622µ ± 3%
SchemaValidation_ManySchemas-4                  1.604µ ± 1%
geomean                                         1.484µ

                                    │ benchmark-results.txt │
                                    │         B/op          │
SchemaValidation_Simple-4                      0.000 ± 0%
SchemaValidation_AllFields-4                   0.000 ± 0%
SchemaValidation_FormatValidation-4            0.000 ± 0%
SchemaValidation_ManySchemas-4                 0.000 ± 0%
geomean                                                   ¹
¹ summaries must be >0 to compute geomean

                                    │ benchmark-results.txt │
                                    │       allocs/op       │
SchemaValidation_Simple-4                      0.000 ± 0%
SchemaValidation_AllFields-4                   0.000 ± 0%
SchemaValidation_FormatValidation-4            0.000 ± 0%
SchemaValidation_ManySchemas-4                 0.000 ± 0%
geomean                                                   ¹
¹ summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/store
cpu: AMD EPYC 7763 64-Core Processor                
                                   │ baseline-bench.txt │
                                   │       sec/op       │
EventStoreAppend_InMemory-4                1.234µ ± 18%
EventStoreAppend_SQLite-4                  1.480m ±  4%
GetTimeline_InMemory/events-10-4           13.64µ ±  2%
GetTimeline_InMemory/events-50-4           74.44µ ± 18%
GetTimeline_InMemory/events-100-4          122.3µ ±  1%
GetTimeline_InMemory/events-500-4          631.9µ ±  1%
GetTimeline_InMemory/events-1000-4         1.300m ±  1%
GetTimeline_SQLite/events-10-4             107.7µ ±  2%
GetTimeline_SQLite/events-50-4             248.1µ ±  4%
GetTimeline_SQLite/events-100-4            421.1µ ±  3%
GetTimeline_SQLite/events-500-4            1.807m ±  2%
GetTimeline_SQLite/events-1000-4           3.525m ±  2%
geomean                                    220.7µ

                                   │ baseline-bench.txt │
                                   │        B/op        │
EventStoreAppend_InMemory-4                  771.0 ± 7%
EventStoreAppend_SQLite-4                  1.982Ki ± 2%
GetTimeline_InMemory/events-10-4           7.953Ki ± 0%
GetTimeline_InMemory/events-50-4           46.62Ki ± 0%
GetTimeline_InMemory/events-100-4          94.48Ki ± 0%
GetTimeline_InMemory/events-500-4          472.8Ki ± 0%
GetTimeline_InMemory/events-1000-4         944.3Ki ± 0%
GetTimeline_SQLite/events-10-4             16.74Ki ± 0%
GetTimeline_SQLite/events-50-4             87.14Ki ± 0%
GetTimeline_SQLite/events-100-4            175.4Ki ± 0%
GetTimeline_SQLite/events-500-4            846.1Ki ± 0%
GetTimeline_SQLite/events-1000-4           1.639Mi ± 0%
geomean                                    67.20Ki

                                   │ baseline-bench.txt │
                                   │     allocs/op      │
EventStoreAppend_InMemory-4                  7.000 ± 0%
EventStoreAppend_SQLite-4                    53.00 ± 0%
GetTimeline_InMemory/events-10-4             125.0 ± 0%
GetTimeline_InMemory/events-50-4             653.0 ± 0%
GetTimeline_InMemory/events-100-4           1.306k ± 0%
GetTimeline_InMemory/events-500-4           6.514k ± 0%
GetTimeline_InMemory/events-1000-4          13.02k ± 0%
GetTimeline_SQLite/events-10-4               382.0 ± 0%
GetTimeline_SQLite/events-50-4              1.852k ± 0%
GetTimeline_SQLite/events-100-4             3.681k ± 0%
GetTimeline_SQLite/events-500-4             18.54k ± 0%
GetTimeline_SQLite/events-1000-4            37.29k ± 0%
geomean                                     1.162k

cpu: AMD EPYC 9V74 80-Core Processor                
                                   │ benchmark-results.txt │
                                   │        sec/op         │
EventStoreAppend_InMemory-4                   1.087µ ± 21%
EventStoreAppend_SQLite-4                     1.077m ±  7%
GetTimeline_InMemory/events-10-4              12.51µ ±  2%
GetTimeline_InMemory/events-50-4              70.87µ ±  3%
GetTimeline_InMemory/events-100-4             108.2µ ± 24%
GetTimeline_InMemory/events-500-4             551.4µ ±  2%
GetTimeline_InMemory/events-1000-4            1.120m ±  1%
GetTimeline_SQLite/events-10-4                84.79µ ±  5%
GetTimeline_SQLite/events-50-4                220.5µ ±  3%
GetTimeline_SQLite/events-100-4               387.8µ ±  2%
GetTimeline_SQLite/events-500-4               1.657m ±  1%
GetTimeline_SQLite/events-1000-4              3.233m ±  6%
geomean                                       193.1µ

                                   │ benchmark-results.txt │
                                   │         B/op          │
EventStoreAppend_InMemory-4                    759.5 ± 13%
EventStoreAppend_SQLite-4                    1.985Ki ±  2%
GetTimeline_InMemory/events-10-4             7.953Ki ±  0%
GetTimeline_InMemory/events-50-4             46.62Ki ±  0%
GetTimeline_InMemory/events-100-4            94.48Ki ±  0%
GetTimeline_InMemory/events-500-4            472.8Ki ±  0%
GetTimeline_InMemory/events-1000-4           944.3Ki ±  0%
GetTimeline_SQLite/events-10-4               16.74Ki ±  0%
GetTimeline_SQLite/events-50-4               87.14Ki ±  0%
GetTimeline_SQLite/events-100-4              175.4Ki ±  0%
GetTimeline_SQLite/events-500-4              846.1Ki ±  0%
GetTimeline_SQLite/events-1000-4             1.639Mi ±  0%
geomean                                      67.13Ki

                                   │ benchmark-results.txt │
                                   │       allocs/op       │
EventStoreAppend_InMemory-4                     7.000 ± 0%
EventStoreAppend_SQLite-4                       53.00 ± 0%
GetTimeline_InMemory/events-10-4                125.0 ± 0%
GetTimeline_InMemory/events-50-4                653.0 ± 0%
GetTimeline_InMemory/events-100-4              1.306k ± 0%
GetTimeline_InMemory/events-500-4              6.514k ± 0%
GetTimeline_InMemory/events-1000-4             13.02k ± 0%
GetTimeline_SQLite/events-10-4                  382.0 ± 0%
GetTimeline_SQLite/events-50-4                 1.852k ± 0%
GetTimeline_SQLite/events-100-4                3.681k ± 0%
GetTimeline_SQLite/events-500-4                18.54k ± 0%
GetTimeline_SQLite/events-1000-4               37.29k ± 0%
geomean                                        1.162k

Benchmarks run with go test -bench=. -benchmem -count=6.
Regressions ≥ 20% are flagged. Results compared via benchstat.

- resolveRef now delegates ${MODULE.field} lookup to lookupModuleField
  so strict and lenient paths share one implementation (no drift)
- Remove defer for resolution diagnostics; print only after successful
  plan output (not on error returns from computePlan)
- --plan apply path: mirror resolveSpecsAgainstState before hashing so
  plan.DesiredHash (post-resolution) matches the stale-check recompute
- Fix TryResolveSpec doc comment: references infra_resolve_state.go, not infra.go

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Comment on lines +53 to +88
const vpcID = "14badc41-1234-5678-abcd-ef0123456789"
t.Setenv("STAGING_VPC_UUID", vpcID)
writeApplyStateFile(t, stateDir, "core-dump-vpc", "infra.vpc", vpcID,
map[string]any{"id": vpcID, "cidr": "10.0.0.0/16"},
map[string]any{"provider": "do-provider", "cidr": "10.0.0.0/16"})
writeApplyStateFile(t, stateDir, "coredump-staging-pg", "infra.droplet", "droplet-99",
map[string]any{"vpc_uuid": vpcID},
map[string]any{"provider": "do-provider", "vpc_uuid": vpcID, "size": "s-1vcpu-2gb"})

cfgPath := filepath.Join(dir, "infra.yaml")
if err := os.WriteFile(cfgPath, []byte(`
modules:
- name: do-provider
type: iac.provider
config:
provider: test-cloud

- name: do-state
type: iac.state
config:
backend: filesystem
directory: `+stateDir+`

- name: core-dump-vpc
type: infra.vpc
config:
provider: do-provider
cidr: "10.0.0.0/16"

- name: coredump-staging-pg
type: infra.droplet
config:
provider: do-provider
vpc_uuid: "${STAGING_VPC_UUID}"
size: s-1vcpu-2gb
`), 0o600); err != nil {
Comment on lines +145 to +150
// We inject through resolveIaCProvider; computeInfraPlan is used by
// applyWithProviderAndStore which calls p.Plan — handled above.

if err := runInfraApply([]string{"--config", cfgPath, "--auto-approve"}); err != nil {
t.Fatalf("runInfraApply: %v", err)
}
Comment on lines +41 to +103
// tc2MockProvider is an IaCProvider that returns a Diff-based plan
// simulating the DO DropletDriver behavior: if the desired config's
// vpc_uuid field differs from the state's, it emits a replace action.
// This faithfully reproduces the TC2 spurious-replace root cause.
type tc2MockProvider struct {
applyCapture
}

func (p *tc2MockProvider) Plan(_ context.Context, desired []interfaces.ResourceSpec, current []interfaces.ResourceState) (*interfaces.IaCPlan, error) {
// Build a lookup of current state by name.
currentMap := make(map[string]interfaces.ResourceState, len(current))
for _, s := range current {
currentMap[s.Name] = s
}
var actions []interfaces.PlanAction
for _, spec := range desired {
rs, exists := currentMap[spec.Name]
if !exists {
actions = append(actions, interfaces.PlanAction{Action: "create", Resource: spec})
continue
}
// Simulate DropletDriver.Diff: for each field in desired config,
// compare against the stored state config. If any field differs,
// emit a replace action (ForceNew semantics for vpc_uuid).
//
// This faithfully reproduces the TC2 root cause: before plan-time
// resolution, spec.Config["vpc_uuid"] was "${STAGING_VPC_UUID}"
// but rs.Config["vpc_uuid"] was the real UUID — mismatch → replace.
//
// After plan-time resolution, spec.Config["vpc_uuid"] is the real
// UUID (matching rs.Config["vpc_uuid"]) → no action.
anyDiff := false
for k, desiredVal := range spec.Config {
if k == "provider" {
continue // provider ref is metadata, not a cloud field
}
stateVal, hasField := rs.AppliedConfig[k]
if !hasField {
// Check state Config (iacStateRecord.config maps to AppliedConfig
// but some backends persist as Outputs). Check outputs too.
stateVal, hasField = rs.Outputs[k]
}
if !hasField || fmt.Sprintf("%v", desiredVal) != fmt.Sprintf("%v", stateVal) {
anyDiff = true
break
}
}
if anyDiff {
actions = append(actions, interfaces.PlanAction{
Action: "replace",
Resource: spec,
Current: &rs,
})
}
}
plan := &interfaces.IaCPlan{Actions: actions}
return plan, nil
}

func (p *tc2MockProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) {
return nil, nil
}

Both tc2 regression tests previously injected a mock provider via
resolveIaCProvider but computePlanForInfraSpecs calls computeInfraPlan
(platform.ComputePlan) which falls back to ConfigHash comparison when
ResourceDriver is nil — making the tests vacuously green.

Fix: override computeInfraPlan with a TC2 field-compare function that
simulates DropletDriver.Diff behavior. Also:
- Plan test: remove tc2MockProvider.Plan (dead code, never called)
- Apply test: use secrets.generate infra_output + provider:env instead of
  t.Setenv so the test validates the state-output resolution path, not the
  os.LookupEnv fallback; add infra.auto_bootstrap:false to bypass bootstrap

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Comment on lines +43 to +47
out := make([]interfaces.ResourceSpec, len(specs))
var diags []ResolutionDiagnostic
for i, spec := range specs {
resolved, unresolved, err := jitsubst.TryResolveSpec(spec, nil, syncedOutputs, envLookup)
if err != nil {
Comment thread cmd/wfctl/infra.go
Comment on lines 258 to +265
desired = filterSpecsByInclude(desired, planIncludeSet)
current = filterStatesByInclude(current, planIncludeSet)

// Plan-time JIT resolution (PR-1): substitute ${MODULE.field} and
// ${SECRET} refs against current state so driver.Diff sees real
// values instead of literal templates. Refs whose source isn't in
// state stay templated; planRequiresJITSubstitution detects them
// and SchemaVersion=2 stamping handles the apply-time path.
Comment thread cmd/wfctl/infra_apply.go
Comment on lines 186 to +206
// Validate and apply the include filter to both specs and state before
// grouping by provider so that out-of-scope resources are never passed
// down to any provider.
if err := validateIncludeSet(includeSet, infraSpecs, current); err != nil {
return err
}
infraSpecs = filterSpecsByInclude(infraSpecs, includeSet)
current = filterStatesByInclude(current, includeSet)

// Load full config to resolve iac.provider module definitions.
cfg, err := config.LoadFromFile(cfgFile)
if err != nil {
return fmt.Errorf("load config: %w", err)
}

// Plan-time JIT resolution (PR-1): substitute ${MODULE.field} and
// ${SECRET} refs against current state so driver.Diff sees real
// values instead of literal templates. Apply does not print the
// diagnostics — they're plan-output sugar only.
infraSpecs, _, err = resolveSpecsAgainstState(infraSpecs, current, cfg, envName)
if err != nil {
Clarifies why collapsing ${MODULE.id} at plan time is safe for the
TC2 fix scenario: when parent is being replaced, the dependent shows
no-change in the plan (both desired and state have the same old ProviderID),
so there is no plan action carrying a wrong literal ProviderID. The
replace-cascade path (dependent has an action AND parent's id changes)
still flows through apply-time JIT via ReplaceIDMap as before.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@intel352 intel352 merged commit 3f920f7 into main May 7, 2026
23 checks passed
@intel352 intel352 deleted the feat/jit-plan-time-resolver branch May 7, 2026 21:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants