Skip to content

Commit 120debe

Browse files
intel352claude
andauthored
feat(iac): ProviderPlanner interface + cross-plugin build CI gate (W-9) (#534)
* feat(iac): add IaCPlan.SchemaVersion + InputSnapshot + PlanAction.ResolvedConfigHash + DriftEntry type * feat(iac): add inputsnapshot.Compute + Snapshot + NewTolerantEnvProvider with preservation sentinel * feat(iac): wfctl infra plan writes InputSnapshot to plan.json * feat(iac): ComputePlan sets PlanAction.ResolvedConfigHash * feat(iac): wfctl infra plan warns when plan.json not in .gitignore * feat(iac): typed ErrEnvVarChanged sentinel + plan-stale diagnostic + ComputeDrift sentinel-honoring * feat(iac): add refreshoutputs.Refresh — read-only state output refresh T2.1 — bounded-concurrency Refresh(ctx, provider, states, opts) that calls ResourceDriver.Read per resource and returns a copy of the state slice with Outputs reconciled to the live values. Default concurrency 8 when Options.Concurrency < 1; otherwise honor the caller's value. On any Read or driver-resolution failure, returns (nil, err) so callers don't half-persist a refresh. Foundation for wfctl infra refresh-outputs (T2.2) and the opt-in apply pre-step (T2.3). Co-Authored-By: Claude Opus 4.7 <[email protected]> * feat(iac): add wfctl infra refresh-outputs subcommand T2.2 — `wfctl infra refresh-outputs [-c CONFIG] [--env ENV] [--concurrency N]` reads live Outputs for each resource already in state and persists any field-level changes back to the state backend. Read-only at the cloud level — never invokes Update or Replace. Discovers iac.provider modules in the config (with per-env resolution), groups state entries by their owning iac.provider module (ProviderRef-first, falling back to provider type when exactly one module of that type exists), loads each provider once, calls iac/refreshoutputs.Refresh per group, and SaveResource()s any state whose Outputs map changed. When the resolved config has no usable iac.provider module for the requested env, emits the literal error refresh-outputs: provider not configured for env "<env>" verbatim per `fmt.Errorf("refresh-outputs: provider not configured for env %q", env)`. T2.7's runtime-launch-validation asserts against this exact line. Co-Authored-By: Claude Opus 4.7 <[email protected]> * feat(iac): apply-time refresh-outputs pre-step (opt-in via WFCTL_REFRESH_OUTPUTS) T2.3 — wires iac/refreshoutputs.Refresh into runInfraApply as a pre-plan read-only state reconciliation. Default OFF: operators get pre-W-2 behavior unless they explicitly opt in. Activation rules: - WFCTL_REFRESH_OUTPUTS unset, empty, or unrecognised → no-op (default). - WFCTL_REFRESH_OUTPUTS="1"/"true"/"t" (strconv.ParseBool truthy) → run pre-step. - WFCTL_REFRESH_OUTPUTS="0"/"false"/"f" (strconv.ParseBool falsey) → no-op. Operators who use the "0"/"false" convention to disable a feature get the expected behaviour rather than a presence-only foot-gun. - --skip-refresh → suppress pre-step regardless of env var (for CI environments that force the env var on globally). Behavior: after the existing --refresh drift/prune phase and before the plan/apply dispatch, discovers iac.provider modules with per-env resolution, loads current state, and calls refreshOutputsAcrossProviders to read live Outputs and persist any field-level changes. On any Read or driver-resolution failure, apply aborts with the wrapped error from T2.1's helper (no half-persisted refresh, no plan computed against stale state). Only fires for infra.* configs (legacy platform.* path is silently skipped). Rollback: unset WFCTL_REFRESH_OUTPUTS, pass --skip-refresh, or revert this commit. Reverting removes the pre-step entirely (helper file plus the gated block in infra.go). Co-Authored-By: Claude Opus 4.7 <[email protected]> * test(iac): concurrency stress test for refreshoutputs.Refresh T2.5 — pure-package stress test in iac/refreshoutputs/. Drives Refresh with 100 fake resources at Concurrency=8 and asserts: 1. No deadlock (10s watchdog around the call). 2. Read called exactly once per ProviderID (atomic per-ID counter). 3. Every refreshed state carries the live Outputs map — no write-into-wrong-slot bug under concurrency. 4. Concurrent in-flight peak between 2 and the requested cap, proving both that parallelism happened AND that the semaphore enforced its limit. The countingDriver introduces a 5ms sleep per Read so the bounded pool actually queues at the cap (5ms × 100 / 8 ≈ 63ms total at peak; well under the 10s watchdog). Test runs ~1.5s wall. Co-Authored-By: Claude Opus 4.7 <[email protected]> * docs(wfctl): document infra refresh-outputs subcommand T2.6 — adds the infra refresh-outputs section to docs/WFCTL.md: - New row in the Command Tree mermaid graph. - New row in the infra Action table. - Dedicated #### subsection with usage, flag table, behavior summary, literal-error contract (load-bearing per T2.7), apply-time pre-step semantics (WFCTL_REFRESH_OUTPUTS, --skip-refresh), and three representative examples. See also: docs/adr/006-wfctl-refresh-outputs-env-var-parsebool.md records the T2.3 plan-deviation (ParseBool vs plan-literal presence check) that the docs in this commit accurately reflect. Verification — plan §T2.6 line 1090 invocation `mdformat --check docs/WFCTL.md && find docs -name "*.md" -exec markdown-link-check {} +` ran with locally-installed mdformat 1.0.0 (pip) and markdown-link-check 3.14.2 (npm): $ mdformat --check docs/WFCTL.md Error: File "docs/WFCTL.md" is not formatted. exit=1 This failure is PRE-EXISTING. Verified by checking out the file at the W-2 T2.2 tip (181e579) before any T2.6 edits and rerunning mdformat against it: identical error. docs/WFCTL.md has never been mdformat-formatted in this repo. Reformatting the entire file is out of scope for T2.6 (would introduce a multi-thousand-line unrelated diff). T2.6's own additions follow the existing in-file conventions exactly. $ markdown-link-check docs/WFCTL.md FILE: docs/WFCTL.md [✓] https://github.com/GoCodeAlone/workflow [✓] #build-ui [✓] mcp.md 3 links checked. exit=0 docs/WFCTL.md has zero broken links — including the new refresh-outputs section. The directory-wide scan reports 7 broken links in unrelated files (self-improvement-tutorial.md, getting-started.md, etc.); all are pre-existing and out of scope. T2.7 runtime-launch-validation transcript (folded into this commit body per the "Files: none new" plan note for T2.7): $ GOWORK=off go build -o /tmp/wfctl ./cmd/wfctl exit=0 $ /tmp/wfctl infra refresh-outputs --help Usage of infra refresh-outputs: -c string Config file (short for --config) -concurrency int Maximum concurrent Read calls (default 8) -config string Config file -e string Environment name (short for --env) -env string Environment name (resolves per-module overrides) exit=0 $ cat /tmp/t27-fake.yaml modules: - name: state-store type: iac.state config: backend: filesystem directory: /tmp/t27-fake-state $ /tmp/wfctl infra refresh-outputs -c /tmp/t27-fake.yaml --env staging error: refresh-outputs: provider not configured for env "staging" exit=1 No panic, no stack trace. Stderr line is the verbatim literal pinned by T2.7 (plan line 1098), produced by T2.2's fmt.Errorf("refresh-outputs: provider not configured for env %q", env) at cmd/wfctl/infra_refresh_outputs.go:49. PR W-2 mandate (plan line 1101): $ GOWORK=off go test ./iac/refreshoutputs/... ./cmd/wfctl/... -count=1 -race ok github.com/GoCodeAlone/workflow/iac/refreshoutputs 1.405s ok github.com/GoCodeAlone/workflow/cmd/wfctl 10.485s Manual smoke against staging-PG: not run — no staging-PG available in this worktree environment. Plan line 1102 marks this "if available", so deferring to the operator landing the PR. Co-Authored-By: Claude Opus 4.7 <[email protected]> * docs(adr): record WFCTL_REFRESH_OUTPUTS ParseBool semantics deviation from plan §T2.3 ADR 006 — formalises the spec-vs-quality-review trade-off recorded during W-2 T2.3 review: - Plan §T2.3 line 1061 specified `os.Getenv("WFCTL_REFRESH_OUTPUTS") != ""`. - Code-reviewer flagged this as a foot-gun (=0 mis-enables). - Implementation at cmd/wfctl/infra_apply_refresh_pre.go (bfd1bbe) uses strconv.ParseBool so falsey values explicitly disable. - Spec-reviewer accepted post-hoc and requested this ADR per superpowers:recording-decisions. - Team-lead approved option-1 (approve-as-is + follow-up ADR) over a plan revert; provenance recorded in the ADR itself. Captures the rejected alternative, the rationale, references back to the plan spec, the implementation site, the pinning test, and the operator-facing docs. Co-Authored-By: Claude Opus 4.7 <[email protected]> * feat(iac): plugin manifest gains iacProvider.computePlanVersion (default v1) * fix(iac): T3.0 review — sync.Once-guarded schema cache + tighter iacProvider schema Addresses code-reviewer findings on commit 695a070: - Important: race on lazy compiledSchema cache. Wrap with sync.Once; capture both *jsonschema.Schema and the compile error so concurrent callers observe a single deterministic outcome. Adds a 32-goroutine ParseManifest stress test that fires under -race to lock in the invariant going forward. - Minor: ManifestSchemaJSON() now returns bytes.Clone(...) so callers cannot mutate the //go:embed slice (defense-in-depth; embed slices are technically writable). New test verifies the copy semantics. - Minor: iacProvider sub-object gains additionalProperties:false so a typo like "computeplanversion" or an unknown key is rejected at parse time instead of silently defaulting to v1 dispatch. The root object stays permissive — existing plugin.json files carry version/author/dependencies/etc. and the SDK manifest is a strict subset by design. New test covers both the typo-rejection and the root-permissivity contracts. * feat(iac): add refreshoutputs.Refresh — read-only state output refresh T2.1 — bounded-concurrency Refresh(ctx, provider, states, opts) that calls ResourceDriver.Read per resource and returns a copy of the state slice with Outputs reconciled to the live values. Default concurrency 8 when Options.Concurrency < 1; otherwise honor the caller's value. On any Read or driver-resolution failure, returns (nil, err) so callers don't half-persist a refresh. Foundation for wfctl infra refresh-outputs (T2.2) and the opt-in apply pre-step (T2.3). Co-Authored-By: Claude Opus 4.7 <[email protected]> * feat(iac): add wfctl infra refresh-outputs subcommand T2.2 — `wfctl infra refresh-outputs [-c CONFIG] [--env ENV] [--concurrency N]` reads live Outputs for each resource already in state and persists any field-level changes back to the state backend. Read-only at the cloud level — never invokes Update or Replace. Discovers iac.provider modules in the config (with per-env resolution), groups state entries by their owning iac.provider module (ProviderRef-first, falling back to provider type when exactly one module of that type exists), loads each provider once, calls iac/refreshoutputs.Refresh per group, and SaveResource()s any state whose Outputs map changed. When the resolved config has no usable iac.provider module for the requested env, emits the literal error refresh-outputs: provider not configured for env "<env>" verbatim per `fmt.Errorf("refresh-outputs: provider not configured for env %q", env)`. T2.7's runtime-launch-validation asserts against this exact line. Co-Authored-By: Claude Opus 4.7 <[email protected]> * feat(iac): apply-time refresh-outputs pre-step (opt-in via WFCTL_REFRESH_OUTPUTS) T2.3 — wires iac/refreshoutputs.Refresh into runInfraApply as a pre-plan read-only state reconciliation. Default OFF: operators get pre-W-2 behavior unless they explicitly opt in. Activation rules: - WFCTL_REFRESH_OUTPUTS unset, empty, or unrecognised → no-op (default). - WFCTL_REFRESH_OUTPUTS="1"/"true"/"t" (strconv.ParseBool truthy) → run pre-step. - WFCTL_REFRESH_OUTPUTS="0"/"false"/"f" (strconv.ParseBool falsey) → no-op. Operators who use the "0"/"false" convention to disable a feature get the expected behaviour rather than a presence-only foot-gun. - --skip-refresh → suppress pre-step regardless of env var (for CI environments that force the env var on globally). Behavior: after the existing --refresh drift/prune phase and before the plan/apply dispatch, discovers iac.provider modules with per-env resolution, loads current state, and calls refreshOutputsAcrossProviders to read live Outputs and persist any field-level changes. On any Read or driver-resolution failure, apply aborts with the wrapped error from T2.1's helper (no half-persisted refresh, no plan computed against stale state). Only fires for infra.* configs (legacy platform.* path is silently skipped). Rollback: unset WFCTL_REFRESH_OUTPUTS, pass --skip-refresh, or revert this commit. Reverting removes the pre-step entirely (helper file plus the gated block in infra.go). Co-Authored-By: Claude Opus 4.7 <[email protected]> * test(iac): concurrency stress test for refreshoutputs.Refresh T2.5 — pure-package stress test in iac/refreshoutputs/. Drives Refresh with 100 fake resources at Concurrency=8 and asserts: 1. No deadlock (10s watchdog around the call). 2. Read called exactly once per ProviderID (atomic per-ID counter). 3. Every refreshed state carries the live Outputs map — no write-into-wrong-slot bug under concurrency. 4. Concurrent in-flight peak between 2 and the requested cap, proving both that parallelism happened AND that the semaphore enforced its limit. The countingDriver introduces a 5ms sleep per Read so the bounded pool actually queues at the cap (5ms × 100 / 8 ≈ 63ms total at peak; well under the 10s watchdog). Test runs ~1.5s wall. Co-Authored-By: Claude Opus 4.7 <[email protected]> * docs(wfctl): document infra refresh-outputs subcommand T2.6 — adds the infra refresh-outputs section to docs/WFCTL.md: - New row in the Command Tree mermaid graph. - New row in the infra Action table. - Dedicated #### subsection with usage, flag table, behavior summary, literal-error contract (load-bearing per T2.7), apply-time pre-step semantics (WFCTL_REFRESH_OUTPUTS, --skip-refresh), and three representative examples. See also: docs/adr/006-wfctl-refresh-outputs-env-var-parsebool.md records the T2.3 plan-deviation (ParseBool vs plan-literal presence check) that the docs in this commit accurately reflect. Verification — plan §T2.6 line 1090 invocation `mdformat --check docs/WFCTL.md && find docs -name "*.md" -exec markdown-link-check {} +` ran with locally-installed mdformat 1.0.0 (pip) and markdown-link-check 3.14.2 (npm): $ mdformat --check docs/WFCTL.md Error: File "docs/WFCTL.md" is not formatted. exit=1 This failure is PRE-EXISTING. Verified by checking out the file at the W-2 T2.2 tip (181e579) before any T2.6 edits and rerunning mdformat against it: identical error. docs/WFCTL.md has never been mdformat-formatted in this repo. Reformatting the entire file is out of scope for T2.6 (would introduce a multi-thousand-line unrelated diff). T2.6's own additions follow the existing in-file conventions exactly. $ markdown-link-check docs/WFCTL.md FILE: docs/WFCTL.md [✓] https://github.com/GoCodeAlone/workflow [✓] #build-ui [✓] mcp.md 3 links checked. exit=0 docs/WFCTL.md has zero broken links — including the new refresh-outputs section. The directory-wide scan reports 7 broken links in unrelated files (self-improvement-tutorial.md, getting-started.md, etc.); all are pre-existing and out of scope. T2.7 runtime-launch-validation transcript (folded into this commit body per the "Files: none new" plan note for T2.7): $ GOWORK=off go build -o /tmp/wfctl ./cmd/wfctl exit=0 $ /tmp/wfctl infra refresh-outputs --help Usage of infra refresh-outputs: -c string Config file (short for --config) -concurrency int Maximum concurrent Read calls (default 8) -config string Config file -e string Environment name (short for --env) -env string Environment name (resolves per-module overrides) exit=0 $ cat /tmp/t27-fake.yaml modules: - name: state-store type: iac.state config: backend: filesystem directory: /tmp/t27-fake-state $ /tmp/wfctl infra refresh-outputs -c /tmp/t27-fake.yaml --env staging error: refresh-outputs: provider not configured for env "staging" exit=1 No panic, no stack trace. Stderr line is the verbatim literal pinned by T2.7 (plan line 1098), produced by T2.2's fmt.Errorf("refresh-outputs: provider not configured for env %q", env) at cmd/wfctl/infra_refresh_outputs.go:49. PR W-2 mandate (plan line 1101): $ GOWORK=off go test ./iac/refreshoutputs/... ./cmd/wfctl/... -count=1 -race ok github.com/GoCodeAlone/workflow/iac/refreshoutputs 1.405s ok github.com/GoCodeAlone/workflow/cmd/wfctl 10.485s Manual smoke against staging-PG: not run — no staging-PG available in this worktree environment. Plan line 1102 marks this "if available", so deferring to the operator landing the PR. Co-Authored-By: Claude Opus 4.7 <[email protected]> * docs(adr): record WFCTL_REFRESH_OUTPUTS ParseBool semantics deviation from plan §T2.3 ADR 006 — formalises the spec-vs-quality-review trade-off recorded during W-2 T2.3 review: - Plan §T2.3 line 1061 specified `os.Getenv("WFCTL_REFRESH_OUTPUTS") != ""`. - Code-reviewer flagged this as a foot-gun (=0 mis-enables). - Implementation at cmd/wfctl/infra_apply_refresh_pre.go (bfd1bbe) uses strconv.ParseBool so falsey values explicitly disable. - Spec-reviewer accepted post-hoc and requested this ADR per superpowers:recording-decisions. - Team-lead approved option-1 (approve-as-is + follow-up ADR) over a plan revert; provenance recorded in the ADR itself. Captures the rejected alternative, the rationale, references back to the plan spec, the implementation site, the pinning test, and the operator-facing docs. Co-Authored-By: Claude Opus 4.7 <[email protected]> * feat(iac): add ApplyResult.InitialInputSnapshot + InputDriftReport + ReplaceIDMap fields * feat(iac): add wfctlhelpers.ApplyPlan skeleton (4-action dispatch) * fix(iac): T3.0.4 review — correct ReplaceIDMap key direction + lock omitempty contract Addresses code-reviewer findings on commit 13a6fad: - Important: ReplaceIDMap godoc said "Keyed by the dependent resource Name" but the populating site (T3.4 plan §1625) sets result.ReplaceIDMap[action.Resource.Name] where action.Resource is the REPLACED resource. The roundtrip fixture {"vpc":"new-uuid"} confirms this. Re-worded to "Keyed by the *replaced* resource's Name" with an explicit reference to action.Resource.Name + a sentence on how W-5 JIT substitution will use the map (lookup by replaced-resource name to obtain the new ProviderID for dependent configs). Locks the contract before the field has any consumers. - Minor: cross-referenced the InputDriftReport sort-stability guarantee to its enforcing test (TestComputeDrift_ResultIsSortedByName in iac/inputsnapshot/compute_drift_test.go) so the contract is no longer free-floating on the field godoc. - Minor: added TestApplyResult_OmitEmptyContract — table-driven across nil and empty-but-non-nil values for all three new fields, asserting the JSON keys are absent from the encoded form. Locks the omitempty tag behavior so a future refactor cannot silently regress to emitting "initial_input_snapshot": {} / "input_drift_report": [] / "replace_id_map": {}. * fix(iac): T3.1 review — strengthen Replace coverage + ctx-cancel + driver-resolve test Addresses code-reviewer findings on commit 8416498: - Important 1 (weak Replace assertion): converted fakeDriver from boolean call recorders to integer counters. The 4-action plan [create, update, replace, delete] now asserts Create==2, Update==1, Delete==2. If "case replace" were silently dropped from dispatchAction the counts would shift to 1/1/1 and the test would fail. Added TestApplyPlan_ReplaceDispatchesViaDeleteThenCreate that isolates Replace via a single-action plan: 1 Delete + 1 Create + 0 Update. Removes the calledReplace() proxy entirely. - Important 2 (resolve-driver-error path uncovered): added TestApplyPlan_ResolveDriverErrorRecordsActionError which exercises fakeProvider.driverErr, asserts the canonical "resolve driver:" prefix, and verifies the loop continues past action[0] to action[1] (best-effort contract). Folded the loop-continues-after-failure coverage into a separate TestApplyPlan_LoopContinuesAfterPerActionFailure using a selectiveFakeProvider that errors on one type only — proves one action's failure does not block another's success. - Minor 1 (wasted %w): switched fmt.Errorf(...).Error() to fmt.Sprintf("resolve driver: %v", err) since the destination is a string field and the wrapping chain dies at the field boundary. - Minor 3 (ctx.Done not checked): added ctx.Err() check at the loop iteration boundary; on cancel, returns the result accumulated so far + the ctx error as top-level. Added TestApplyPlan_CtxCancellationStopsLoop covering pre-call cancel: driver receives zero invocations, top-level error is context.Canceled. - Minor 5 (refFromAction defensive note): added a godoc paragraph documenting the same-name-same-type invariant for Replace plans. Documenting rather than enforcing — ComputePlan upstream is the contract owner. Minor 2 (uniform error prefixing across sub-functions) intentionally deferred to T3.2/T3.3/T3.4 per reviewer guidance — those tasks own the final sub-function bodies and can pick the convention once. * fix(wfctl): drop unused crypto/sha256 + encoding/hex from infra_apply_plan_test Imports were left orphaned by W-1 PR #523 (commit 48f7a0c) when fingerprintForTest was switched to delegate to inputsnapshot.Compute instead of computing sha256 inline. cmd/wfctl test build was broken on HEAD because of the unused imports — surfaced while landing T3.1.5, which adds a new test file in the same package. Pure-mechanical cleanup. No behavior change. * feat(iac): in-process apply unconditional drift postcondition (panic-safe + tolerant of mid-apply env unset) * feat(iac): doCreate honors UpsertSupporter for ErrResourceAlreadyExists recovery * feat(iac): doUpdate + doDelete actions * feat(iac): doReplace populates ApplyResult.ReplaceIDMap * feat(iac): add diff cache with LRU eviction + corruption recovery * fix(iac): T3.1.5/T3.2/T3.3 review minors — helper consistency, type-assertion coverage, prefix policy Three independent review-fix bundles: T3.1.5 (commit f5a7ce9 review — Minor 1): - apply_postcondition_test.go::fingerprint now delegates to inputsnapshot.Compute, mirroring cmd/wfctl/infra_apply_plan_test.go's fingerprintForTest. Drops the inline crypto/sha256 + encoding/hex imports. Future Compute-algorithm changes (prefix length, hash) now re-align both test files automatically — keeps the cross-package fixture parity guaranteed. T3.2 (commit 0c30eec review — Minors 1 + 2): - apply_create_test.go gains TestApplyPlan_Create_AlreadyExists_DriverDoesNotImplementUpsertSupporter + alreadyExistsBareDriver + bareDriverProvider. Covers the `!ok` arm of doCreate's `us, ok := d.(interfaces.UpsertSupporter)` type assertion — distinct code path from the existing ok-but-SupportsUpsert==false test. Compile-time premise check ensures the test stays meaningful if a future refactor lifts SupportsUpsert onto the embedded fakeDriver. - apply.go::doCreate godoc tightens the errors.Is contract to make the in-package vs at-the-ActionError-boundary distinction explicit. External callers reading [interfaces.ApplyResult].Errors lose errors.Is matching at the string-conversion boundary; the canonical "upsert: read after conflict:" prefix is the discriminant. Also documents the single-pass recovery contract (recovery Update that itself returns ErrResourceAlreadyExists surfaces unchanged rather than retriggering the recovery loop). T3.3 (commit a3fc98b review — Minors 1 + 2 + 4): - apply_update_delete_test.go::TestApplyPlan_Update_NilCurrentIsHandledDefensively now also asserts len(result.Resources) == 1 on the success path — locks the resource-append contract so a regression that skipped the append on nil Current would fail loudly. - apply_update_delete_test.go gains parallel TestApplyPlan_Delete_NilCurrentIsHandledDefensively. Same defensive shape: empty ProviderID flows to driver, no synthesized precondition error, deleteCount==1 (latent bug-fix from design — the v1 path silently skipped Delete; v2 must call it). - apply.go package godoc adds a "Per-action error-prefix policy" section documenting the decompose-then-prefix rule (bare on simple actions; "upsert: ..." / "replace: ..." on decomposing paths) so future reviewers don't suggest "let's add prefixes for consistency." * fix(iac): T3.4 review — ctx-cancel guard between Delete and Create in doReplace Addresses code-reviewer Minor 1 (worth-doing) on commit b17d703. Without the guard, a Ctrl-C / SIGTERM arriving exactly between the Delete and Create driver calls of a Replace action would still trigger the Create — surprising operators who expected fast interruption mid-Replace. The half-replaced state is still the documented recovery surface (Delete happened, Create did not, so ReplaceIDMap stays empty), but cancellation now propagates as soon as it is observable. Failure shape: return fmt.Errorf("replace: canceled after delete: %w", err) Wrapped to preserve the context.Canceled / context.DeadlineExceeded sentinel for in-package errors.Is matching. The "replace: canceled after delete:" string prefix is the discriminant for callers reading result.Errors at the public API surface. New test: TestApplyPlan_Replace_CtxCancelAfterDelete_SkipsCreate + cancelOnDeleteFakeProvider scaffolding. Driver's Delete invokes a captured context.CancelFunc as a side-effect, simulating exact post-Delete cancellation. Asserts Delete ran, Create did NOT, ReplaceIDMap stays empty for the resource, error has the canonical prefix. Code-reviewer Minor 3 (ctx-cancel mid-Replace test) folded into this commit since it's the symmetric coverage for the new guard. Other Minors (2/4/5/6/7) intentionally skipped — all documentary or out-of-scope per reviewer guidance. * docs(iac): document diffcache + set WFCTL_DIFFCACHE=:memory: in CI workflows T3.5 lifecycle constraint #4 (rev3) follow-up — addresses spec-reviewer finding on commit 8774205. Two plan-mandated deliverables that the T3.5 commit's `git add` line omitted: 1. **docs/WFCTL.md gains a "Diff Cache" section.** Documents the cache as an amortization-only optimization (not correctness mechanism), the WFCTL_DIFFCACHE backend selection (disabled / :memory: / filesystem default), the LRU eviction caps (1024 entries / 64 MiB), the corruption recovery contract (silent eviction + once-per-process info log), the plugin-downgrade safety property, and the rev3 "all CI workflows set :memory: explicitly" statement plus a list of the affected workflow files. 2. **WFCTL_DIFFCACHE=:memory: at workflow-level env in CI.** Set in every workflow that runs `go test` or `wfctl`: - .github/workflows/ci.yml (test + lint jobs) - .github/workflows/benchmark.yml (performance benchmarks) - .github/workflows/pre-release.yml (pre-release tests) - .github/workflows/release.yml (release tests) - .github/workflows/dependency-update.yml (post-update test gate) Workflow files that don't invoke go test / wfctl are not modified (codeql.yml, copilot-setup-steps.yml, create-release.yml, helm-lint.yml, osv-scanner.yml, test-dispatch.yml). Each workflow gets a brief inline comment citing ci.yml as the canonical rationale + the T3.5 rev3 lifecycle constraint reference. Per spec-reviewer guidance: kept the original T3.5 package-code commit (8774205) untouched and stacked this docs+CI commit on top. YAML syntax verified on all 5 modified workflows. * fix(iac): T3.5 review minors — atomic Put + godoc tightening + test cleanup Addresses 5 of 7 code-reviewer minors on commits 8774205 + f80a060: - Minor 1 (atomic Put, worth-doing production improvement): Put now uses write-temp-then-rename. POSIX rename(2) is atomic on the same filesystem, so a process crash mid-write leaves either the prior contents or the new contents — never a partial write. The corruption-recovery path in Get is still the safety net for cross- filesystem renames or NFS edge cases that don't honor atomicity. In production this means corruption recovery essentially never fires from native crashes. The .json extension filter in maybeEvict already excludes .tmp orphans, so no additional filtering needed. On rename failure, best-effort cleanup of the temp file. - Minor 3 (userCacheDir godoc): tightened the platform-conventions language. Linux honors XDG_CACHE_HOME; macOS uses ~/Library/Caches; Windows uses %LocalAppData%. The previous comment overstated XDG honoring on all platforms. - Minor 4 (Key JSON tags vs keyFingerprint): added a godoc note explaining the tags are for log/transcript serialization, not cache keying — keyFingerprint uses NUL-separated string concat, not JSON marshaling. Future readers checking the fingerprint shape now have the right pointer. - Minor 5 (vestigial sanity check): dropped the `os.Stat(filepath.Join(dir, "*.json"))` literal-glob check at the end of TestCache_EvictionTouchesNothingWhenUnderCap. The check was meaningless — no code path creates a file with `*` in its name. Likely leftover from earlier debugging. Removing it lets us drop the now-unused `os` import. - Minor 6 (mtime resolution test comment): added a paragraph to TestCache_LRUEvictionByCount's godoc explaining the ≤1ms mtime resolution assumption and listing the supported filesystems (ext4/btrfs/xfs/APFS/NTFS — the CI matrix). Coarse-mtime filesystems (FAT32, SMB) are explicitly out of scope. Skipped per reviewer guidance: - Minor 2 (maybeEvict O(N) scan on every Put): "skeleton-class concern; acceptable for W-3a scope." - Minor 7 (Put error log-silent): "the cache-as-amortization framing in the package godoc already sets the expectation." * refactor(iac): ComputePlan signature accepts ctx+provider (no behavior change) * feat(iac)!: wfctl infra plan now loads provider for Diff dispatch (BREAKING: fails on plugin-load error) W-3b T3.6b. Adds computePlanForInfraSpecs which discovers iac.provider modules in the config, groups desired specs by `provider:` field, loads each via the same loader the apply path uses, and dispatches platform.ComputePlan per group so the v2 Diff contract (T3.6e) operates against a real plugin process at plan time, not just at apply time. BREAKING: configs declaring at least one iac.provider module now require the plugin process to load successfully. Plugin-load failure exits non-zero with the literal error documented in the v0.21.0 CHANGELOG. There is no --no-provider escape hatch (rev3 YAGNI fix per cycle-2); operators who need pure offline validation should use `wfctl validate`. Configs without any iac.provider module fall back to the legacy ConfigHash compare path so minimal/legacy fixtures and out-of-band scripts continue to work. cmd/wfctl/infra_apply.go:350 receives a temporary nil provider so the package compiles; T3.6c replaces nil with the live provider handle. * feat(iac): wfctl infra apply threads provider into ComputePlan * test(iac): update cross-package fakes for ComputePlan provider arg W-3b T3.6d. Updates the 4 cross-package ComputePlan call sites in module/infra_module_integration_test.go to the new (ctx, provider, …) signature. Lifts the no-op fake into a small public test helper at iac/iactest/fakeprovider.go so the same shape no longer needs to be re-declared every time a new package wants to satisfy the interface. Folds in the T3.6c review's IMPORTANT follow-up: cmd/wfctl's computePlanForInfraSpecs now dispatches via the same computeInfraPlan seam the apply path uses (no parallel seam variable; one override point serves both call sites). Plan-loop body is wrapped in an IIFE so each provider's closer fires after its group is computed instead of deferring to function exit (multi-provider plan no longer holds N gRPC connections open at once). Drops the duplicated planNoopProvider and applyV2RecordingProvider no-op implementations in cmd/wfctl tests in favor of the shared iactest.NoopProvider. Three structurally-identical 14-method shells become one. Atomic counters carried forward where used. Doc updates: - godoc on computePlanForInfraSpecs corrected: groups are concatenated in first-reference-in-`desired` order, not iac.provider declaration order (matches actual code). - CHANGELOG entry calls out the empty-desired alignment with apply (loop over groupOrder is empty when no specs reference any provider; use `wfctl infra destroy --dry-run` to preview teardown). * feat(iac): ComputePlan dispatches Diff per resource; emits replace action when ForceNew or NeedsReplace W-3b T3.6e — the binding TDD red→green commit for the v2 IaC contract (rev3 fix for the cycle-2 self-contradiction: test + impl ship in the same SHA, no t.Skip placeholder). ComputePlan now classifies each existing resource via p.ResourceDriver(spec.Type).Diff(ctx, spec, currentOut), running the per-resource Diff calls in parallel under errgroup with a bounded worker pool (default 8; WFCTL_PLAN_DIFF_CONCURRENCY env var override clamped 1..32). Action emission: - replace, when DiffResult.NeedsReplace OR any FieldChange.ForceNew is true (the latter closes design issue C — pre-W-3b ForceNew was silently downgraded to update); - update, when DiffResult.NeedsUpdate is true and replace did not fire; - skip, when neither flag is set. Net-new resources still emit create without dispatching Diff; resources removed from desired still emit delete in reverse-dep order. Nil-tolerance contract preserved: if p is nil, or if p.ResourceDriver(typ) returns (nil, nil) for a resource type, ComputePlan falls back to the legacy ConfigHash compare for the affected resources. Replace cannot be expressed via the legacy path — callers needing Replace must supply a provider whose drivers implement Diff. Per-resource driver.Diff errors propagate via errgroup so operators see the underlying cause (rate limit, network, etc.). Test surface (platform/differ_replace_test.go, NEW; ships in this commit per the rev3 atomicity rule): - TestComputePlan_NeedsReplaceEmitsReplaceAction - TestComputePlan_ForceNewWithoutNeedsReplace_StillEmitsReplace - TestComputePlan_NeedsUpdateWithoutForceNew_EmitsUpdate - TestComputePlan_DiffReturnsNoChanges_EmitsNothing - TestComputePlan_NilProvider_FallsBackToConfigHash - TestComputePlan_NilDriver_FallsBackToConfigHash - TestComputePlan_DriverDiffError_PropagatesAsError platform/fake_provider_test.go extended with newFakeProviderWithDiff helper; in-package no-op fakeProvider/fakeDriver kept (cannot collapse to iac/iactest until cache_test in T3.6f also depends on the helper — deferred to keep T3.6e's diff bounded). Carry-forward notes addressed: - T3.6a note 1: dropped unused *testing.T param from newFakeProvider(). - T3.6a note 2: added compile-time interface conformance asserts on fakeProvider and fakeDriver. - T3.6a note 3: nil-provider AND nil-driver guards baked in; covered by two explicit tests. - T3.6a note 4: rewrote fake_provider_test.go godoc to behavior-based phrasing. cmd/wfctl test fakes updated to match the new dispatch model: - readDriver.Diff now returns NeedsUpdate=true (the adoption tests rely on the post-adopt ComputePlan emitting update; pre-W-3b that was the ConfigHash compare's job). - refreshOutputsCmdFakeDriver.Diff now returns (nil, nil) instead of panicking — the refresh-outputs test fixture only exercises Read. * perf(iac): ComputePlan consults diffcache before invoking provider.Diff W-3b T3.6f. Wires the iac/diffcache package (W-3a/T3.5) into classifyModification: cache.Get is consulted before each ResourceDriver.Diff dispatch under the (PluginVersion, Type, ProviderID, SHAConfig, SHAOutputs) tuple; on hit, the cached DiffResult is used directly; on miss, the freshly-computed result is Put into the cache. Apply-time correctness does not depend on cache hits — fresh CI runners always miss and re-Diff (the cache is purely an amortization optimization for repeated `wfctl infra plan` against the same checkout). Cache backend selection follows iac/diffcache's WFCTL_DIFFCACHE env var contract: unset → filesystem (~/.cache/wfctl/diff/); ":memory:" → in-memory; "disabled" → noop. The package-level cache instance is lazy-initialised on first ComputePlan call and shared across subsequent calls; tests in the same package may swap it via the internal-package setDiffCacheForTest helper. platform/main_test.go (NEW) sets WFCTL_DIFFCACHE=disabled at TestMain so the platform test suite never reads/writes the developer's filesystem cache and so cache state cannot leak across tests with incidentally-aligned cache keys (caught during integration: T3.6e's Replace-emission test was Putting a result that polluted later update/no-op tests). Folds in the T3.6e code-review IMPORTANT carry-forwards (since both fixes touch platform/): - Note 1 (env-clamping testability): extract parseConcurrencyEnv as a pure function; new TestParseConcurrencyEnv table-driven test covers empty, non-numeric, "0", "1", "8", "32", "33", "100", "-5". - Note 2 (parallel-dispatch correctness): new TestComputePlan_ParallelDispatch_AllCandidatesObserveDiff exercises N=5 modification candidates, asserts driver.diffCount.Load() == 5 and the resulting plan has 5 actions. - Note 3 (driver returns nil DiffResult): explicit test TestComputePlan_DriverReturnsNilDiff_EmitsNothing. And T3.6e adversarial-review minor cleanups: - Note 4 (i := i shadowing redundant in Go 1.22+): dropped. - Note 5 (errSentinel uses custom errFromTest): replaced with errors.New. - Note 7 (concurrency contract on ComputePlan godoc): added — p and the ResourceDriver instances it returns MUST be safe for concurrent use. New tests (3 cache-behaviour scenarios in differ_cache_test.go): - TestComputePlan_CacheHitSkipsDiff (second call against unchanged inputs hits cache; diffCount stays at 1) - TestComputePlan_CacheMissesOnDifferentInputs (varying SHAConfig forces re-dispatch) - TestComputePlan_NoopCacheNeverHits (disabled backend always re-dispatches) * test(iac): T3.6e review — channel-gated parallel-dispatch in-flight test (Copilot review) Strengthens the count-only TestComputePlan_ParallelDispatch_AllCandidatesObserveDiff (landed in T3.6f) per team-lead's explicit request: a regression that accidentally serialized Diff dispatch (e.g., g.SetLimit(1)) would still pass the count-only assertion as long as every candidate eventually got dispatched. The new TestComputePlan_ParallelDiffDispatch_InFlightGoroutinesObserved uses a channel-gated driver to prove ≥2 Diff goroutines are simultaneously in-flight before any returns: regression to serial dispatch would hang on the second `<-entered` and time out at 5s. Pure addition (no production-code change). cacheTestProvider.driver loosened from *cacheTestDriver to interfaces.ResourceDriver so the new channelGatedDriver shares the provider shell. * fix(iac): T3.6f review — pluginVersionKey uses sha256 instead of @ separator (Copilot review) Code-reviewer flagged the T3.6f cache PluginVersion key as fragile: composing via `p.Name() + "@" + p.Version()` would let two genuinely-different providers — `("foo", "[email protected]")` vs `("foo@bar", "1.0")` — collide on the literal string `"foo@[email protected]"` and serve each other's cached DiffResults. Today's registered providers (digitalocean, dockercompose, mock) don't carry `@` in either field so no observed bug, but there's no compile-time guard against a future provider declaring `do@enterprise` or similar. Replace with sha256(name + "\x00" + version) — fixed-length, NUL is invalid in both fields by Unicode convention, ambiguity-free. Matches how configHash already keys per-config inputs. Three regression tests pin the fix: - TestPluginVersionKey_NoCollisionOnAtSeparator (the actual bug) - TestPluginVersionKey_NilProvider (defensive — empty key, no panic) - TestPluginVersionKey_Stable (deterministic across calls) Pure additive — no change to any existing test outcome. The cache re-keys against the new digest, which means any DiffResults persisted under the old `name@version` keys will miss on the next plan and re-Diff naturally (cache misses are correct by design). * feat(iac): apply path branches on plugin manifest's iacProvider.computePlanVersion W-3b T3.7. Routes apply through wfctlhelpers.ApplyPlan when the loaded plugin's plugin.json declares iacProvider.computePlanVersion: v2 (read at provider load time and surfaced via the optional ComputePlanVersionDeclarer interface). Providers that don't declare the field, or declare anything other than "v2", take the legacy provider.Apply path. rev2/rev3-locked: NO env-var, NO operator-flippable gate. The v1/v2 routing is plugin-author-controlled via plugin.json from day 1 — there is no transitional WFCTL_USE_V2_APPLY flag to misuse. Wires the printDriftReportIfAny helper (added unwired in W-3a/T3.1.5 as foundation only). The v2 dispatch path is the production caller that surfaces the InputDriftReport to stderr after a successful ApplyPlan return; v1 path remains untouched per the W-3a "zero runtime change for v1 plugins" invariant. New plumbing: - iac/wfctlhelpers/dispatch.go (NEW): ComputePlanVersionDeclarer interface + DispatchVersionV2 const + DispatchVersionFor helper. Single override point for the dispatch decision. - iac/iactest/fakeprovider.go: NoopProvider gains DispatchVersion + ProviderVersion fields and ComputePlanVersion() method so tests drive both v1 (default empty) and v2 paths through the shared fake. - cmd/wfctl/deploy_providers.go: iacPluginManifest reads top-level iacProvider.computePlanVersion alongside existing capabilities.iacProvider.name; findIaCPluginDir returns the version; readIaCPluginComputePlanVersion is the load-time helper; remoteIaCProvider stores the value and exposes it via ComputePlanVersion() to satisfy the optional interface. (Re-reads plugin.json once per provider load rather than threading through loadIaCPlugin's 4-tuple var-seam — keeps the seam signature stable for the existing test override; cost is one tiny os.ReadFile vs the gRPC start.) - cmd/wfctl/infra_apply.go: applyV2ApplyPlanFn = wfctlhelpers.ApplyPlan test seam + dispatch branch in applyWithProviderAndStore. Drift report printed to writer on success (no-op when empty). - cmd/wfctl/infra_apply_v2_test.go: 3 new tests cover TestApplyWithProviderAndStore_V2RoutesThroughWfctlhelpers (v2 routes), TestApplyWithProviderAndStore_V1FallsThroughToProviderApply (v1/un-declared routes legacy), TestApplyWithProviderAndStore_V2 PrintsDriftReport (drift wiring asserted via writer-buffer substring). v1 fixture v1RecordingProvider intentionally does NOT implement ComputePlanVersionDeclarer to prove the dispatcher's "default to v1 when un-declared" branch. * fix(iac): T3.7 review — drift report on partial failure + Path B coverage (Copilot review) Code-reviewer flagged 3 IMPORTANT items in T3.7: 1. Comment/code mismatch on drift-report timing. The comment promised "Run on success or partial failure" but the code gated on `err == nil` (success only). The contract the comment described is the more useful behavior — operators most need the stale-input diagnostic when an apply fails ("which input went stale during the failed apply?"). Without it, the failure error and the "what changed" context are disconnected. Fix: gate on `result != nil` instead of `err == nil`. printDriftReportIfAny already no-ops on empty/nil reports so unconditional-on-result-non-nil is safe. 2. No test for the drift-on-partial-failure path. Added TestApplyWithProviderAndStore_V2PrintsDriftReportOnPartialFailure which has applyV2ApplyPlanFn return (resultWithDrift, applyErr) and asserts both: (a) the err propagates, AND (b) the drift report still reaches the writer. 3. Optional-interface coverage gap. Two semantically-different "v1" paths exist: - Path A: provider doesn't implement ComputePlanVersionDeclarer at all → type-assert fails → legacy. Covered by v1RecordingProvider. - Path B: provider implements interface but ComputePlanVersion() returns "" (the realistic mid-transition state for v1 plugins after the SDK update lands but before they migrate) → type- assert succeeds, DispatchVersionFor returns "v1" → legacy. Was untested. Added TestApplyWithProviderAndStore_V1Path_DeclarerReturnsEmpty using iactest.NoopProvider{DispatchVersion: ""}, which always implements the interface (the method exists on the type). Pins Path B specifically. Pure correctness fixes — no signature change, no behavior change for the success-only or v1-RecordingProvider paths. * fix(iac): map[string]bool drops gRPC args silently — sensitiveToAny conversion cmd/wfctl/deploy_providers.go remoteResourceDriver.Diff was passing current.Sensitive (map[string]bool) directly into the args map. structpb.NewStruct rejects map[string]bool — it accepts map[string]any only — and the upstream plugin/external/convert.go::mapToStruct returns &structpb.Struct{} on err rather than surfacing the typing failure. Result: every Diff dispatch over gRPC for any provider whose ResourceOutput.Sensitive map was non-nil (or even an empty map[string]bool{}) silently observed args=map[] on the plugin side. v1 plugins never tripped this because v1 dispatches IaCProvider.Plan server-side (no ResourceDriver.Diff over gRPC). v2 (W-3b T3.7's manifest-driven dispatch) surfaces it immediately on the first existing-resource Diff call. Fix: convert via sensitiveToAny() to the map[string]any shape NewStruct accepts. Returns nil for empty/nil input so the wire stays trim-friendly. Bug discovered during W-3b T3.9 runtime-launch validation against an out-of-band gRPC stub plugin; the canonical T3.9 in-tree test ships separately as a loader-seam Go integration test (per team-lead direction + plan precedent at plugin/sdk/iaclint/). Will surface in T3.10's PR description as a third incidentally-fixed-by-W-3b bug. * test(iac): T3.9 runtime-launch-validation via loader-seam (ADR 007) W-3b T3.9. Exercises the full v2 dispatch chain — config parse → state load → provider load (via the resolveIaCProvider seam from T3.6c) → ComputePlan Diff dispatch (T3.6e/f) → wfctlhelpers.ApplyPlan (T3.7's manifest-driven branch) → Replace decomposition into Delete + Create → printDriftReportIfAny — by injecting a Go in-process v2-declaring provider through the package- level seam. No out-of-process gRPC binary or plugin.json under internal/testdata/. # ADR 007 — non-trivial deviation from plan-literal Plan §T3.9 specified "Build a real gRPC-loaded stub provider plugin in internal/testdata/stub-provider/." Team-lead authorized switching to in-tree loader-seam validation per: 1. Plan precedent cite (plugin/sdk/iaclint/) is itself a Go test-helper package, not a runnable binary. 2. Real-gRPC runtime validation lands in P-DO when DO sets computePlanVersion: v2 in its plugin.json. 3. Hours-of-stub-plumbing cost doesn't earn proportional coverage vs. T3.6e/f + T3.7 unit tests + this loader-seam end-to-end. 4. W-7 conformance suite is the recurring cross-PR gRPC harness. Full reasoning + considered alternatives in docs/adr/007-t3-9-runtime-validation-via-loader-seam.md. # Tests - TestApply_V2_LoaderSeamDispatch_EndToEnd: - Writes a real config + filesystem state seeded with vpc region=nyc3 (under iacStateRecord shape). - Sets desired region=nyc1. - Substitutes the resolveIaCProvider seam to return a Go provider that declares v2 + has a driver returning NeedsReplace=true. - Calls applyInfraModules (the production runInfraApply entrypoint) and asserts driver.diffCount == 1, deleteCount == 1, createCount == 1, plus exact identity of the deleted ProviderID and the created Config["region"]. - TestApply_V2_LoaderSeam_DriftReportPrinted: - Same loader-seam setup + applyV2ApplyPlanFn substitution returning InputDriftReport with one entry. - Captures os.Stderr and asserts the FormatStaleError block reaches the operator (drift-report wiring T3.7 added is end-to-end alive in the v2 loader path). # Test infrastructure - cmd/wfctl/main_test.go: NEW TestMain forces WFCTL_DIFFCACHE=disabled so the platform diffcache (process- scoped via getDiffCache lazy init) doesn't observe stale entries from a developer's local ~/.cache/wfctl/diff/ as false-positive cache hits skipping driver Diff dispatch. Same pattern as platform/main_test.go from T3.6f. Caught during dev when the end-to-end test failed in the full cmd/wfctl test run but passed in isolation. # Bug-class context The Option-A draft (real gRPC binary; not retained on this branch per the ADR) surfaced a real wfctl bug fixed in commit 40e07a1 (remoteResourceDriver.Diff sensitiveToAny conversion). The bug exists independent of which T3.9 option ships; the fix is in tree and surfaces in T3.10's PR description as the third W-3b incidentally-fixed bug. * docs(pr): note bugs incidentally fixed by W-3b W-3b T3.10. Stages the W-3b PR body text in docs/prs/w3b-pr-body.md as a stable artifact the team-lead can copy-paste at PR-open time. Pure-additive doc; no code changes. Captures all three incidentally-fixed bugs surfaced during W-3b's binding dispatch wiring: 1. Delete-via-Apply state leakage (T3.3 doDelete + T3.7 dispatch) 2. ForceNew silently downgraded to Update (T3.6e replace emission) 3. map[string]bool drops gRPC args silently — sensitiveToAny converter (commit 40e07a1; surfaced during T3.9 runtime validation; v1 plugins never tripped it) Includes summary, BREAKING-change call-out, ADR reference, rollout notes, and test plan. * docs(adr): amend ADR 007 with full T3.9 decision history (5 transitions) Per spec-reviewer's adversarial review of the prior keeps-grpc-stub variant: the durability invariant for recording-decisions requires preserving ALL transitions of a deliberation, not just the final landing. The original ADR (loader-seam variant) recorded only one team-lead direction; the keeps-grpc-stub variant (since superseded) recorded only one reversal. Neither captured the full B → A → B → A → B oscillation that played out during T3.9 execution. This commit: - Status header updated to "Accepted (with extensive deliberation history — see Decision history section)". - Context section adjusted to preface the deliberation history rather than imply a single-direction trajectory. - New Decision history section lists all 5 transitions with verbatim team-lead quotes + per-transition implementer action. - Final paragraph captures the meta-lesson: when team-lead path- flips mid-execution, reviewer + implementer should refuse to proceed and force explicit disambiguation. Both reviewers endorsed this hold during transition 4; the strict-interpretation invariant from using-superpowers was the operative rule. Pure ADR amendment; no code changes. Branch state (c9101ba T3.9 loader-seam + d2e50d4 T3.10 PR body) unaffected. Closes spec-reviewer's Issue 1 from c9101ba pre-review: "ADR-history erasure: cherry-picking 92f060e onto 40e07a1 erased the durable record of team-lead's 'Path #1 — keep A' reversal. Future branch-readers will see no record of why Option A was considered + rejected." * feat(iac): add optional ProviderPlanner interface for v2 plugins (rev10 user override) * ci(iac): cross-plugin build gate + ADR 009 (ProviderPlanner included per user override) * docs(iac): document ProviderPlanner adapter author guide * docs(adr): restore plan-literal Context para 1 in ADR 009 (T9.2 spec-review fix) * docs(iac): point ProviderPlanner author guide at real ProviderIDValidator precedent (T9.3 quality fix) * ci(iac): add fail-fast=false, concurrency, go.mod/go.sum paths to cross-plugin gate (T9.2 quality fix) * fix(iac): R2 review — correct ProviderPlanner doc/ADR/test/CI findings (Copilot review) Six Copilot inline findings + CodeQL workflow-permissions warning: 1. docs/iac/providerplanner.md: ComputePlan in v0.21.0 dispatches driver.Diff directly (in platform/differ.go); it does NOT call IaCProvider.Plan. The reverse is true (Plan delegates to ComputePlan in some implementations). Updated the call-chain description and the illustrative dispatch-site code block to reference the actual file (platform/differ.go) so adapter authors don't follow the wrong call chain. 2. docs/adr/009: replaced the personal email reference with "the workspace owner" so ADR provenance doesn't embed PII. 3. interfaces/iac_provider_planner_test.go: now actually verifies the additivity claim by reusing the package's existing mockProvider as the negative case — runtime assertion confirms mockProvider does NOT satisfy ProviderPlanner. Moved file to interfaces_test package to share fixtures. 4. .github/workflows/cross-plugin-build-test.yml: explicit `permissions: contents: read` (CodeQL workflow-permissions guidance); added `env: GOPRIVATE/GONOSUMCHECK` matching ci.yml + codeql.yml so downstream plugin builds resolve github.com/GoCodeAlone/* deps consistently. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.7 <[email protected]>
1 parent 8f1b907 commit 120debe

6 files changed

Lines changed: 206 additions & 0 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: cross-plugin-build-test
2+
on:
3+
pull_request:
4+
paths:
5+
# rev4 — cycle-3 minor: only run on IaC-touching PRs to save ~5min/docs-PR.
6+
# T9.2 quality-review fix: include go.mod/go.sum so transitive dep bumps
7+
# (a frequent source of downstream-plugin compile breakage) trigger the gate
8+
# even when interfaces/iac/platform/plugin code is untouched.
9+
- 'interfaces/**'
10+
- 'iac/**'
11+
- 'platform/**'
12+
- 'plugin/sdk/**'
13+
- 'go.mod'
14+
- 'go.sum'
15+
- '.github/workflows/cross-plugin-build-test.yml'
16+
17+
# T9.2 quality-review fix: cancel older in-flight runs when a PR is updated.
18+
# Standard CI hygiene; saves CI minutes on rapid push cycles.
19+
concurrency:
20+
group: cross-plugin-build-${{ github.ref }}
21+
cancel-in-progress: true
22+
23+
# R2 review: explicit minimal token permissions per CodeQL workflow-permissions
24+
# scan; this gate only needs to read the workflow + plugin checkouts.
25+
permissions:
26+
contents: read
27+
28+
# R2 review: match the repo-standard Go module env used by ci.yml + codeql.yml so
29+
# downstream plugin builds resolve github.com/GoCodeAlone/* deps consistently.
30+
env:
31+
GOPRIVATE: github.com/GoCodeAlone/*
32+
GONOSUMCHECK: github.com/GoCodeAlone/*
33+
34+
jobs:
35+
cross-plugin-build:
36+
runs-on: ubuntu-latest
37+
strategy:
38+
# T9.2 quality-review fix: matches codeql.yml + release.yml precedent.
39+
# The whole point of a cross-plugin gate is to surface ALL three plugins'
40+
# breakage in a single CI run; default fail-fast: true would cancel the
41+
# other two on first failure and force iterative re-runs to debug.
42+
fail-fast: false
43+
matrix:
44+
plugin: [workflow-plugin-aws, workflow-plugin-gcp, workflow-plugin-azure]
45+
steps:
46+
- uses: actions/checkout@v4
47+
with: { path: workflow }
48+
- uses: actions/checkout@v4
49+
with:
50+
repository: GoCodeAlone/${{ matrix.plugin }}
51+
path: ${{ matrix.plugin }}
52+
- uses: actions/setup-go@v5
53+
with: { go-version-file: workflow/go.mod }
54+
# The replace directive points the plugin's go.mod at THIS PR's checkout
55+
# of workflow (../workflow), NOT at workflow main. The gate exercises
56+
# whether the PR's interface changes break AWS/GCP/Azure compilation —
57+
# which is precisely the per-PR signal we want.
58+
- run: |
59+
cd ${{ matrix.plugin }}
60+
go mod edit -replace github.com/GoCodeAlone/workflow=../workflow
61+
go mod tidy
62+
go build ./...
63+
# NOTE: not `go test` — those plugins have their own CI; we just verify compile-compat

DOCUMENTATION.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1113,6 +1113,22 @@ modules:
11131113

11141114
---
11151115

1116+
### `ProviderPlanner` (IaC plugin interface)
1117+
1118+
Optional interface in `interfaces/iac_provider.go` for v2 IaC plugins that need custom plan logic instead of core wfctl's default `platform.ComputePlan` + `driver.Diff` dispatch. Reserved as an extension hook for Tofu/Pulumi-style adapter plugins.
1119+
1120+
```go
1121+
type ProviderPlanner interface {
1122+
PlanV2(ctx context.Context, desired []ResourceSpec, current []ResourceState) (IaCPlan, error)
1123+
}
1124+
```
1125+
1126+
Purely additive — plugins that do not implement `ProviderPlanner` remain valid `IaCProvider` implementations. Core wfctl does NOT type-assert against this interface in v0.21.0; future adapter PRs add the type-assertion at the dispatch site alongside their concrete consumer.
1127+
1128+
See [docs/iac/providerplanner.md](docs/iac/providerplanner.md) for the adapter author guide and [ADR 009](docs/adr/009-providerplanner-included-per-user-override.md) for the ratification provenance.
1129+
1130+
---
1131+
11161132
### `observability.otel`
11171133

11181134
Initializes an OpenTelemetry distributed tracing provider that exports spans via OTLP/HTTP to a collector. Sets the global OTel tracer provider so all instrumented code in the process is covered.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# ADR 009: ProviderPlanner Interface Included Per User Override
2+
3+
## Status
4+
Accepted
5+
6+
## Context
7+
8+
Cycle-3 → cycle-7 adversarial reviews of the implementation plan oscillated between "drop entirely" (YAGNI) and "ship as definition-only" (preserve extension hook); rev6 surfaced the ratification ask to the user via plan § Open Questions.
9+
10+
The user explicitly ratified Option C ("Override — W-9 expands to include the `ProviderPlanner` interface definition") on 2026-05-03 (the workspace owner, direct chat reply). User reasoning paraphrased: the design pass mandate "don't defer any fixes" + "build these fixes the right way" applies to the extension hook as well; the workspace roadmap includes future Tofu/Pulumi adapter work and the cost of shipping the interface now is bounded (~30 min plan revision + ~1 hour implementation).
11+
12+
**Provenance:** Decided by the workspace owner 2026-05-03 via direct chat reply ("option C"). Recorded in plan § Open Questions § "ProviderPlanner deferral".
13+
14+
## Decision
15+
16+
The optional `ProviderPlanner` interface ships as part of W-9's T9.1 task (`interfaces/iac_provider.go`). The interface is purely additive (plugins that don't implement it remain valid `IaCProvider` implementations). Core wfctl's `platform.ComputePlan` + `wfctlhelpers.ApplyPlan` do NOT type-assert against `ProviderPlanner` in v0.21.0 — the type-assertion at the dispatch site is reserved for future adapter PRs (Tofu/Pulumi-style) which will add it alongside their concrete consumer + design discussion.
17+
18+
## Consequences
19+
20+
**Positive:** Extension hook is in the public API surface from v0.21.0; future adapter PRs do not need to make the interface change a separate prerequisite PR. Type-assertion pattern is documented in the interface comment for adapter authors. Cross-plugin-build CI gate (T9.2 here) verifies the interface addition does not break AWS/GCP/Azure compile compatibility.
21+
22+
**Negative:** A speculative interface ships without a concrete in-tree consumer. If the future Tofu/Pulumi adapter design surfaces a different shape (different signature, different doc semantics), this interface will need a backwards-incompatible revision (or a sibling interface like `ProviderPlannerV2`). The user accepted this risk via the ratification.
23+
24+
**Operational notes:** the interface's `PlanV2` signature mirrors `platform.ComputePlan` (ctx + desired + current → IaCPlan, error) so the future adapter can type-assert and call cleanly. No tests beyond T9.1's compile-time type-assertion test ship in this plan series; the first concrete consumer ships its own integration tests.

docs/iac/providerplanner.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# ProviderPlanner adapter author guide
2+
3+
`ProviderPlanner` is an optional v2 interface declared in [`interfaces/iac_provider.go`](../../interfaces/iac_provider.go). It lets a plugin replace core wfctl's default plan logic (`platform.ComputePlan` + `driver.Diff` dispatch) with its own plan computation — typically because the plugin wraps a foreign engine (Terraform/OpenTofu, Pulumi) that already produces a plan from external state and needs to surface that plan back to wfctl rather than rebuilding it from scratch.
4+
5+
This guide explains what the interface is, when to implement it, how core wfctl handles plugins that do not implement it, and the type-assertion pattern reserved for future adapter PRs at the dispatch site.
6+
7+
## What `ProviderPlanner` is
8+
9+
```go
10+
type ProviderPlanner interface {
11+
PlanV2(ctx context.Context, desired []ResourceSpec, current []ResourceState) (IaCPlan, error)
12+
}
13+
```
14+
15+
`PlanV2` mirrors the signature of `platform.ComputePlan` so that an adapter implementation can be invoked at the dispatch site as a drop-in replacement. The signature returns an `IaCPlan` value (not a pointer) — distinct from the existing `IaCProvider.Plan` method which returns `*IaCPlan`. The value-return is intentional: it underlines that `PlanV2` is a pure computation, not a method that retains plan state on the provider.
16+
17+
The interface is **purely additive**. A plugin that does not implement `ProviderPlanner` is still a valid `IaCProvider` and continues to work without changes.
18+
19+
## When to implement
20+
21+
Implement `ProviderPlanner` when your plugin needs custom plan logic that core wfctl's default Diff dispatch cannot express. Typical cases:
22+
23+
- **Tofu/Terraform-style adapters** that derive desired-vs-current state from a `.tfstate` file the plugin manages itself, rather than from wfctl's `[]ResourceState` snapshot.
24+
- **Pulumi-style adapters** that delegate plan computation to the Pulumi engine and need to translate its preview output into an `IaCPlan` directly.
25+
- **Composite providers** where a single resource type fans out to multiple sub-resources whose plan ordering must be coordinated across drivers.
26+
27+
If your plugin's resources can be planned independently per driver and the per-driver Diff output is sufficient, do **not** implement `ProviderPlanner` — the default dispatch already does the right thing.
28+
29+
## How core wfctl handles plugins that do not implement it
30+
31+
In v0.21.0, core wfctl's `platform.ComputePlan` (in [`platform/differ.go`](../../platform/differ.go)) dispatches `driver.Diff` directly per resource — it resolves a `ResourceDriver` for each spec via `p.ResourceDriver(spec.Type)`, calls `driver.Diff(ctx, spec, currentOut)` for every modification candidate, and assembles the per-driver output into an `IaCPlan`. (Note: `IaCProvider.Plan`, when implemented, typically delegates back to `platform.ComputePlan` — but `ComputePlan` itself does not call `provider.Plan`.) There is no type-assertion against `ProviderPlanner` at the dispatch site — meaning even a plugin that does implement `ProviderPlanner` will not have its `PlanV2` method invoked by core code in v0.21.0.
32+
33+
The `ProviderPlanner` interface is reserved as a forward-compatible extension hook. Adapter PRs that wish to use it will add the type-assertion at the dispatch site as part of their own design discussion.
34+
35+
## Type-assertion pattern at the dispatch site
36+
37+
The future adapter PR is expected to wire `ProviderPlanner` into `platform.ComputePlan` with a pattern of the following shape:
38+
39+
```go
40+
// Illustrative pattern for the dispatch site in platform/differ.go's
41+
// ComputePlan (not yet wired in v0.21.0).
42+
if planner, ok := provider.(interfaces.ProviderPlanner); ok {
43+
return planner.PlanV2(ctx, desired, current)
44+
}
45+
// Fall through to the existing per-driver Diff dispatch in ComputePlan.
46+
```
47+
48+
The pattern is the standard Go optional-interface idiom: type-assert against the optional interface, fall through to the default if the provider does not implement it. The same optional-interface pattern is already used by `ProviderIDValidator` in [`interfaces/iac_resource_driver.go`](../../interfaces/iac_resource_driver.go) — type-assert against the optional interface, fall through to a default if the implementer does not satisfy it.
49+
50+
Adapter authors wiring `PlanV2` into the dispatch site should also note the pointer/value asymmetry: `IaCProvider.Plan` returns `*IaCPlan` while `PlanV2` returns `IaCPlan` by value, so the wrapper at the dispatch site converts as needed (e.g. `p := planner.PlanV2(...); return &p, nil`).
51+
52+
## Provenance
53+
54+
This interface ships per [ADR 009](../adr/009-providerplanner-included-per-user-override.md), which records the user's Option-C ratification on 2026-05-03 of the decision to ship the extension hook in W-9 alongside the cross-plugin build CI gate, rather than deferring it to the first concrete adapter PR.

interfaces/iac_provider.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,20 @@ type IaCProvider interface {
4949
Close() error
5050
}
5151

52+
// ProviderPlanner is an optional interface for v2 plugins that need custom
53+
// plan logic (replacing platform.ComputePlan's default driver.Diff dispatch).
54+
//
55+
// Reserved as an extension hook for Tofu/Pulumi-style adapter plugins. Core
56+
// wfctl's platform.ComputePlan + wfctlhelpers.ApplyPlan do NOT type-assert
57+
// against this interface in v0.21.0 — adapter PRs that wish to use it will
58+
// add the type-assertion at the dispatch site in their own design discussion.
59+
//
60+
// Plugins implementing this interface are accepted by the loader; the
61+
// implementation is not yet exercised by core code.
62+
type ProviderPlanner interface {
63+
PlanV2(ctx context.Context, desired []ResourceSpec, current []ResourceState) (IaCPlan, error)
64+
}
65+
5266
// BootstrapResult contains metadata returned by a successful BootstrapStateBackend call.
5367
type BootstrapResult struct {
5468
// Bucket is the name of the created or confirmed state bucket/container.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package interfaces_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/GoCodeAlone/workflow/interfaces"
8+
)
9+
10+
// TestProviderPlanner_TypeAssertionCompiles verifies the optional-interface
11+
// pattern: (1) a type CAN implement ProviderPlanner, and (2) a type that
12+
// implements IaCProvider is NOT required to implement ProviderPlanner —
13+
// the additivity claim ADR 009 makes about the v0.21.0 ship is enforced
14+
// here at the type system level. The negative case reuses the package's
15+
// existing mockProvider fixture (defined in iac_test.go), which intentionally
16+
// does not implement PlanV2.
17+
func TestProviderPlanner_TypeAssertionCompiles(t *testing.T) {
18+
// (1) plannerStub implements ProviderPlanner.
19+
var _ interfaces.ProviderPlanner = (*plannerStub)(nil)
20+
21+
// (2) mockProvider implements IaCProvider but NOT ProviderPlanner.
22+
// If a future change accidentally moved PlanV2 onto IaCProvider (or
23+
// made ProviderPlanner a required embedded interface), this runtime
24+
// assertion would fail.
25+
var p interfaces.IaCProvider = (*mockProvider)(nil)
26+
if _, ok := p.(interfaces.ProviderPlanner); ok {
27+
t.Errorf("mockProvider should not satisfy ProviderPlanner; the optional-interface idiom requires the negative case to assert false")
28+
}
29+
}
30+
31+
type plannerStub struct{}
32+
33+
func (p *plannerStub) PlanV2(ctx context.Context, desired []interfaces.ResourceSpec, current []interfaces.ResourceState) (interfaces.IaCPlan, error) {
34+
return interfaces.IaCPlan{}, nil
35+
}

0 commit comments

Comments
 (0)