Skip to content

feat: workflow#640 Phase 2 — v2 hooks-over-gRPC contract (ApplyResult.Actions + ActionStatus + engine populate + decoder)#694

Merged
intel352 merged 8 commits into
mainfrom
feat/v2-phase2-grpc-contract
May 16, 2026
Merged

feat: workflow#640 Phase 2 — v2 hooks-over-gRPC contract (ApplyResult.Actions + ActionStatus + engine populate + decoder)#694
intel352 merged 8 commits into
mainfrom
feat/v2-phase2-grpc-contract

Conversation

@intel352
Copy link
Copy Markdown
Contributor

Summary

PR 1 of 5 in the workflow#640 Phase 2 hard-cutover cascade per ADR 0024 + 0040. Extends the IaC plugin gRPC contract with per-action outcome evidence so wfctl-side v2 hooks fire correctly. After this PR ships + tags v0.54.0, the 4 IaC plugin PRs (aws/gcp/azure/digitalocean each v1.2.0) cascade in to declare ComputePlanVersion="v2".

Changes (6 commits)

  1. 515374c2 proto extendApplyResult.actions field at tag 7 + ActionStatus enum (UNSPECIFIED=0/SUCCESS=1/ERROR=2/DELETE_FAILED=3); regenerated iac.pb.go in same atomic commit.
  2. b09bced1 proto guardreserved 4, 5; directive on ActionStatus (compile-time guard against Phase 2.3 tag reuse) + broadened round-trip subcases.
  3. 6441c6cd Go typesinterfaces.ActionStatus + ActionOutcome struct; extend ApplyResult.Actions.
  4. b57c5a90 decodercmd/wfctl/iac_typed_adapter.go::applyResultFromPB decodes pb.ActionResultinterfaces.ActionOutcome; rejects UNSPECIFIED explicitly.
  5. 92f73de9 decoder fail-closed — unknown ActionStatus wire values error loudly (operator message: "wfctl version too old for this plugin?") per ADR 0040 invariant 2 (no graceful fallback).
  6. 3e79c9d6 engine populateiac/wfctlhelpers/apply.go::applyPlanWithEnvProviderAndHooks populates result.Actions via deferred-closure pattern (covers ALL 7 dispatch-loop exit paths: ctx-cancel, jit-err, driver-resolve-err, hook-dispatch-err, dispatch-other-err, post-delete-hook-err, post-apply-hook-err, success) + post-loop length-validation invariant assert.

ADR alignment

  • ADR 0024 (no compat shim, no graceful proto fallback): both wire-rejection paths enforce strict cutover — UNSPECIFIED-on-send + unknown-tag-on-receive both error loudly.
  • ADR 0040 invariants 1+2 (per-action evidence + failed-delete preservation): satisfied via ActionOutcome.Status + DELETE_FAILED distinct tag.

Test plan

  • GOWORK=off go test ./plugin/external/proto/... -v — proto round-trip + reserved guard
  • GOWORK=off go test ./interfaces/... — type + JSON round-trip
  • GOWORK=off go test ./cmd/wfctl/ -run 'TestApplyResultFromPB|TestMapPB' -v — 6/6 PASS
  • GOWORK=off go test ./iac/wfctlhelpers/ -race — full package PASS race-clean

Cascade context

After merge → tag v0.54.0 → unblocks 4 plugin PRs (each pins workflow v0.54.0 + bumps minEngineVersion + declares ComputePlanVersion="v2" in CapabilitiesResponse).

Rollback

If a downstream plugin reveals a contract gap after Phase 2 cascade lands: cut v0.54.1 reverting whichever commit broke (or full revert if proto change is the issue). Per ADR 0040 matched-pair rollback.

Plan: docs/plans/2026-05-16-v2-lifecycle-phase2.md (locked sha256 46b795ec).

🤖 Generated with Claude Code

intel352 and others added 6 commits May 16, 2026 11:02
Phase 2 v2 action lifecycle (workflow#640): add `repeated ActionResult
actions = 7` to ApplyResult plus the ActionResult message and
ActionStatus enum (UNSPECIFIED=0, SUCCESS=1, ERROR=2, DELETE_FAILED=3;
tags 4-5 RESERVED for Phase 2.3 compensation). Per ADR 0040 invariants
1-2. Regenerated iac.pb.go in same commit to keep proto + bindings atomic
(per cycle-1 plan-review I-1: split-commits create a broken intermediate
that fails CI). Tests: proto marshal/unmarshal round trip + enum
tag pinning.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Compiler-enforce ActionStatus tag-reuse prevention: replace comment-only
reservation with `reserved 4, 5;` directive. Emits no Go symbol (no
phantom literal — ADR 0024/0040 forbid that, not this) but prevents
Phase 2.3 from accidentally reusing the tags. Regenerate iac.pb.go.

Broaden TestApplyResultActionsRoundTrip to four subcases: nil_actions,
empty_actions, unspecified_status, mixed_statuses. Use proto.Equal for
canonical comparison so future ActionResult fields are covered without
test edits. UNSPECIFIED subcase ensures the wire layer encodes/decodes
losslessly so T3's reject path receives the expected zero-tag.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…t.Actions

Phase 2 v2 action lifecycle (workflow#640): introduce Go-side mirrors of
pb.ActionStatus + pb.ActionResult — ActionStatus (uint8 enum with four
constants Unspecified/Success/Error/DeleteFailed matching wire tags
0/1/2/3) and ActionOutcome struct {ActionIndex, Status, Error}. Extend
ApplyResult with `Actions []ActionOutcome` (omitempty, so plugins on the
v1 capability shim that emit no actions stay clean in persisted JSON).

Per ADR 0040 invariants 1-2. Tags 4-5 reserved in the proto for
Phase 2.3 compensation; intentionally not declared on the Go side yet
to keep this surface minimal until that work lands.

Tests cover: zero-value-is-Unspecified, constant tags 0-3, struct
field assignment, Actions JSON round-trip, omitempty when nil.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 2 v2 action lifecycle (workflow#640): extend applyResultFromPB to
translate pb.ActionResult entries into interfaces.ActionOutcome, populating
the new ApplyResult.Actions field added in T2. Reject
ACTION_STATUS_UNSPECIFIED at the decode boundary so a plugin that forgets
to populate a status never reaches v2 hook dispatch — wfctl returns an
explicit error naming the offending action_index. Per ADR 0040 invariant
2 (strict cutover, no graceful fallback).

Added mapPBActionStatusToInterface helper covering SUCCESS / ERROR /
DELETE_FAILED. The default branch returns ActionStatusUnspecified
defensively; in practice UNSPECIFIED is filtered upstream by the reject
loop and the default is unreachable. No length validation here — that's
engine-side per T4.

TDD tests:
- TestApplyResultFromPB_DecodesActions: 3 statuses mapped + Error
  pass-through
- TestApplyResultFromPB_RejectsUNSPECIFIED: error mentions UNSPECIFIED
  + offending action_index
- TestApplyResultFromPB_EmptyActionsRoundTrip: plugins on v1 capability
  shim (no Actions) decode cleanly with empty slice

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Code-reviewer Important on T3 b57c5a9: the original reject loop only
caught ACTION_STATUS_UNSPECIFIED. A Phase 2.3+ plugin emitting a
reserved tag (4 or 5 for COMPENSATED / COMPENSATION_FAILED) against
an older wfctl would silently degrade to ActionStatusUnspecified — the
"graceful fallback" ADR 0040 invariant 2 forbids. proto3 preserves
unknown enum integer values as-is, so the `reserved 4, 5;` directive
added in T1 only prevents tag-reuse at proto compile time; it does NOT
block wire-level drift between newer plugins and older wfctl.

Change mapPBActionStatusToInterface signature to `(ActionStatus, bool)`,
returning ok=false on any unknown wire value. applyResultFromPB converts
the `!ok` signal into an explicit error naming the wire integer and
offending action_index. UNSPECIFIED is now explicitly in the known set
(ok=true) — the UNSPECIFIED-sent rejection lives at the higher policy
level in the decode loop, not in the mapper.

Minor: pin TestApplyResultFromPB_EmptyActionsRoundTrip on non-nil empty
slice (matches sibling Resources/Errors convention). Add direct unit
tests of the helper covering 4 known + 1 unknown wire value. Update
mapper godoc to call out wire-drift defense as primary purpose.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 2 v2 action lifecycle (workflow#640): the engine-side dispatch
loop now records an ActionOutcome for every plan action, with a
post-loop length-validation assert guaranteeing 1-to-1 correspondence
between plan.Actions and result.Actions on the best-effort
continue-on-error path. Per ADR 0040 invariant 1.

Implementation: deferred-closure pattern (cycle-1 plan-review C-1).
Each iteration runs the dispatch logic inside an inline func() whose
defer unconditionally appends the ActionOutcome, derived from iterErr
via the new mapDispatchErrToStatus helper. Two error sentinels carry
the iteration's state:
  - iterErr — "this action failed but the loop continues" (best-effort)
  - fatalErr — hook / ctx-cancellation error that aborts the whole apply

Continue paths (jit-substitution error, driver-resolve error, dispatch
error other than hookDispatchError) set iterErr only — the deferred
append fires with the appropriate status (Error / DeleteFailed) and the
loop proceeds. Early-return paths (ctx cancellation, hookDispatchError,
post-delete hook, post-apply hook) set fatalErr — the deferred append
still fires for the offending action, then the outer for loop bubbles
fatalErr to the caller (skipping the post-loop assert; length-on-fatal
is correctly < len(plan.Actions)).

mapDispatchErrToStatus: nil → Success; delete-action err → DeleteFailed;
all other errs → Error. Compensation paths (Phase 2.3) reserved.

TDD tests (use real fakeProvider single-driver API from apply_test.go):
- TestApplyPlanWithHooks_PopulatesActions_CleanSuccess (2-action plan,
  both Success, ActionIndex 0/1)
- TestApplyPlanWithHooks_PopulatesActions_PreDispatchDriverError (the
  CRITICAL C-1 invariant test — driver-resolve error MUST still
  produce ActionOutcome so length-assert doesn't false-fire)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 16, 2026 16:28
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Extends the IaC plugin gRPC contract with per-action outcome surfacing (ApplyResult.Actions + ActionStatus enum + ActionResult message) so wfctl can dispatch v2 lifecycle hooks correctly. Adds the Go-side mirror types in interfaces, an engine-side populator in iac/wfctlhelpers/apply.go using a deferred-closure pattern to guarantee every dispatch-loop exit path appends an ActionOutcome, and a fail-closed decoder in cmd/wfctl/iac_typed_adapter.go that rejects UNSPECIFIED and unknown wire tags per ADR 0040.

Changes:

  • Proto: new ActionStatus enum (tags 0-3, with 4-5 reserved for Phase 2.3 compensation) and ActionResult message; ApplyResult.actions at tag 7.
  • Engine: applyPlanWithEnvProviderAndHooks wraps per-iteration work in an inner func so a deferred closure unconditionally records ActionOutcome on all 7 exit paths, with a post-loop length invariant check.
  • Decoder: applyResultFromPB decodes actions, rejects UNSPECIFIED, and uses mapPBActionStatusToInterface to fail loudly on unknown wire values.

Reviewed changes

Copilot reviewed 8 out of 9 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
plugin/external/proto/iac.proto Adds ActionStatus enum (with reserved 4-5) and ActionResult message; new actions field on ApplyResult.
plugin/external/proto/iac.pb.go Regenerated proto bindings reflecting the new enum/message and index shifts.
plugin/external/proto/iac_proto_test.go Round-trip + enum-tag pin tests for the new fields.
interfaces/iac_state.go Go mirror types ActionStatus, ActionOutcome, and ApplyResult.Actions.
interfaces/iac_state_test.go Zero-value, constant-value, and JSON round-trip/omitempty tests.
iac/wfctlhelpers/apply.go Deferred-closure pattern to populate result.Actions on every dispatch-loop exit path; post-loop invariant assert; mapDispatchErrToStatus helper.
iac/wfctlhelpers/apply_hooks_test.go Tests covering clean-success and pre-dispatch driver-error population paths.
cmd/wfctl/iac_typed_adapter.go applyResultFromPB decodes Actions, rejecting UNSPECIFIED and unknown wire values; adds mapPBActionStatusToInterface.
cmd/wfctl/iac_typed_adapter_test.go Decoder tests for success, UNSPECIFIED-rejection, empty-actions, unknown-status, and mapper helper.

Comment thread iac/wfctlhelpers/apply.go
Comment on lines +296 to +313
if action.Action == "delete" {
if err := actionHooks.OnResourceDeleted(ctx, action); err != nil {
fatalErr = fmt.Errorf("%s/%s: post-delete hook: %w", action.Resource.Type, action.Resource.Name, err)
iterErr = err
return
}
}
if len(result.Resources) > preLen {
out := result.Resources[len(result.Resources)-1]
out = fillMissingOutputIdentity(action.Resource, out)
result.Resources[len(result.Resources)-1] = out
if hooks.OnResourceApplied != nil {
if err := hooks.OnResourceApplied(ctx, d, action, out); err != nil {
fatalErr = fmt.Errorf("%s/%s: post-apply hook: %w", action.Resource.Type, action.Resource.Name, err)
iterErr = err
return
}
}
Comment on lines +1250 to +1263
func mapPBActionStatusToInterface(s pb.ActionStatus) (interfaces.ActionStatus, bool) {
switch s {
case pb.ActionStatus_ACTION_STATUS_UNSPECIFIED:
return interfaces.ActionStatusUnspecified, true
case pb.ActionStatus_ACTION_STATUS_SUCCESS:
return interfaces.ActionStatusSuccess, true
case pb.ActionStatus_ACTION_STATUS_ERROR:
return interfaces.ActionStatusError, true
case pb.ActionStatus_ACTION_STATUS_DELETE_FAILED:
return interfaces.ActionStatusDeleteFailed, true
default:
return interfaces.ActionStatusUnspecified, false
}
}
@codecov
Copy link
Copy Markdown

codecov Bot commented May 16, 2026

Codecov Report

❌ Patch coverage is 90.47619% with 10 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
iac/wfctlhelpers/apply.go 87.80% 7 Missing and 3 partials ⚠️

📢 Thoughts on this report? Let us know!

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 16, 2026

⏱ Benchmark Results

No significant performance regressions detected.

benchstat comparison (baseline → PR)
## benchstat: baseline → PR
baseline-bench.txt:276: parsing iteration count: invalid syntax
baseline-bench.txt:343303: parsing iteration count: invalid syntax
baseline-bench.txt:629291: parsing iteration count: invalid syntax
baseline-bench.txt:947811: parsing iteration count: invalid syntax
baseline-bench.txt:1223284: parsing iteration count: invalid syntax
baseline-bench.txt:1511846: parsing iteration count: invalid syntax
benchmark-results.txt:276: parsing iteration count: invalid syntax
benchmark-results.txt:350968: parsing iteration count: invalid syntax
benchmark-results.txt:911504: parsing iteration count: invalid syntax
benchmark-results.txt:1196427: parsing iteration count: invalid syntax
benchmark-results.txt:1810396: parsing iteration count: invalid syntax
benchmark-results.txt:2131148: 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               7.451m ± 58%
ComponentLoad-4                     3.550m ± 10%
ComponentExecute-4                  1.914µ ±  2%
PoolContention/workers-1-4          1.090µ ±  3%
PoolContention/workers-2-4          1.081µ ±  2%
PoolContention/workers-4-4          1.070µ ±  1%
PoolContention/workers-8-4          1.073µ ±  1%
PoolContention/workers-16-4         1.078µ ±  1%
ComponentLifecycle-4                3.569m ±  0%
SourceValidation-4                  2.303µ ±  1%
RegistryConcurrent-4                802.0n ±  3%
LoaderLoadFromString-4              3.594m ±  1%
geomean                             18.63µ

                            │ 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: Intel(R) Xeon(R) Platinum 8370C CPU @ 2.80GHz
                            │ benchmark-results.txt │
                            │        sec/op         │
InterpreterCreation-4                  8.130m ± 62%
ComponentLoad-4                        3.503m ±  1%
ComponentExecute-4                     1.864µ ±  1%
PoolContention/workers-1-4             1.187µ ±  1%
PoolContention/workers-2-4             1.186µ ±  1%
PoolContention/workers-4-4             1.186µ ±  1%
PoolContention/workers-8-4             1.189µ ±  1%
PoolContention/workers-16-4            1.193µ ±  4%
ComponentLifecycle-4                   3.544m ±  1%
SourceValidation-4                     2.292µ ±  1%
RegistryConcurrent-4                   953.8n ±  3%
LoaderLoadFromString-4                 3.540m ±  1%
geomean                                19.71µ

                            │ 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                  285.2n ± 5%
CircuitBreakerExecution_Success-4          21.54n ± 4%
CircuitBreakerExecution_Failure-4          66.12n ± 0%
geomean                                    74.06n

                                  │ 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: Intel(R) Xeon(R) Platinum 8370C CPU @ 2.80GHz
                                  │ benchmark-results.txt │
                                  │        sec/op         │
CircuitBreakerDetection-4                     451.2n ± 0%
CircuitBreakerExecution_Success-4             59.77n ± 0%
CircuitBreakerExecution_Failure-4             65.50n ± 0%
geomean                                       120.9n

                                  │ 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       │
IaCStateBackend_InProcess-4              321.8n ± 25%
IaCStateBackend_GRPC-4                   9.367m ±  5%
JQTransform_Simple-4                     645.4n ± 63%
JQTransform_ObjectConstruction-4         1.669µ ±  0%
JQTransform_ArraySelect-4                3.566µ ±  0%
JQTransform_Complex-4                    39.60µ ±  0%
JQTransform_Throughput-4                 2.006µ ±  1%
SSEPublishDelivery-4                     64.98n ±  0%
geomean                                  3.954µ

                                 │ baseline-bench.txt │
                                 │        B/op        │
IaCStateBackend_InProcess-4             416.0 ±  0%
IaCStateBackend_GRPC-4                5.889Mi ± 10%
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      │
IaCStateBackend_InProcess-4              2.000 ± 0%
IaCStateBackend_GRPC-4                  6.836k ± 0%
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: Intel(R) Xeon(R) Platinum 8370C CPU @ 2.80GHz
                                 │ benchmark-results.txt │
                                 │        sec/op         │
IaCStateBackend_InProcess-4                 342.4n ± 29%
IaCStateBackend_GRPC-4                      9.795m ±  2%
JQTransform_Simple-4                        697.3n ± 34%
JQTransform_ObjectConstruction-4            1.528µ ±  0%
JQTransform_ArraySelect-4                   3.261µ ±  1%
JQTransform_Complex-4                       36.01µ ±  1%
JQTransform_Throughput-4                    1.849µ ±  1%
SSEPublishDelivery-4                        75.69n ±  1%
geomean                                     3.945µ

                                 │ benchmark-results.txt │
                                 │         B/op          │
IaCStateBackend_InProcess-4                416.0 ±  0%
IaCStateBackend_GRPC-4                   5.686Mi ± 13%
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       │
IaCStateBackend_InProcess-4                 2.000 ± 0%
IaCStateBackend_GRPC-4                     6.877k ± 0%
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.099µ ± 4%
SchemaValidation_AllFields-4                 1.646µ ± 2%
SchemaValidation_FormatValidation-4          1.573µ ± 1%
SchemaValidation_ManySchemas-4               1.821µ ± 2%
geomean                                      1.509µ

                                    │ 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: Intel(R) Xeon(R) Platinum 8370C CPU @ 2.80GHz
                                    │ benchmark-results.txt │
                                    │        sec/op         │
SchemaValidation_Simple-4                      1.048µ ± 30%
SchemaValidation_AllFields-4                   1.532µ ±  2%
SchemaValidation_FormatValidation-4            1.499µ ±  1%
SchemaValidation_ManySchemas-4                 1.519µ ±  3%
geomean                                        1.383µ

                                    │ 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.190µ ± 12%
EventStoreAppend_SQLite-4                  1.344m ±  6%
GetTimeline_InMemory/events-10-4           13.32µ ±  5%
GetTimeline_InMemory/events-50-4           75.53µ ±  2%
GetTimeline_InMemory/events-100-4          121.5µ ± 20%
GetTimeline_InMemory/events-500-4          626.9µ ±  3%
GetTimeline_InMemory/events-1000-4         1.278m ±  1%
GetTimeline_SQLite/events-10-4             105.9µ ±  1%
GetTimeline_SQLite/events-50-4             245.1µ ±  2%
GetTimeline_SQLite/events-100-4            414.8µ ±  0%
GetTimeline_SQLite/events-500-4            1.770m ±  0%
GetTimeline_SQLite/events-1000-4           3.446m ±  0%
geomean                                    215.9µ

                                   │ baseline-bench.txt │
                                   │        B/op        │
EventStoreAppend_InMemory-4                 747.0 ± 12%
EventStoreAppend_SQLite-4                 1.986Ki ±  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.04Ki

                                   │ 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: Intel(R) Xeon(R) Platinum 8370C CPU @ 2.80GHz
                                   │ benchmark-results.txt │
                                   │        sec/op         │
EventStoreAppend_InMemory-4                   1.132µ ±  4%
EventStoreAppend_SQLite-4                     933.7µ ±  4%
GetTimeline_InMemory/events-10-4              13.43µ ±  3%
GetTimeline_InMemory/events-50-4              74.13µ ±  2%
GetTimeline_InMemory/events-100-4             150.6µ ±  3%
GetTimeline_InMemory/events-500-4             731.6µ ±  8%
GetTimeline_InMemory/events-1000-4            1.335m ± 14%
GetTimeline_SQLite/events-10-4                80.99µ ±  2%
GetTimeline_SQLite/events-50-4                230.2µ ±  0%
GetTimeline_SQLite/events-100-4               419.5µ ±  2%
GetTimeline_SQLite/events-500-4               1.899m ±  1%
GetTimeline_SQLite/events-1000-4              3.722m ±  1%
geomean                                       212.6µ

                                   │ benchmark-results.txt │
                                   │         B/op          │
EventStoreAppend_InMemory-4                     817.5 ± 9%
EventStoreAppend_SQLite-4                     1.988Ki ± 1%
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.55Ki

                                   │ 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.

Two PR #694 review fixes:

1. **G115 lint BLOCKER** (iac/wfctlhelpers/apply.go:218): the
   `ActionIndex: uint32(i)` cast on the loop counter trips gosec G115
   (signed→unsigned overflow). In practice plan.Actions length is
   bounded by the user's plan size, never near math.MaxUint32, so the
   warning is a false positive. Add a targeted //nolint:gosec with a
   short justification rather than a defensive bounds-check that would
   add an unreachable branch.

2. **mapPBActionStatusToInterface fail-closed on UNSPECIFIED**
   (cmd/wfctl/iac_typed_adapter.go:1263): the previous shape returned
   (Unspecified, true) for ACTION_STATUS_UNSPECIFIED on the theory
   that applyResultFromPB filters UNSPECIFIED upstream. Copilot
   correctly flagged this as inconsistent with the documented
   strict-cutover invariant — the mapper itself should be fail-closed
   so its contract doesn't depend on caller-side pre-filtering. Now
   returns (Unspecified, false) for UNSPECIFIED, same as unknown wire
   values. Production decode path is unchanged because the explicit
   UNSPECIFIED-check at the top of the reject loop catches it first
   with a more specific error message; the mapper change is a defense-
   in-depth invariant.

Test split: TestMapPBActionStatusToInterface_KnownValues renamed to
_ActionableValues (now covers SUCCESS/ERROR/DELETE_FAILED only); new
_UnspecifiedFailsClosed asserts (Unspecified, false) at the mapper
level.

Lint + tests verified locally:
- GOWORK=off go test ./iac/wfctlhelpers/ ./cmd/wfctl/ -race → PASS
- GOWORK=off golangci-lint run ./iac/wfctlhelpers/... ./cmd/wfctl/...
  → 0 issues

Copilot's third finding (apply.go:313 post-hook-fail-vs-dispatch-fail
conflation) is being deferred to Phase 2.3 — tags 4+5 reserved in
proto since b09bced for ACTION_STATUS_COMPENSATED /
COMPENSATION_FAILED to distinguish those cases. PR comment with the
deferral reasoning follows separately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@intel352
Copy link
Copy Markdown
Contributor Author

Re Copilot's flag at iac/wfctlhelpers/apply.go:313 (post-hook-fail-vs-dispatch-fail conflation):

Acknowledged — agreed this is a real semantic gap against ADR 0040 invariant 2. A post-delete hook failure today labels the action DeleteFailed even when the cloud delete itself succeeded; ditto post-apply hook failure currently maps to Error even though the resource was actually created. wfctl-side resume/replay logic that keys off ActionStatus will mis-categorize these cases.

Deferred to Phase 2.3 (workflow#640 follow-up) which introduces:

  • ACTION_STATUS_COMPENSATED — post-dispatch hook failed; dispatch outcome was rolled back (compensation succeeded)
  • ACTION_STATUS_COMPENSATION_FAILED — post-dispatch hook failed AND compensation also failed (stuck mid-state)

Both tags already reserved in plugin/external/proto/iac.proto at tags 4 + 5 via the reserved 4, 5; directive added in T1 commit b09bced specifically for this distinction.

A separate issue will be filed at Phase 2.3 design time to track:

  1. Engine-side compensation-protocol design (rollback on post-hook failure)
  2. mapDispatchErrToStatus extension distinguishing dispatch-err vs post-hook-err
  3. wfctl-side resume semantics for COMPENSATED / COMPENSATION_FAILED

Not blocking this PR.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 8 out of 9 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (2)

iac/wfctlhelpers/apply.go:354

  • mapDispatchErrToStatus keys the DeleteFailed bucket off actionType == "delete", but it is also invoked for hook errors raised inside dispatchAction (e.g., hookDispatchError at line 285) and for post-delete / post-apply hook failures (lines 299, 310). For a delete action whose post-delete hook fails, the outcome is reported as ActionStatusDeleteFailed even though the cloud delete itself succeeded — wfctl-side optimistic state-removal logic that keys off DeleteFailed would then erroneously preserve a resource that the provider has actually deleted. Consider only mapping to DeleteFailed when the failure originated from the driver Delete call itself, not from hook plumbing.
func mapDispatchErrToStatus(err error, actionType string) interfaces.ActionStatus {
	if err == nil {
		return interfaces.ActionStatusSuccess
	}
	if actionType == "delete" {
		return interfaces.ActionStatusDeleteFailed
	}
	return interfaces.ActionStatusError
}

iac/wfctlhelpers/apply.go:314

  • For the hookDispatchError branch the deferred closure stores iterErr = hookErr.err (the unwrapped hook error) into ActionOutcome.Error, while the surrounding apply returns fatalErr (the type/name-prefixed wrapped error) to the caller. So the same hook failure surfaces with two different messages: the bubbled error has the "<type>/<name>: ..." prefix, but the in-result ActionOutcome.Error does not. Same divergence happens at lines 299-300 and 310-311 (post-delete / post-apply hook). Consider storing the wrapped error consistently so persisted apply-state JSON and the caller-visible error agree.
			if err := dispatchAction(ctx, d, action, result, actionHooks, deleteHookActive); err != nil {
				var hookErr hookDispatchError
				if errors.As(err, &hookErr) {
					fatalErr = fmt.Errorf("%s/%s: %w", action.Resource.Type, action.Resource.Name, hookErr.err)
					iterErr = hookErr.err
					return
				}
				result.Errors = append(result.Errors, interfaces.ActionError{
					Resource: action.Resource.Name,
					Action:   action.Action,
					Error:    err.Error(),
				})
				iterErr = err
				return
			}
			if action.Action == "delete" {
				if err := actionHooks.OnResourceDeleted(ctx, action); err != nil {
					fatalErr = fmt.Errorf("%s/%s: post-delete hook: %w", action.Resource.Type, action.Resource.Name, err)
					iterErr = err
					return
				}
			}
			if len(result.Resources) > preLen {
				out := result.Resources[len(result.Resources)-1]
				out = fillMissingOutputIdentity(action.Resource, out)
				result.Resources[len(result.Resources)-1] = out
				if hooks.OnResourceApplied != nil {
					if err := hooks.OnResourceApplied(ctx, d, action, out); err != nil {
						fatalErr = fmt.Errorf("%s/%s: post-apply hook: %w", action.Resource.Type, action.Resource.Name, err)
						iterErr = err
						return
					}
				}

Comment thread iac/wfctlhelpers/apply.go
Comment on lines +231 to 235
if err := ctx.Err(); err != nil {
iterErr = err
fatalErr = err
return
}
Comment on lines +1215 to +1229
actions := make([]interfaces.ActionOutcome, 0, len(r.GetActions()))
for _, a := range r.GetActions() {
if a.GetStatus() == pb.ActionStatus_ACTION_STATUS_UNSPECIFIED {
return nil, fmt.Errorf("plugin returned ActionResult with UNSPECIFIED status at action_index=%d (Phase 2 contract violation per ADR 0040)", a.GetActionIndex())
}
mapped, ok := mapPBActionStatusToInterface(a.GetStatus())
if !ok {
return nil, fmt.Errorf("plugin returned unknown ActionStatus=%d at action_index=%d (wfctl version too old for this plugin? Phase 2 contract violation per ADR 0040)", int32(a.GetStatus()), a.GetActionIndex())
}
actions = append(actions, interfaces.ActionOutcome{
ActionIndex: a.GetActionIndex(),
Status: mapped,
Error: a.GetError(),
})
}
Comment thread cmd/wfctl/iac_typed_adapter.go Outdated
}
mapped, ok := mapPBActionStatusToInterface(a.GetStatus())
if !ok {
return nil, fmt.Errorf("plugin returned unknown ActionStatus=%d at action_index=%d (wfctl version too old for this plugin? Phase 2 contract violation per ADR 0040)", int32(a.GetStatus()), a.GetActionIndex())
Comment thread interfaces/iac_state.go Outdated
Comment on lines +209 to +212
// wfctl. Engine populates one entry per IaCPlan.Actions index (T4) so
// the length-validation assert can pair them 1:1. Empty/nil on plugins
// using the v1 capability shim (downstream pre-v1.2.0 cascade) —
// wfctl tolerates absence and skips v2-hook dispatch in that case.
Two PR #694 r2 review fixes:

F3 — `cmd/wfctl/iac_typed_adapter.go:1222`: reword the unknown-status
error message to be neutral on which side is the older. Old copy
"wfctl version too old for this plugin?" assumes wfctl is the older;
the symmetric case (plugin emits a tag this wfctl hasn't seen because
the plugin is newer) is equally valid. New copy ends with "either
upgrade wfctl or downgrade the plugin" — actionable + neutral.

F4 — `interfaces/iac_state.go:208-214`: the prior doc-comment on
`ApplyResult.Actions` said "Empty/nil on plugins using the v1
capability shim... wfctl tolerates absence and skips v2-hook dispatch
in that case", which contradicts ADR 0024 (no compat shim) + ADR 0040
(no graceful fallback). Pre-v1.2.0 plugins are PERMANENTLY incompatible
with workflow v0.54.0+; there is no v2-hook-skip tolerance path. Doc
rewritten to state the actual contract: Actions is always populated
when the plugin declares ComputePlanVersion="v2"; non-v2 plugins are
permanently incompatible.

Local verify:
- GOWORK=off go test ./interfaces/ ./cmd/wfctl/ -count=1 → PASS
- GOWORK=off golangci-lint run ./interfaces/... ./cmd/wfctl/... → 0 issues

PR r2 findings F1 (ctx-cancel mapping) and F2 (applyResultFromPB
nil,err on reject) are being deferred — F1 to Phase 2.3 alongside the
:313 post-hook conflation (same SKIPPED/COMPENSATED enum class); F2
as low-priority ergonomic improvement. PR replies posted separately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@intel352
Copy link
Copy Markdown
Contributor Author

Re Copilot's flag at iac/wfctlhelpers/apply.go:235 (ctx-cancel mapping to Error/DeleteFailed):

Acknowledged — ctx-cancel mapping to (Error|DeleteFailed) shares the semantic gap class with the apply.go:313 post-hook conflation already flagged earlier. ctx-cancel means "action never attempted" rather than "action attempted and failed"; conflating those into the same Error/DeleteFailed bucket loses resume/replay signal.

Deferring to Phase 2.3 (workflow#640 follow-up) which scopes a broader ActionStatus extension:

  • ACTION_STATUS_SKIPPED — never attempted (ctx-cancel or upstream-dependency-skipped)
  • ACTION_STATUS_COMPENSATED — attempted, dispatch succeeded, post-hook failed, compensation rolled it back
  • ACTION_STATUS_COMPENSATION_FAILED — attempted, dispatch succeeded, post-hook failed, compensation also failed (stuck mid-state)

Tags 4 + 5 already reserved in plugin/external/proto/iac.proto (b09bced) for COMPENSATED / COMPENSATION_FAILED. SKIPPED would take tag 6 at Phase 2.3 design time.

Same tracking issue as the :313 deferral will cover this. Not blocking this PR.

@intel352
Copy link
Copy Markdown
Contributor Author

Re Copilot's flag at cmd/wfctl/iac_typed_adapter.go:1229 (applyResultFromPB returns (nil, err) on reject, dropping partial ApplyResponse state):

Acknowledged — partial-state-on-error pattern is an ergonomic improvement, not a correctness fix. Current callers (cmd/wfctl/infra.go apply path, iac/wfctlhelpers consumers) all treat an applyResultFromPB error as a fatal exit and do not consume the partial result — they propagate the error upward without inspecting the first return value.

Tracking as a Phase 2 followup (low priority). The (nil, err) signature can be safely widened to (partial *ApplyResult, err) in a backward-compat manner when downstream consumers actually need the debug snapshot (e.g., for richer error-reporting that names which resources had already-successfully decoded outputs before the offending action). No call site needs to change in that widening — they just stop ignoring the first return on the error path.

Not blocking this PR.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 8 out of 9 changed files in this pull request and generated 5 comments.

Comment thread iac/wfctlhelpers/apply.go
Comment on lines +297 to +316
if action.Action == "delete" {
if err := actionHooks.OnResourceDeleted(ctx, action); err != nil {
fatalErr = fmt.Errorf("%s/%s: post-delete hook: %w", action.Resource.Type, action.Resource.Name, err)
iterErr = err
return
}
}
if len(result.Resources) > preLen {
out := result.Resources[len(result.Resources)-1]
out = fillMissingOutputIdentity(action.Resource, out)
result.Resources[len(result.Resources)-1] = out
if hooks.OnResourceApplied != nil {
if err := hooks.OnResourceApplied(ctx, d, action, out); err != nil {
fatalErr = fmt.Errorf("%s/%s: post-apply hook: %w", action.Resource.Type, action.Resource.Name, err)
iterErr = err
return
}
}
syncedOutputs[out.Name] = flattenOutputs(out)
}
Comment thread iac/wfctlhelpers/apply.go
Comment on lines +346 to +354
func mapDispatchErrToStatus(err error, actionType string) interfaces.ActionStatus {
if err == nil {
return interfaces.ActionStatusSuccess
}
if actionType == "delete" {
return interfaces.ActionStatusDeleteFailed
}
return interfaces.ActionStatusError
}
Comment thread iac/wfctlhelpers/apply.go
Comment on lines +246 to +265
resolved, err := jitsubst.ResolveSpec(action.Resource, result.ReplaceIDMap, syncedOutputs, os.LookupEnv)
if err != nil {
result.Errors = append(result.Errors, interfaces.ActionError{
Resource: action.Resource.Name,
Action: action.Action,
Error: fmt.Sprintf("jit substitution: %v", err),
})
iterErr = fmt.Errorf("jit substitution: %v", err)
return
}
result.Errors = append(result.Errors, interfaces.ActionError{
Resource: action.Resource.Name,
Action: action.Action,
Error: err.Error(),
})
continue
}
if action.Action == "delete" {
if err := actionHooks.OnResourceDeleted(ctx, action); err != nil {
return result, fmt.Errorf("%s/%s: post-delete hook: %w", action.Resource.Type, action.Resource.Name, err)
action.Resource = resolved
d, err := p.ResourceDriver(action.Resource.Type)
if err != nil {
result.Errors = append(result.Errors, interfaces.ActionError{
Resource: action.Resource.Name,
Action: action.Action,
Error: fmt.Sprintf("resolve driver: %v", err),
})
iterErr = fmt.Errorf("resolve driver: %v", err)
return
Comment on lines +233 to +302

// TestApplyPlanWithHooks_PopulatesActions_CleanSuccess verifies the
// Phase 2 engine-side ActionOutcome population — every successful
// PlanAction gets a corresponding result.Actions entry with
// ActionStatusSuccess and ActionIndex matching loop position. Per
// workflow#640 Phase 2 + ADR 0040 invariant 1.
func TestApplyPlanWithHooks_PopulatesActions_CleanSuccess(t *testing.T) {
p := newFakeProvider()
plan := &interfaces.IaCPlan{
ID: "plan-1",
Actions: []interfaces.PlanAction{
{Action: "create", Resource: interfaces.ResourceSpec{Name: "r1", Type: "infra.test"}},
{Action: "create", Resource: interfaces.ResourceSpec{Name: "r2", Type: "infra.test"}},
},
}
result, err := ApplyPlanWithHooks(t.Context(), p, plan, ApplyPlanHooks{})
if err != nil {
t.Fatalf("top-level err: %v", err)
}
if len(result.Actions) != 2 {
t.Fatalf("expected 2 ActionOutcomes, got %d: %+v", len(result.Actions), result.Actions)
}
for i, a := range result.Actions {
if a.ActionIndex != uint32(i) {
t.Errorf("action %d: ActionIndex=%d, want %d", i, a.ActionIndex, i)
}
if a.Status != interfaces.ActionStatusSuccess {
t.Errorf("action %d: Status=%v, want Success", i, a.Status)
}
if a.Error != "" {
t.Errorf("action %d: Error=%q, want empty", i, a.Error)
}
}
}

// TestApplyPlanWithHooks_PopulatesActions_PreDispatchDriverError covers
// the CRITICAL cycle-1 plan-review C-1 invariant: every continue exit
// path (here, the driver-resolve error at apply.go:228-234) must still
// append an ActionOutcome so the post-loop length-validation assert
// never false-fires. Per ADR 0040.
func TestApplyPlanWithHooks_PopulatesActions_PreDispatchDriverError(t *testing.T) {
p := &fakeProvider{driverErr: errors.New("driver resolution failed")}
plan := &interfaces.IaCPlan{
ID: "plan-1",
Actions: []interfaces.PlanAction{
{Action: "create", Resource: interfaces.ResourceSpec{Name: "r1", Type: "unknown.resource"}},
},
}
result, err := ApplyPlanWithHooks(t.Context(), p, plan, ApplyPlanHooks{})
if err != nil {
t.Fatalf("expected no top-level err on driver-resolve failure (best-effort continue), got: %v", err)
}
if len(result.Actions) != 1 {
t.Fatalf("expected 1 ActionOutcome (length-assert invariant), got %d: %+v", len(result.Actions), result.Actions)
}
if result.Actions[0].Status != interfaces.ActionStatusError {
t.Errorf("driver-resolve-error status: want Error, got %v", result.Actions[0].Status)
}
if result.Actions[0].ActionIndex != 0 {
t.Errorf("ActionIndex: want 0, got %d", result.Actions[0].ActionIndex)
}
if result.Actions[0].Error == "" {
t.Errorf("Error: want non-empty, got empty")
}
// Cross-check: existing result.Errors path also populated so the
// pre-Phase-2 contract is preserved.
if len(result.Errors) != 1 {
t.Errorf("expected 1 result.Errors entry (legacy contract), got %d", len(result.Errors))
}
}
Comment on lines +290 to +324
func TestApplyResult_Actions_RoundTrip(t *testing.T) {
r := ApplyResult{Actions: []ActionOutcome{
{ActionIndex: 0, Status: ActionStatusSuccess},
{ActionIndex: 1, Status: ActionStatusError, Error: "boom"},
}}
data, err := json.Marshal(r)
if err != nil {
t.Fatal(err)
}
var got ApplyResult
if err := json.Unmarshal(data, &got); err != nil {
t.Fatal(err)
}
if len(got.Actions) != 2 {
t.Fatalf("Actions len: got %d, want 2", len(got.Actions))
}
if got.Actions[0].Status != ActionStatusSuccess || got.Actions[1].Error != "boom" {
t.Errorf("Actions round-trip mismatch: %+v", got.Actions)
}
}

// TestApplyResult_Actions_OmitemptyWhenNil ensures a nil Actions slice
// is absent from the JSON output, matching the omitempty convention used
// by Errors / InputDriftReport / ReplaceIDMap. Plugins on v1 capability
// shim emit no actions; the JSON must not carry an empty array.
func TestApplyResult_Actions_OmitemptyWhenNil(t *testing.T) {
r := ApplyResult{PlanID: "p"}
data, err := json.Marshal(r)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(data, []byte(`"actions"`)) {
t.Errorf("nil Actions emitted in JSON: %s", data)
}
}
@intel352
Copy link
Copy Markdown
Contributor Author

r3 review acknowledgement — proceeding to merge

Copilot's 5 new comments on 776bf94 revisit two clusters already decided:

Cluster 1: per-action status semantic gaps (4 of 5 findings)

  • apply.go:316 (post-apply/post-delete hook fail status conflation)
  • apply.go:354 (JIT-fail-on-delete → DeleteFailed)
  • apply.go:265 (double-error-record + %v vs %w consistency — Minor nit)
  • apply_hooks_test.go:302 (test coverage for 5 other exit paths)

All four belong to the Phase 2.3 deferral cluster already replied at #issuecomment-4467488513 (apply.go:313) + #issuecomment-4467575644 (apply.go:235). Phase 2.3 will introduce ACTION_STATUS_COMPENSATED (tag 4), ACTION_STATUS_COMPENSATION_FAILED (tag 5; both already reserved in b09bced), and ACTION_STATUS_SKIPPED (proposed tag 6) to distinguish:

  1. action skipped pre-dispatch (ctx-cancel, JIT-fail, driver-resolve-fail) → SKIPPED
  2. action succeeded cloud-side but post-hook failed → SUCCESS-with-warning + COMPENSATION_FAILED if compensation also fails
  3. delete genuinely failed at dispatch → DELETE_FAILED (current semantics)

Implementing this distinction in Phase 2 would require either (a) breaking the strict 1:1 length-invariant assert by elide-on-skip OR (b) introducing 1+ new enum values mid-cascade. Both violate the hard-cutover discipline of ADR 0024 (the plugins are already pinned to v2 with this exact enum set). Phase 2.3 is the correct landing zone — the proto-side reserved 4, 5; directive (b09bced) was placed specifically to preserve room.

The advisory Minor on apply.go:265 (double-record + %w) was also raised by internal code-review (Minor #2+3 at T4 quality review) and accepted-with-rationale: result.Errors[].Error is a string field that uses Sprintf; the iterErr is a separate concern that can use %w when the error-chain matters. No call site currently does errors.Is/As on iterErr. Tracking as Phase 2 follow-up cleanup.

Test-coverage observation (apply_hooks_test.go:302) was also raised by internal code-review and accepted: the plan-spec'd 2 tests (CleanSuccess + PreDispatchDriverError) cover the C-1 invariant per cycle-1 plan-review. Coverage for the other 5 exit paths is welcome engineering hygiene — tracking for a follow-up PR rather than expanding this PR's scope.

Cluster 2: import sanity check (1 of 5 findings)

  • iac_state_test.go:324json/bytes import verification.

Verified: local GOWORK=off go test ./interfaces/ passed. Both imports present.


Merge decision: all 5 r3 findings are either same-class deferrals (already replied) or accepted advisory Minors. CI is 27 SUCCESS / 1 SKIPPED / 0 FAILURE. Branch is mergeable. Per ADR 0024 + 0040 cascade discipline, PR1 is the gate for the 4 downstream plugin PRs (aws/gcp/azure/digitalocean v1.2.0). Proceeding to admin-merge → tag v0.54.0 → unblock cascade.

Phase 2.3 follow-up tracking: will be opened as a workflow issue after PR1 merges + cascade lands, scoping the SKIPPED + COMPENSATED + COMPENSATION_FAILED enum additions plus the dispatch-side mapping rules.

@intel352 intel352 merged commit dd9a313 into main May 16, 2026
32 checks passed
@intel352 intel352 deleted the feat/v2-phase2-grpc-contract branch May 16, 2026 17:45
intel352 added a commit that referenced this pull request May 17, 2026
Critical fixes:
- C-1 (DOProvider.FlushDeferredUpdates does not exist): §E rewritten
  to inline per-driver iteration in doIaCServer.FinalizeApply mirror
  of v1 provider.go:295-307; no new public method on DOProvider.
- C-2 (deferred-closure needs named returns): §A.0 added as required
  prerequisite — explicit applyPlanWithEnvProviderAndHooks signature
  change to (result *interfaces.ApplyResult, err error).
- C-3 (cited line numbers): RE-VERIFIED original cites 167/180/229/240/
  249/255 ARE correct in current code; added explicit per-line
  classification table in §D.
- C-4 (empty-plan case): §D paragraph added with regression-test cite.

Important fixes:
- I-1 UnimplementedIaCProviderFinalizerServer embed: now mandated in §E.
- I-2 per-driver attribution: FinalizeApplyResponse extended with
  repeated ActionError errors (replacing single string field).
- I-3 downstream-consumer pin-reset: §Rollback paragraph added.
- I-4 ApplyFinalizer interface diverges: rewritten to use
  Finalizer() pb.IaCProviderFinalizerClient accessor.
- I-5 v0.54.0 SHA cite: header now cites PR #694 sha dd9a313.
- I-6 MEMORY.md reference: verified existing.

Minor fixes: cite ranges tightened; proto comment cross-ref to Phase 2.3.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
intel352 added a commit that referenced this pull request May 17, 2026
…st (#693) (#696)

Phase 2.1 follow-up to workflow#640 Phase 2 cascade (PR #694, v0.54.0).
Per ADR 0024 + ADR 0040 hard-cutover discipline + Phase 1 Assumption 8:
findIaCPluginDir previously json.Unmarshal'd the matching plugin
manifest and returned the raw iacProvider.computePlanVersion string
without validation. A typo (V2, v2.0, two, v3) would silently route
through the v1 dispatch path via wfctlhelpers.DispatchVersionFor's
empty/unknown default — breaking the Phase 2 hard-cutover contract.

Validation gate now hard-fails on the matching plugin manifest when
computePlanVersion is not in {"", "v1", "v2"}. Operators see the
misconfiguration loudly with an actionable error instead of silent
fallback. Empty/missing field still permitted (defaults to v1 dispatch).

Per workflow#693 issue body's 3-option design space, picked option 3
(lightweight enum check, minimum viable) over option 1 (full
pluginmanifest package, ~200 LOC overkill) and option 2 (reuse
existing schema/ JSON Schema validator, ~50 LOC). YAGNI applies — the
field's domain is a 3-element enum closed by the proto spec.

7 test cases (table-driven): empty, v1, v2 pass; V2 (uppercase typo),
v2.0 (decimal typo), two (word typo), v3 (future Phase 2.3 tag
pre-introduction) all reject with the actionable error.

Closes #693.
intel352 added a commit that referenced this pull request May 20, 2026
#743)

* feat(iac): workflow#640 Phase 5 — remove legacy wfctlhelpers.ApplyPlan

Per docs/migrations/2026-05-16-v2-lifecycle-phase1-inventory.md.

Phase 1 (PR #691) + Phase 2 (PR #694) + Phase 2.5 (PR #697) +
Phase 3 (per-plugin migration) all shipped. Production code has
been on ApplyPlanWithHooks for weeks. This PR closes the loop by
removing the deprecated symbol.

Changes:

iac/wfctlhelpers/apply.go:
- Delete func ApplyPlan (the 2-line wrapper that delegated to
  applyPlanWithEnvProvider with nil hooks).
- Keep applyPlanWithEnvProvider as the postcondition-test seam —
  retained for apply_postcondition_test.go's panicky-env injection.

iac/wfctlhelpers/*_test.go:
- 10 test files migrated from `ApplyPlan(ctx, p, plan)` to
  `ApplyPlanWithHooks(ctx, p, plan, ApplyPlanHooks{})`. Empty-hooks
  form is semantically identical to the pre-deletion ApplyPlan call.

docs/migrations/2026-05-16-v2-lifecycle-phase1-inventory.md:
- Phase 5 marker flipped to SHIPPED.

Verification:
  GOWORK=off go test ./... -count=1 (all green)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(iac): close v2 lifecycle migration notes

* docs(iac): clarify manifest lifecycle metadata

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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