Skip to content

feat(wfctl): infra audit-keys + prune + rotate-and-prune CLIs (PR5: Tasks 16-22)#584

Merged
intel352 merged 10 commits into
mainfrom
feat/spaces-key-clis
May 9, 2026
Merged

feat(wfctl): infra audit-keys + prune + rotate-and-prune CLIs (PR5: Tasks 16-22)#584
intel352 merged 10 commits into
mainfrom
feat/spaces-key-clis

Conversation

@intel352
Copy link
Copy Markdown
Contributor

@intel352 intel352 commented May 9, 2026

Summary

PR5 of the spaces-key-iac-resource plan. Adds three new wfctl infra subcommands that surround iac.spaces_key (and any future non-tag-supporting resource type) with audit + prune tooling:

  • wfctl infra audit-keys --type <T> — list every cloud-side resource of <T> (via EnumeratorAll) with its identifying fields. Diff against state for the operator.
  • wfctl infra prune --type <T> — delete cloud-side resources that have no corresponding state entry (with confirmation).
  • wfctl infra rotate-and-prune --type <T> — rotate the active credential, then prune the orphaned old key once the new one is verified, with a recovery file written for safety.

Depends on workflow v0.26.0 (released; bundles PR0 audit-secrets + PR1 R-A9 hardening + PR4a storage-filter + EnumeratorAll interface).

Stack

This PR opens with Task 16 (failing test for audit-keys) only. Subsequent tasks (17–22) will stack on this branch as commits and ship in the same squash-merge:

  • Task 16 — failing test for wfctl infra audit-keys (commit 5622d2f)
  • Task 17 — implement wfctl infra audit-keys
  • Task 18 — failing test for wfctl infra prune
  • Task 19 — implement wfctl infra prune
  • Task 20 — failing test for wfctl infra rotate-and-prune
  • Task 21 — implement wfctl infra rotate-and-prune + recovery file
  • Task 22 — register CLIs in main.go + regen embedded docs

Verification (this commit only)

$ GOWORK=off go test ./cmd/wfctl -run TestInfraAuditKeys -v
cmd/wfctl/infra_audit_keys_test.go:62:14: undefined: runInfraAuditKeys
FAIL	github.com/GoCodeAlone/workflow/cmd/wfctl [build failed]

This is the expected failure mode for the failing-test class. Task 17 will introduce runInfraAuditKeys to make it pass.

Test plan

  • Task 16 test fails with undefined: runInfraAuditKeys
  • Tasks 17 + 22 land → audit-keys CLI works end-to-end against a real DO provider via EnumeratorAll
  • Tasks 18 + 19 land → prune CLI works against orphaned keys
  • Tasks 20 + 21 land → rotate-and-prune ships recovery file alongside the new key
  • Final squash-merge tests cover happy path + at least one regression sentinel per CLI

🤖 Generated with Claude Code

Adds TestInfraAuditKeys_ListsAll + fakeProviderEnumeratorAll fixture for
the new `wfctl infra audit-keys --type <T>` CLI. Verifies that the
command:
  - delegates to interfaces.EnumeratorAll.EnumerateAll(resourceType)
  - forwards the --type flag to the enumerator
  - renders every returned key's identifying fields (Name, ProviderID/
    access_key) into the writer
  - exits 0 on success

This is the failing test for Task 16 of the spaces-key-iac-resource plan
(PR5). Until Task 17 implements runInfraAuditKeys + the registration of
`wfctl infra audit-keys`, this test fails to compile with `undefined:
runInfraAuditKeys`.

Pre-staged on feat/spaces-key-clis off feat/spaces-key-storage-filter
(PR4a). Will be rebased onto origin/main after PR4a merges + workflow
v0.26.0 tag is cut, then pushed as PR5.

Plan: docs/plans/2026-05-08-spaces-key-iac-resource.md (commit 316559f7),
Task 16 of PR5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 9, 2026 06:39
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

Adds the initial TDD “failing test” scaffold for the upcoming wfctl infra audit-keys subcommand, leveraging the new interfaces.EnumeratorAll provider capability.

Changes:

  • Introduces TestInfraAuditKeys_ListsAll to validate that audit-keys --type <T> enumerates all resources of a given type and renders identifying fields.
  • Adds a minimal fake EnumeratorAll implementation to support the test scenario.

Comment on lines +61 to +63
var out bytes.Buffer
exitCode := runInfraAuditKeys([]string{"--type", "infra.spaces_key"}, fakeProv, &out)
if exitCode != 0 {
intel352 and others added 2 commits May 9, 2026 02:45
Implements `wfctl infra audit-keys --type <T>` for Task 17 of the
spaces-key-iac-resource plan (PR5). The command loads iac.provider
modules from infra.yaml (honoring --config / --env), finds the first
provider that implements interfaces.EnumeratorAll, and prints every
resource of `<T>` it returns as a fixed-width table (NAME / ACCESS_KEY
/ CREATED_AT). Read-only — drift correction surface for the destructive
`wfctl infra prune` (Task 19) that comes next.

Design notes:

 - runInfraAuditKeys takes interfaces.EnumeratorAll directly (not the
   broader IaCProvider) so unit tests can pass a minimal fake without
   implementing every IaCProvider method. The IaCProvider →
   EnumeratorAll type-assertion happens at the dispatcher boundary in
   runInfraAuditKeysCmd, where producing a structured error is
   appropriate (provider plugin doesn't support the optional interface).
 - auditKeysLoadProviders is a seam variable defaulting to
   defaultCleanupLoadProviders so audit-keys inherits the same
   env-resolution + plugin-discovery contract as `infra cleanup`.
 - auditKeysStdout / auditKeysStderr seam variables mirror the cleanup
   pattern so parallel tests don't race on global os.Stdout.

Test coverage: TestInfraAuditKeys_ListsAll (Task 16, prior commit) now
PASSes with this implementation; full cmd/wfctl test suite stays green.

Smoke-tested:
  $ wfctl infra audit-keys --help    # exit 0, prints flags
  $ wfctl infra audit-keys            # exit 1, "no infra config found"

Plan: docs/plans/2026-05-08-spaces-key-iac-resource.md (commit 316559f7),
Task 17 of PR5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds three failing tests for `wfctl infra prune` (Task 18 of PR5):

  TestInfraPrune_RequiresTwoKeyOptIn — destructive prune must require
    BOTH `--confirm` flag AND WFCTL_CONFIRM_PRUNE=1 env var. Either
    alone (or neither) → non-zero exit before any cloud call.
  TestInfraPrune_RequiresExcludeAccessKey — `--exclude-access-key` is
    mandatory so the operator must explicitly name the active credential
    they want preserved. Error message must mention the flag.
  TestInfraPrune_FiltersByTimeAndExcludesAccessKey — happy path: with
    both opt-ins + the exclude flag, prune deletes every key whose
    created_at is older than --created-before EXCEPT the excluded
    access_key. Tracks deletions by ProviderID on a fakeProviderWithDelete
    that implements EnumerateAll + DeleteResource.

Local helper `pruneContains` (renamed to avoid collision with the
existing `containsString` in infra_templates.go).

Currently fails with `undefined: runInfraPrune` (test build broken) —
expected red state. Task 19 will introduce the implementation to make
all three pass. Stacked onto PR #584; final squash will land all
six PR5 commits together.

Plan: docs/plans/2026-05-08-spaces-key-iac-resource.md (commit 316559f7),
Task 18 of PR5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 9, 2026 06:47
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 9, 2026

⏱ Benchmark Results

No significant performance regressions detected.

benchstat comparison (baseline → PR)
## benchstat: baseline → PR
baseline-bench.txt:260: parsing iteration count: invalid syntax
baseline-bench.txt:429054: parsing iteration count: invalid syntax
baseline-bench.txt:811897: parsing iteration count: invalid syntax
baseline-bench.txt:1222085: parsing iteration count: invalid syntax
baseline-bench.txt:1582113: parsing iteration count: invalid syntax
baseline-bench.txt:1998577: parsing iteration count: invalid syntax
benchmark-results.txt:260: parsing iteration count: invalid syntax
benchmark-results.txt:300495: parsing iteration count: invalid syntax
benchmark-results.txt:650725: parsing iteration count: invalid syntax
benchmark-results.txt:1177439: parsing iteration count: invalid syntax
benchmark-results.txt:1471866: parsing iteration count: invalid syntax
benchmark-results.txt:1808638: parsing iteration count: invalid syntax
goos: linux
goarch: amd64
pkg: github.com/GoCodeAlone/workflow/dynamic
cpu: AMD EPYC 9V74 80-Core Processor                
                            │ baseline-bench.txt │         benchmark-results.txt         │
                            │       sec/op       │     sec/op      vs base               │
InterpreterCreation-4               2.775m ± 95%    3.267m ± 169%        ~ (p=0.310 n=6)
ComponentLoad-4                     2.682m ±  4%    3.496m ±   1%  +30.35% (p=0.002 n=6)
ComponentExecute-4                  1.416µ ±  0%    1.838µ ±   1%  +29.81% (p=0.002 n=6)
PoolContention/workers-1-4          781.9n ±  2%   1029.0n ±   4%  +31.59% (p=0.002 n=6)
PoolContention/workers-2-4          785.7n ±  1%   1013.0n ±   2%  +28.93% (p=0.002 n=6)
PoolContention/workers-4-4          783.2n ±  1%   1018.0n ±   1%  +29.98% (p=0.002 n=6)
PoolContention/workers-8-4          781.2n ±  3%   1011.0n ±   1%  +29.41% (p=0.002 n=6)
PoolContention/workers-16-4         784.8n ±  1%   1016.0n ±   1%  +29.46% (p=0.002 n=6)
ComponentLifecycle-4                2.695m ±  0%    3.523m ±   1%  +30.75% (p=0.002 n=6)
SourceValidation-4                  1.612µ ±  1%    2.106µ ±   1%  +30.61% (p=0.002 n=6)
RegistryConcurrent-4                579.4n ±  6%    765.2n ±   3%  +32.05% (p=0.002 n=6)
LoaderLoadFromString-4              2.723m ±  0%    3.572m ±   0%  +31.17% (p=0.002 n=6)
geomean                             12.90µ          16.68µ         +29.27%

                            │ baseline-bench.txt │        benchmark-results.txt         │
                            │        B/op        │     B/op      vs base                │
InterpreterCreation-4               2.027Mi ± 0%   2.027Mi ± 0%       ~ (p=0.790 n=6)
ComponentLoad-4                     2.180Mi ± 0%   2.180Mi ± 0%       ~ (p=0.797 n=6)
ComponentExecute-4                  1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-1-4          1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-2-4          1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-4-4          1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-8-4          1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-16-4         1.203Ki ± 0%   1.203Ki ± 0%       ~ (p=1.000 n=6) ¹
ComponentLifecycle-4                2.183Mi ± 0%   2.183Mi ± 0%       ~ (p=0.292 n=6)
SourceValidation-4                  1.984Ki ± 0%   1.984Ki ± 0%       ~ (p=1.000 n=6) ¹
RegistryConcurrent-4                1.133Ki ± 0%   1.133Ki ± 0%       ~ (p=1.000 n=6) ¹
LoaderLoadFromString-4              2.182Mi ± 0%   2.182Mi ± 0%       ~ (p=0.065 n=6)
geomean                             15.25Ki        15.25Ki       -0.00%
¹ all samples are equal

                            │ baseline-bench.txt │        benchmark-results.txt        │
                            │     allocs/op      │  allocs/op   vs base                │
InterpreterCreation-4                15.68k ± 0%   15.68k ± 0%       ~ (p=1.000 n=6) ¹
ComponentLoad-4                      18.02k ± 0%   18.02k ± 0%       ~ (p=1.000 n=6)
ComponentExecute-4                    25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-1-4            25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-2-4            25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-4-4            25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-8-4            25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
PoolContention/workers-16-4           25.00 ± 0%    25.00 ± 0%       ~ (p=1.000 n=6) ¹
ComponentLifecycle-4                 18.07k ± 0%   18.07k ± 0%       ~ (p=1.000 n=6) ¹
SourceValidation-4                    32.00 ± 0%    32.00 ± 0%       ~ (p=1.000 n=6) ¹
RegistryConcurrent-4                  2.000 ± 0%    2.000 ± 0%       ~ (p=1.000 n=6) ¹
LoaderLoadFromString-4               18.06k ± 0%   18.06k ± 0%       ~ (p=1.000 n=6)
geomean                               183.3         183.3       +0.00%
¹ all samples are equal

pkg: github.com/GoCodeAlone/workflow/middleware
                                  │ baseline-bench.txt │       benchmark-results.txt        │
                                  │       sec/op       │   sec/op     vs base               │
CircuitBreakerDetection-4                  230.9n ± 0%   297.1n ± 2%  +28.72% (p=0.002 n=6)
CircuitBreakerExecution_Success-4          17.59n ± 1%   22.65n ± 0%  +28.83% (p=0.002 n=6)
CircuitBreakerExecution_Failure-4          55.17n ± 0%   70.90n ± 0%  +28.50% (p=0.002 n=6)
geomean                                    60.73n        78.15n       +28.68%

                                  │ baseline-bench.txt │       benchmark-results.txt        │
                                  │        B/op        │    B/op     vs base                │
CircuitBreakerDetection-4                 144.0 ± 0%     144.0 ± 0%       ~ (p=1.000 n=6) ¹
CircuitBreakerExecution_Success-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
CircuitBreakerExecution_Failure-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                              ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                                  │ baseline-bench.txt │       benchmark-results.txt        │
                                  │     allocs/op      │ allocs/op   vs base                │
CircuitBreakerDetection-4                 1.000 ± 0%     1.000 ± 0%       ~ (p=1.000 n=6) ¹
CircuitBreakerExecution_Success-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
CircuitBreakerExecution_Failure-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                              ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/module
                                 │ baseline-bench.txt │        benchmark-results.txt        │
                                 │       sec/op       │    sec/op     vs base               │
JQTransform_Simple-4                     643.3n ± 23%   921.2n ± 15%  +43.20% (p=0.002 n=6)
JQTransform_ObjectConstruction-4         1.079µ ±  3%   1.383µ ±  0%  +28.17% (p=0.002 n=6)
JQTransform_ArraySelect-4                2.601µ ±  0%   3.311µ ±  0%  +27.30% (p=0.002 n=6)
JQTransform_Complex-4                    31.30µ ±  0%   40.51µ ±  0%  +29.43% (p=0.002 n=6)
JQTransform_Throughput-4                 1.300µ ±  0%   1.690µ ±  1%  +30.05% (p=0.002 n=6)
SSEPublishDelivery-4                     48.89n ±  1%   63.25n ±  1%  +29.35% (p=0.002 n=6)
geomean                                  1.237µ         1.623µ        +31.14%

                                 │ baseline-bench.txt │        benchmark-results.txt         │
                                 │        B/op        │     B/op      vs base                │
JQTransform_Simple-4                   1.273Ki ± 0%     1.273Ki ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_ObjectConstruction-4       1.773Ki ± 0%     1.773Ki ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_ArraySelect-4              2.625Ki ± 0%     2.625Ki ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_Complex-4                  16.22Ki ± 0%     16.22Ki ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_Throughput-4               1.984Ki ± 0%     1.984Ki ± 0%       ~ (p=1.000 n=6) ¹
SSEPublishDelivery-4                     0.000 ± 0%       0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                             ²                 +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                                 │ baseline-bench.txt │       benchmark-results.txt        │
                                 │     allocs/op      │ allocs/op   vs base                │
JQTransform_Simple-4                     10.00 ± 0%     10.00 ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_ObjectConstruction-4         15.00 ± 0%     15.00 ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_ArraySelect-4                30.00 ± 0%     30.00 ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_Complex-4                    324.0 ± 0%     324.0 ± 0%       ~ (p=1.000 n=6) ¹
JQTransform_Throughput-4                 17.00 ± 0%     17.00 ± 0%       ~ (p=1.000 n=6) ¹
SSEPublishDelivery-4                     0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                             ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/schema
                                    │ baseline-bench.txt │        benchmark-results.txt         │
                                    │       sec/op       │    sec/op      vs base               │
SchemaValidation_Simple-4                   865.6n ± 17%   1118.5n ± 12%  +29.22% (p=0.002 n=6)
SchemaValidation_AllFields-4                1.258µ ±  7%    1.649µ ±  3%  +30.99% (p=0.002 n=6)
SchemaValidation_FormatValidation-4         1.231µ ±  5%    1.595µ ±  1%  +29.58% (p=0.002 n=6)
SchemaValidation_ManySchemas-4              1.232µ ±  2%    1.617µ ±  2%  +31.21% (p=0.002 n=6)
geomean                                     1.134µ          1.476µ        +30.25%

                                    │ baseline-bench.txt │       benchmark-results.txt        │
                                    │        B/op        │    B/op     vs base                │
SchemaValidation_Simple-4                   0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_AllFields-4                0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_FormatValidation-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_ManySchemas-4              0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                                ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                                    │ baseline-bench.txt │       benchmark-results.txt        │
                                    │     allocs/op      │ allocs/op   vs base                │
SchemaValidation_Simple-4                   0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_AllFields-4                0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_FormatValidation-4         0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
SchemaValidation_ManySchemas-4              0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                                ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

pkg: github.com/GoCodeAlone/workflow/store
                                   │ baseline-bench.txt │        benchmark-results.txt         │
                                   │       sec/op       │    sec/op      vs base               │
EventStoreAppend_InMemory-4                737.6n ±  8%   1086.5n ±  7%  +47.30% (p=0.002 n=6)
EventStoreAppend_SQLite-4                  1.764m ± 53%    1.076m ±  6%  -39.03% (p=0.002 n=6)
GetTimeline_InMemory/events-10-4           10.46µ ±  8%    12.82µ ±  2%  +22.64% (p=0.002 n=6)
GetTimeline_InMemory/events-50-4           44.28µ ±  2%    71.50µ ±  2%  +61.47% (p=0.002 n=6)
GetTimeline_InMemory/events-100-4          89.32µ ±  1%   142.27µ ± 24%  +59.29% (p=0.002 n=6)
GetTimeline_InMemory/events-500-4          458.2µ ±  0%    559.2µ ±  1%  +22.03% (p=0.002 n=6)
GetTimeline_InMemory/events-1000-4         939.0µ ±  3%   1140.8µ ±  1%  +21.48% (p=0.002 n=6)
GetTimeline_SQLite/events-10-4             67.02µ ±  1%    83.05µ ±  1%  +23.91% (p=0.002 n=6)
GetTimeline_SQLite/events-50-4             175.6µ ±  1%    218.4µ ±  1%  +24.36% (p=0.002 n=6)
GetTimeline_SQLite/events-100-4            309.8µ ±  1%    378.9µ ±  0%  +22.31% (p=0.002 n=6)
GetTimeline_SQLite/events-500-4            1.359m ±  1%    1.651m ±  0%  +21.53% (p=0.002 n=6)
GetTimeline_SQLite/events-1000-4           2.619m ±  1%    3.220m ±  0%  +22.95% (p=0.002 n=6)
geomean                                    160.9µ          197.6µ        +22.86%

                                   │ baseline-bench.txt │         benchmark-results.txt         │
                                   │        B/op        │     B/op      vs base                 │
EventStoreAppend_InMemory-4                 848.5 ± 12%     756.5 ± 8%  -10.84% (p=0.015 n=6)
EventStoreAppend_SQLite-4                 1.984Ki ±  2%   1.984Ki ± 1%        ~ (p=0.892 n=6)
GetTimeline_InMemory/events-10-4          7.953Ki ±  0%   7.953Ki ± 0%        ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-50-4          46.62Ki ±  0%   46.62Ki ± 0%        ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-100-4         94.48Ki ±  0%   94.48Ki ± 0%        ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-500-4         472.8Ki ±  0%   472.8Ki ± 0%        ~ (p=0.545 n=6)
GetTimeline_InMemory/events-1000-4        944.3Ki ±  0%   944.3Ki ± 0%        ~ (p=1.000 n=6)
GetTimeline_SQLite/events-10-4            16.74Ki ±  0%   16.74Ki ± 0%        ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-50-4            87.14Ki ±  0%   87.14Ki ± 0%        ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-100-4           175.4Ki ±  0%   175.4Ki ± 0%        ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-500-4           846.1Ki ±  0%   846.1Ki ± 0%   -0.00% (p=0.022 n=6)
GetTimeline_SQLite/events-1000-4          1.639Mi ±  0%   1.639Mi ± 0%   +0.00% (p=0.032 n=6)
geomean                                   67.75Ki         67.10Ki        -0.95%
¹ all samples are equal

                                   │ baseline-bench.txt │        benchmark-results.txt        │
                                   │     allocs/op      │  allocs/op   vs base                │
EventStoreAppend_InMemory-4                  7.000 ± 0%    7.000 ± 0%       ~ (p=1.000 n=6) ¹
EventStoreAppend_SQLite-4                    53.00 ± 2%    53.00 ± 0%       ~ (p=1.000 n=6)
GetTimeline_InMemory/events-10-4             125.0 ± 0%    125.0 ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-50-4             653.0 ± 0%    653.0 ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-100-4           1.306k ± 0%   1.306k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-500-4           6.514k ± 0%   6.514k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_InMemory/events-1000-4          13.02k ± 0%   13.02k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-10-4               382.0 ± 0%    382.0 ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-50-4              1.852k ± 0%   1.852k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-100-4             3.681k ± 0%   3.681k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-500-4             18.54k ± 0%   18.54k ± 0%       ~ (p=1.000 n=6) ¹
GetTimeline_SQLite/events-1000-4            37.29k ± 0%   37.29k ± 0%       ~ (p=1.000 n=6) ¹
geomean                                     1.162k        1.162k       +0.00%
¹ all samples are equal

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

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

Comment on lines +120 to +124
for _, p := range providers {
if enum, ok := p.(interfaces.EnumeratorAll); ok {
if rc := runInfraAuditKeys(args, enum, auditKeysStdout); rc != 0 {
return fmt.Errorf("audit-keys exited with code %d", rc)
}
Comment on lines +51 to +55
code := runInfraPrune([]string{
"--type", "infra.spaces_key",
"--created-before", "2026-05-08T00:00:00Z",
"--exclude-access-key", "AK_NEW",
}, nil, &out)
Comment thread cmd/wfctl/infra_audit_keys.go Outdated
Comment on lines +72 to +73
name, _ := o.Outputs["name"].(string)
ak, _ := o.Outputs["access_key"].(string)
Comment on lines +31 to +34
// writer. This is the failing test for Task 16 of the spaces-key-iac-resource
// plan (PR5). Until Task 17 implements runInfraAuditKeys + the registration
// of `wfctl infra audit-keys`, this test fails with `undefined:
// runInfraAuditKeys`.
intel352 and others added 2 commits May 9, 2026 02:51
…minator

Implements `wfctl infra prune` for Task 19 of PR5. Destructive cloud-side
resource pruning with three-factor authorization plus mandatory exclusion
target so a typo can't accidentally nuke the active credential.

Authorization gauntlet (all three required):
  - `--confirm` flag: explicit per-invocation consent
  - WFCTL_CONFIRM_PRUNE=1 env var: two-key authorization (set by
    operator; not by automation by default)
  - interactive y/N prompt: skipped under `--non-interactive` for CI

Mandatory filter args (paranoia rail):
  - `--created-before <RFC3339>`: only resources older than this eligible
  - `--exclude-access-key <AK>`: this access_key preserved no matter what

Optional filters:
  - `--allowlist <regex>`: skip names matching the regex
  - `--recovery-from-last-rotation`: read filter args from the recovery
    file written by `infra rotate-and-prune` (Task 21). On success the
    recovery file is removed; on failure it's retained for re-invocation.

Design notes:

  - runInfraPrune takes a narrow `pruneProvider` interface (EnumerateAll +
    DeleteResource) so unit tests can use a minimal fake. Production code
    wraps an interfaces.IaCProvider in pruneProviderAdapter which bridges
    to the existing interfaces.EnumeratorAll + ResourceDriver.Delete
    primitives at the boundary.
  - Separates the recovery-file machinery (defaultStateDir,
    recoveryFilePath, readRecoveryFile, recoveryFile struct) so Task 21
    can reuse the writer side without code duplication.
  - pruneStdout/pruneStderr seam vars + pruneLoadProviders seam mirror
    the cleanup pattern so prune-related tests don't race on global
    os.Stdout.

Test coverage: TestInfraPrune_RequiresTwoKeyOptIn,
TestInfraPrune_RequiresExcludeAccessKey, and
TestInfraPrune_FiltersByTimeAndExcludesAccessKey (Task 18, prior commit)
all PASS with this implementation. Full cmd/wfctl suite stays green.

Smoke-tested:
  $ wfctl infra prune --confirm ... → exits 0/1 per the gauntlet

Plan: docs/plans/2026-05-08-spaces-key-iac-resource.md (commit 316559f7),
Task 19 of PR5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds two failing tests for `wfctl infra rotate-and-prune` per Task 20 of
docs/plans/2026-05-08-spaces-key-iac-resource.md (commit 316559f7):

- TestInfraRotateAndPrune_HappyPath stubs the package-level
  bootstrapSecrets test hook (same pattern as the existing generateSecret
  hook in bootstrap_test.go) to return a single RotationResult, runs
  rotate-and-prune against a fake provider returning AK_OLD + AK_NEW from
  EnumerateAll, and asserts that AK_OLD is deleted, AK_NEW is preserved,
  and the recovery file at $WFCTL_STATE_DIR/last-rotation.json is removed
  on full success.

- TestInfraRotateAndPrune_RecoveryFileWrittenWithCorrectPerms forces
  DeleteResource to return an error, then asserts that the recovery file
  is RETAINED (so `wfctl infra prune --recovery-from-last-rotation` can
  complete the prune without re-rotating) AND has permissions 0600
  (owner-only — the file contains access_key + name metadata sufficient
  to identify the canonical credential).

Adds the fakeProviderEnumerableDriver test double — same shape as the
existing fakeProviderWithDelete in infra_prune_test.go but with an
additional deleteErr hook so the failure-path test can simulate
transient errors. Local rotateAndPruneContains helper avoids cross-file
collisions with sibling test helpers (existing pruneContains is in a
different file scope).

Currently FAILS with `undefined: runInfraRotateAndPrune` — the
failing-side signal Task 20 is supposed to produce. Task 21 lands the
implementation in cmd/wfctl/infra_rotate_and_prune.go to make these PASS.

Stacks on top of Task 19's implementation (commit 3185c5b) on the
PR5 (feat/spaces-key-clis) branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 9, 2026 06:54
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 6 out of 6 changed files in this pull request and generated 9 comments.

Comment on lines +111 to +118
code := runInfraRotateAndPrune([]string{
"--type", "infra.spaces_key",
"--name", "test-key",
"--confirm", "--non-interactive",
}, fakeProv, &out)
if code != 0 {
t.Fatalf("rotate-and-prune failed: code=%d, out=%s", code, out.String())
}
Comment thread cmd/wfctl/infra.go
Comment on lines +93 to +96
case "audit-keys":
return runInfraAuditKeysCmd(args[1:])
case "prune":
return runInfraPruneCmd(args[1:])
Comment thread cmd/wfctl/infra_prune.go Outdated
fmt.Fprintf(w, " ✓ deleted %s\n", o.Name)
}
if failed > 0 {
fmt.Fprintf(w, "\n%d delete(s) failed; recovery file retained at %s\n", failed, recoveryFilePath())
Comment thread cmd/wfctl/infra_prune.go Outdated
return 1
}
if recoveryFromLastRotation {
_ = os.Remove(recoveryFilePath())
Comment thread cmd/wfctl/infra_prune.go Outdated
Comment on lines +107 to +108
// move opt-in checks further from --confirm parsing.
//
Comment thread cmd/wfctl/infra_prune.go Outdated
fs.StringVar(&resourceType, "type", "", "Resource type (required, e.g. infra.spaces_key)")
fs.StringVar(&createdBefore, "created-before", "", "RFC3339 timestamp; only resources older than this are eligible")
fs.StringVar(&excludeAK, "exclude-access-key", "", "Access key to preserve (required: paranoia rail)")
fs.StringVar(&allowlist, "allowlist", "", "Regex matching names to skip (orthogonal to time filter)")
Comment thread cmd/wfctl/infra_prune.go
Comment on lines +181 to +195
for _, out := range outs {
ak, _ := out.Outputs["access_key"].(string)
ca, _ := out.Outputs["created_at"].(string)
name, _ := out.Outputs["name"].(string)
if ak == excludeAK {
continue
}
if allowlistRe != nil && allowlistRe.MatchString(name) {
continue
}
t, parseErr := time.Parse(time.RFC3339, ca)
if parseErr != nil || !t.Before(cutoff) {
continue
}
toDelete = append(toDelete, out)
Comment thread cmd/wfctl/infra_audit_keys.go Outdated
Comment on lines +72 to +75
name, _ := o.Outputs["name"].(string)
ak, _ := o.Outputs["access_key"].(string)
ca, _ := o.Outputs["created_at"].(string)
fmt.Fprintf(w, "%-30s %-30s %s\n", name, ak, ca)
Comment on lines +83 to +84
// interfaces.EnumeratorAll for the requested --type, and dispatches to
// runInfraAuditKeys.
intel352 and others added 2 commits May 9, 2026 03:00
…017)

Implements Task 21 of the spaces-key-iac-resource plan
(docs/plans/2026-05-08-spaces-key-iac-resource.md, commit 316559f7).

Adds `cmd/wfctl/infra_rotate_and_prune.go` with three pieces:

1. recoveryRecord struct — JSON-superset of recoveryFile (defined in
   infra_prune.go by Task 19) adding Source + RotatedAt for forensics.
   The prune reader ignores the extra fields so this is a backwards-
   compatible extension; both readers share the same canonical path.

2. writeRecoveryRecord(rec) — persists the JSON to
   $WFCTL_STATE_DIR/last-rotation.json with 0600 file perms + 0700
   parent dir. Sensitive credential metadata; only the owner reads it.

3. runInfraRotateAndPrune(args, provider, w) — the all-in-one CLI:
   - Two-key opt-in: --confirm flag + WFCTL_CONFIRM_PRUNE=1 env (same as
     plain prune, since rotation+prune is doubly destructive).
   - Step 1: rotate via the existing parseSecretsConfig +
     resolveSecretsProvider + resolveCredentialRevoker + bootstrapSecrets
     chain, with forceRotate={name: true}. Returns []RotationResult per
     ADR 0020 — no subprocess, no stderr parsing.
   - Step 2: persist recovery record BEFORE the prune step, so a
     mid-prune failure doesn't lose the data needed to finish cleanup
     (an operator can recover via prune --recovery-from-last-rotation
     without re-rotating, which would worsen any leak).
   - Step 3: delegate to runInfraPrune passing rotated.CreatedAt as
     --created-before and rotated.AccessKey as --exclude-access-key.
     Older keys for the same Type are deleted; the just-rotated key is
     preserved by the exclusion filter.
   - On full success: rotate-and-prune (the file's writer) deletes the
     recovery file. On prune failure: file is retained + recovery
     instructions printed to stdout.

Provider is typed as `pruneProvider` (the narrow interface from
infra_prune.go) so the unit tests share a single fake surface with the
prune CLI itself — keeps blast radius small.

Verification:
- GOWORK=off go test ./cmd/wfctl -run TestInfraRotateAndPrune -v →
  TestInfraRotateAndPrune_HappyPath PASS,
  TestInfraRotateAndPrune_RecoveryFileWrittenWithCorrectPerms PASS.
- GOWORK=off go test ./cmd/wfctl -count=1 → entire wfctl suite PASS.

Test fixture adjustments (Task 20's test file):
- Added writeMinimalRotationConfig helper that writes a minimal
  infra.yaml with secrets.provider=env (no external deps) so
  parseSecretsConfig + resolveSecretsProvider succeed before the
  bootstrapSecrets stub takes over. Plan's test code didn't pass a
  --config but the actual implementation chain requires one;
  surfacing the fixture in the test rather than special-casing the
  impl keeps the hot path config-driven.
- Both tests now pass --config <fixture> alongside the existing flags.

Stacks on top of Task 20 (commit 739a0ef) on PR5 branch
(feat/spaces-key-clis).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…Task 22 + Task 17 args bug fix)

Closes Task 22 of PR5 — registers all three new infra subcommands under
the `wfctl infra` dispatcher and adds operator-facing reference docs in
docs/WFCTL.md.

Bundled bug fix (per code-reviewer + Copilot 3212662894 retro):
runInfraAuditKeysCmd and runInfraPruneCmd both forwarded the raw args
slice (which can include --config / --env / -c) to their inner
runInfraAuditKeys / runInfraPrune helpers whose narrow FlagSets only
declared --type and prune-specific flags. Real CLI invocations like
`wfctl infra audit-keys --type infra.spaces_key --config infra.yaml`
exited 2 with `flag provided but not defined: -config` — documented
flags broken. Unit tests of the inner functions never exercised the
dispatcher's args-passing path so the bug shipped past review.

Fix (team-lead's recommended option a): both dispatchers now CAPTURE
every flag they pre-parse and SYNTHESIZE a clean inner-args slice with
only flags the inner function understands. Added regression-sentinel
smoke tests `TestInfraAuditKeysCmd_AcceptsConfigFlag` and
`TestInfraPruneCmd_AcceptsConfigFlag` that prove `--config` + `--env`
are accepted end-to-end through the dispatcher.

rotate-and-prune dispatcher: Task 21's `runInfraRotateAndPrune` already
declares --config / --env on its own FlagSet (it needs them for
parseSecretsConfig in Step 1 of the rotate flow), so the dispatcher
forwards args verbatim — no synthesize-clean-args dance required there.

Files:
- cmd/wfctl/infra.go: register `audit-keys`, `prune`, `rotate-and-prune`
  cases under the infra dispatcher; add usage-doc lines.
- cmd/wfctl/infra_audit_keys.go: capture all dispatcher flags including
  --type; synthesize clean inner args for runInfraAuditKeys.
- cmd/wfctl/infra_prune.go: same fix for runInfraPruneCmd; capture all
  flags + synthesize clean inner args.
- cmd/wfctl/infra_rotate_and_prune.go: add runInfraRotateAndPruneCmd
  dispatcher + rotateAndPruneStdout / rotateAndPruneStderr seam vars +
  rotateAndPruneLoadProviders seam.
- cmd/wfctl/infra_audit_keys_test.go: TestInfraAuditKeysCmd_AcceptsConfigFlag
  smoke test + minimal fakeIaCProviderForAuditKeys stub.
- cmd/wfctl/infra_prune_test.go: TestInfraPruneCmd_AcceptsConfigFlag smoke
  test + fakeIaCProviderForPrune + fakeNoopDriver stubs.
- docs/WFCTL.md: add command reference for all three subcommands (~120
  lines: action table entries, full flag tables, exit codes, examples).

Verification:
  $ go test ./cmd/wfctl → ok (full suite green)
  $ go build -o /tmp/wfctl ./cmd/wfctl
  $ /tmp/wfctl infra --help → lists audit-keys, prune, rotate-and-prune
  $ /tmp/wfctl infra audit-keys --help → exit 0, prints flags
  $ /tmp/wfctl infra prune --help → exit 0, prints flags
  $ /tmp/wfctl infra rotate-and-prune --help → exit 0, prints flags

Note: `docs/dsl-reference-embedded.md` regen step from the plan skipped
— the file does not exist in the current main; the `docs gen-dsl-reference`
generator wasn't introduced in PR0/PR1/PR4a so there's nothing to regen.

Plan: docs/plans/2026-05-08-spaces-key-iac-resource.md (commit 316559f7),
Task 22 of PR5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 9, 2026 07:09
… step numbering, actionable recovery-cleanup warning

Three Task 21 Minor follow-ups from code-reviewer (PR #584, on top of
implementer-2's Task 22 wiring commit 5d8ae88):

1. **Unused --env**: replaced `var envName string; fs.StringVar(&envName, ...);
   _ = envName` with `_ = fs.String("env", "", ...)`. The flag is still
   declared so the dispatcher (`runInfraRotateAndPruneCmd` in 5d8ae88)
   can forward args verbatim including --env without the inner FlagSet
   erroring on unknown-flag, but no local var is allocated for a value
   that's never read inside this function. Doc comment now explicitly
   states the flag is consumed by the dispatcher; secrets-config
   resolution here happens via --config alone.

2. **Step-numbering**: previously the inline comments labelled three
   internal steps (1/rotate, 2/persist, 3/prune) but the user only saw
   two banners ("Step 1: rotating", "Step 2: pruning"). Aligned the
   comments to match the user-visible numbering: rotate is Step 1
   (with recovery-write as a sub-step), prune is Step 2.

3. **Recovery-cleanup warning**: bare `failed to remove recovery file`
   replaced with explicit success-confirmation + manual-cleanup hint
   ("rotation+prune succeeded but failed to remove stale recovery file
   at PATH: ERR" + "this file is no longer needed; remove with `rm
   PATH` once safe."). Operator knows their data is fine and only the
   state file needs hand-clearing.

Verification:
- GOWORK=off go test ./cmd/wfctl -run TestInfraRotateAndPrune -v → both PASS
- GOWORK=off go test ./cmd/wfctl -count=1 → entire wfctl suite PASS

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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 8 changed files in this pull request and generated 7 comments.

Comment on lines +35 to +40
// runInfraRotateAndPrune already declares --config / --env (it needs them
// for parseSecretsConfig in Step 1 of the rotate flow), so the dispatcher
// forwards args verbatim — no synthesize-clean-inner-args dance required.
// We still pre-parse here to extract --config / --env for the provider
// loader, but we don't reformat the args slice.
func runInfraRotateAndPruneCmd(args []string) error {
Comment on lines +161 to +180
func runInfraRotateAndPrune(args []string, provider pruneProvider, w io.Writer) int {
fs := flag.NewFlagSet("infra rotate-and-prune", flag.ContinueOnError)
fs.SetOutput(w)
var resourceType, name, configFile, allowlist string
var confirm, nonInteractive bool
fs.StringVar(&resourceType, "type", "", "Resource type (required, e.g. infra.spaces_key)")
fs.StringVar(&name, "name", "", "Canonical credential name to rotate (required)")
fs.StringVar(&configFile, "config", "infra.yaml", "Config file")
fs.StringVar(&configFile, "c", "infra.yaml", "Config file (short)")
// --env is accepted-and-ignored here so the dispatcher (runInfraRotateAndPruneCmd)
// can forward args verbatim including --env without the inner FlagSet
// erroring on unknown-flag. The dispatcher already uses --env to scope
// provider loading; secrets-config resolution happens via --config alone.
_ = fs.String("env", "", "Environment name (consumed by dispatcher; ignored here)")
fs.StringVar(&allowlist, "allowlist", "", "Regex matching names to skip during prune")
fs.BoolVar(&confirm, "confirm", false, "Required: explicit confirmation flag")
fs.BoolVar(&nonInteractive, "non-interactive", false, "Skip the prune y/N prompt")
if err := fs.Parse(args); err != nil {
return 2
}
Comment on lines +113 to +128
func writeRecoveryRecord(rec recoveryRecord) error {
dir, err := defaultStateDir()
if err != nil {
return fmt.Errorf("rotate-and-prune: resolve state dir: %w", err)
}
if err := os.MkdirAll(dir, 0700); err != nil {
return fmt.Errorf("rotate-and-prune: create state dir %s: %w", dir, err)
}
data, err := json.MarshalIndent(rec, "", " ")
if err != nil {
return fmt.Errorf("rotate-and-prune: marshal recovery record: %w", err)
}
path := filepath.Join(dir, "last-rotation.json")
if err := os.WriteFile(path, data, 0600); err != nil { //nolint:gosec // intentional 0600
return fmt.Errorf("rotate-and-prune: write recovery file %s: %w", path, err)
}
Comment thread cmd/wfctl/infra_prune.go
Comment on lines +181 to +189
for _, out := range outs {
ak, _ := out.Outputs["access_key"].(string)
ca, _ := out.Outputs["created_at"].(string)
name, _ := out.Outputs["name"].(string)
if ak == excludeAK {
continue
}
if allowlistRe != nil && allowlistRe.MatchString(name) {
continue
Comment thread cmd/wfctl/infra_audit_keys.go Outdated
Comment on lines +72 to +73
name, _ := o.Outputs["name"].(string)
ak, _ := o.Outputs["access_key"].(string)
Comment thread cmd/wfctl/infra_prune.go Outdated
Comment on lines +107 to +109
// move opt-in checks further from --confirm parsing.
//
//nolint:cyclop // the validation gauntlet is intentional; splitting it would
Comment thread docs/WFCTL.md
Comment on lines +1545 to +1557
```
wfctl infra prune --type <T> --created-before <RFC3339> --exclude-access-key <AK> --confirm [--non-interactive] [--allowlist <regex>] [--recovery-from-last-rotation]
```

| Flag | Default | Description |
|------|---------|-------------|
| `--type` | _(required)_ | Resource type (e.g. `infra.spaces_key`) |
| `--created-before` | _(required)_ | RFC3339 timestamp; only resources older than this are eligible |
| `--exclude-access-key` | _(required)_ | Access key to preserve (paranoia rail) |
| `--allowlist` | `` | Regex matching resource names to skip (orthogonal to time filter) |
| `--confirm` | `false` | Required: explicit confirmation flag (paired with `WFCTL_CONFIRM_PRUNE=1` env var) |
| `--non-interactive` | `false` | Skip the y/N prompt (CI-friendly) |
| `--recovery-from-last-rotation` | `false` | Read filter args from `${WFCTL_STATE_DIR:-$HOME/.wfctl}/last-rotation.json` (written by `infra rotate-and-prune` for recovery from partial-failure rotations without re-rotating) |
intel352 and others added 2 commits May 9, 2026 03:36
… + 3 Minor)

Addresses code-reviewer's HOLD on Task 19 (commit 3185c5b) of PR #584.
All five findings bundled into one commit per
feedback_commit_label_by_content_not_round.

Important #1 — flag rename: --allowlist → --preserve-names

The --allowlist flag preserved matching names (skipped them during
delete) but the name read as "list of resources allowed to be operated
on", an ambiguity that on a destructive command is a real operator-
error trap. Mental-model A would have an operator running
`--allowlist '^manual-'` expecting to delete only manual-* keys, and
instead deleting everything else (every production key).

Renamed to --preserve-names (verb in the name; matches what the impl
actually does: PRESERVE matching names, skip them during delete). No
backward-compat alias since the flag shipped today and no operator
has been instructed in any external doc to use --allowlist.

Updates:
- cmd/wfctl/infra_prune.go: runInfraPrune flag declaration + variable
  + error message
- cmd/wfctl/infra_prune.go: runInfraPruneCmd dispatcher captures
  --preserve-names + forwards as --preserve-names
- cmd/wfctl/infra_rotate_and_prune.go: runInfraRotateAndPrune flag
  declaration + variable; runInfraRotateAndPruneCmd dispatcher's
  pre-parse declaration; pruneArgs forwarding to runInfraPrune

Important #2 — typed ProviderID/Name fields with Outputs[*] fallback

The exclusion filter in runInfraPrune compared
`out.Outputs["access_key"]` against --exclude-access-key. If a future
EnumeratorAll-implementing provider populates ProviderID per the
documented contract but doesn't redundantly write
Outputs["access_key"], the exclude check would compare against an
empty string, the active credential would be added to toDelete, and
get silently deleted on a destructive run.

Same risk in audit-keys' rendering loop (Outputs[*] only).

Fix: prefer typed ProviderID/Name fields, fall back to Outputs[*] for
backward compat with providers that populate both. Applied to:
- cmd/wfctl/infra_prune.go: filter loop + dry-run rendering
- cmd/wfctl/infra_audit_keys.go: rendering loop

The DO provider (Task 15) populates both, so this is forward-looking
defense; no behavior change for the only current consumer.

Minor #1 — stray TODO fragment removed

cmd/wfctl/infra_prune.go: the line `// move opt-in checks further from
--confirm parsing.` was sandwiched between the exit-codes doc and the
//nolint:cyclop directive. It was actually the second line of the
nolint comment that gofmt split. Folded back into the nolint comment
where it belongs.

Minor #2 — conditional "recovery file retained" message

cmd/wfctl/infra_prune.go: the failure message
`%d delete(s) failed; recovery file retained at %s` fired
unconditionally on delete failure, but the recovery file only exists
if --recovery-from-last-rotation was set OR rotate-and-prune wrote it
before this invocation. Plain prune invocations were getting pointed
at a non-existent path. Conditional fix: only mention the recovery
file when one actually exists.

Minor #3 — surface os.Remove error as warning

cmd/wfctl/infra_prune.go: `_ = os.Remove(recoveryFilePath())` after
successful prune silently discarded errors. If perms changed or the
file was locked, the next --recovery-from-last-rotation invocation
would re-read stale data. Replaced with a non-fatal warning that's
loud enough for operators to hand-clean.

Verification:
  $ go test ./cmd/wfctl → ok
  $ wfctl infra prune --help        → shows -preserve-names (no -allowlist)
  $ wfctl infra rotate-and-prune --help → shows -preserve-names

Follow-up: PR #585 (docs) needs the runbook + ADR 0017 + WFCTL.md
updated to the renamed flag. Will push as a stacked commit on
docs/spaces-key-runbook.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Follow-up to abe6b7b (the code rename). Updates the four docs/WFCTL.md
references that still pointed at the old --allowlist flag name:
- prune synopsis line
- prune flag table row
- rotate-and-prune synopsis line
- rotate-and-prune flag table row

These are the only remaining --allowlist references in the workflow
repo. The runbook + ADR 0017 in docs/spaces-key-runbook (PR #585) get
matching updates in a stacked commit on that branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 9, 2026 07:39
intel352 added a commit that referenced this pull request May 9, 2026
…ionale

Follow-up to abe6b7b (the code rename in PR #584). Updates the runbook
+ ADR 0017 to use --preserve-names everywhere the operator-facing flag
appears, plus adds a rename-rationale block in each that explains why
"allowlist" was retired (ambiguous verb on a destructive command —
some operators read it as "list allowed to be operated on" / delete).

Files:
- docs/runbooks/spaces-key-prune.md:
  - "Allowlist for manual keys" section retitled "Preserving
    hand-created keys"; --allowlist → --preserve-names throughout.
  - Added a blockquote-style note explaining the rename, anchored to
    ADR 0017.
- decisions/0017-prune-cli-two-key-opt-in.md:
  - Trade-study list updated (kept the prose generic — "Name regex
    as the primary filter" rather than naming a specific flag).
  - Decision section's --preserve-names paragraph extended with the
    naming rationale explicitly.

Two remaining mentions of "allowlist" (runbook line 190, ADR 0017
line 65) are intentional — they're inside the rename-rationale text.

No structural change — runbook still 7 H2 sections; ADR still
follows workspace standard format.

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

intel352 commented May 9, 2026

Code-reviewer findings on Task 19 addressed — commits abe6b7b + 77882c2 on feat/spaces-key-clis.

Important #1 — flag rename --allowlist → --preserve-names

Renamed in:

  • cmd/wfctl/infra_prune.go (runInfraPrune flag + variable + error message; runInfraPruneCmd dispatcher capture + forward)
  • cmd/wfctl/infra_rotate_and_prune.go (runInfraRotateAndPrune flag + variable; pruneArgs forwarding to runInfraPrune; dispatcher pre-parse declaration)
  • docs/WFCTL.md (4 line refs in prune + rotate-and-prune flag tables and synopsis lines)

No backward-compat alias since the flag shipped today and no operator was instructed in any external doc to use --allowlist. Verb-in-the-name (preserve vs the original allowlist) makes the semantics unambiguous on a destructive command.

PR #585 (docs/spaces-key-runbook) gets matching runbook + ADR 0017 updates in commit c784ae6.

Important #2 — typed ProviderID/Name fields with Outputs[*] fallback

Both runInfraPrune's filter loop AND runInfraAuditKeys's render loop now prefer typed out.ProviderID / out.Name and fall back to out.Outputs["access_key"] / out.Outputs["name"] only if the typed field is empty. Defensive against future EnumeratorAll providers that follow the contract strictly without redundant Outputs writes — the original code would have silently deleted the active credential in that case.

Same defensive fallback pattern in audit-keys' rendering loop (Copilot 3212662900 / 3212670097).

Minors

  1. Stray TODO comment removed — refolded the multi-line nolint:cyclop comment into a single line so gofmt doesn't keep splitting it.
  2. Conditional "recovery file retained" message — only mention the recovery file path when one actually exists (--recovery-from-last-rotation OR rotate-and-prune wrote it).
  3. os.Remove error surfaced as warning — non-fatal warning so operators can hand-clean if perms changed or the file is locked.

Verification

$ go test ./cmd/wfctl → ok
$ wfctl infra prune --help        → shows -preserve-names (no -allowlist)
$ wfctl infra rotate-and-prune --help → shows -preserve-names

Ready for re-review.

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

Comment on lines +166 to +174
fs.StringVar(&resourceType, "type", "", "Resource type (required, e.g. infra.spaces_key)")
fs.StringVar(&name, "name", "", "Canonical credential name to rotate (required)")
fs.StringVar(&configFile, "config", "infra.yaml", "Config file")
fs.StringVar(&configFile, "c", "infra.yaml", "Config file (short)")
// --env is accepted-and-ignored here so the dispatcher (runInfraRotateAndPruneCmd)
// can forward args verbatim including --env without the inner FlagSet
// erroring on unknown-flag. The dispatcher already uses --env to scope
// provider loading; secrets-config resolution happens via --config alone.
_ = fs.String("env", "", "Environment name (consumed by dispatcher; ignored here)")
Comment thread cmd/wfctl/infra_prune.go
Comment on lines +246 to +252
ref := interfaces.ResourceRef{Type: o.Type, Name: o.Name, ProviderID: o.ProviderID}
if delErr := provider.DeleteResource(ctx, ref); delErr != nil {
fmt.Fprintf(w, "prune: delete %s: %v\n", o.Name, delErr)
failed++
continue
}
fmt.Fprintf(w, " ✓ deleted %s\n", o.Name)
Comment on lines +91 to +94
//
// Until Task 21 lands runInfraRotateAndPrune in infra_rotate_and_prune.go,
// this test fails to compile with `undefined: runInfraRotateAndPrune` — the
// failing-side signal Task 20 is supposed to produce.
Comment on lines +126 to +131
// <T>` delegates to the provider's EnumeratorAll, then renders every
// returned key's identifying fields (Name, ProviderID/access_key) into the
// writer. This is the failing test for Task 16 of the spaces-key-iac-resource
// plan (PR5). Until Task 17 implements runInfraAuditKeys + the registration
// of `wfctl infra audit-keys`, this test fails with `undefined:
// runInfraAuditKeys`.
Comment thread cmd/wfctl/infra.go
Comment on lines 90 to 100
return fmt.Errorf("audit-secrets exited with code %d", rc)
}
return nil
case "audit-keys":
return runInfraAuditKeysCmd(args[1:])
case "prune":
return runInfraPruneCmd(args[1:])
case "rotate-and-prune":
return runInfraRotateAndPruneCmd(args[1:])
default:
return infraUsage()
@intel352 intel352 merged commit 6d183e3 into main May 9, 2026
23 of 24 checks passed
@intel352 intel352 deleted the feat/spaces-key-clis branch May 9, 2026 07:53
intel352 added a commit that referenced this pull request May 9, 2026
…+24) (#585)

* docs: spaces-key prune runbook + ADRs 0015-0018 + 0020 (PR6)

Closes Tasks 23 + 24 of the spaces-key-iac-resource plan. PR6 ships
operator + design documentation for the spaces-key infrastructure
shipped in PR0-PR5.

Files:
- docs/runbooks/spaces-key-prune.md (Task 23) — operator runbook
  covering happy/multi-step/recovery/allowlist/GH-Secrets/FAQ.
- decisions/0015-spaces-key-as-iac-resource.md — Approach B (two-phase fix).
- decisions/0016-enumerator-all-interface.md — New optional interface
  for non-tag-supporting resource types.
- decisions/0017-prune-cli-two-key-opt-in.md — `--exclude-access-key`
  + `--created-before` discriminator + two-key opt-in.
- decisions/0018-bootstrap-key-exemption.md — Bootstrap key NOT an
  `infra.spaces_key` (chicken-and-egg + lifecycle separation).
- decisions/0020-storage-filter-sidecar-metadata.md — Storage filter
  by `providerCredentialSubKeys[source]` allow-list.

ADR 0019 (bootstrap-key rotation reaper) explicitly deferred per the
design's decision log. ADR 0021 (rewriteTransport security fix) was
authored separately by implementer-3 in PR4a; not modified here.

Plan: docs/plans/2026-05-08-spaces-key-iac-resource.md (commit 316559f7).

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

* docs(runbook): fix WFCTL_NEW_KEY_ACCESS_KEY false instruction + clarify opt-in terminology

Two fixes from PR #585 code review:

1. **Important** (line 98) — the multi-step variant told operators to
   capture `WFCTL_NEW_KEY_ACCESS_KEY=<ak>` from `infra bootstrap
   --force-rotate` stderr. That line is never emitted: per the
   storage-filter contract (ADR 0020), `access_key` is a CANONICAL
   credential field (in `providerCredentialSubKeys["digitalocean.spaces"]`),
   so it gets STORED as a GH Secret named `SPACES_access_key`, NOT
   logged to stderr. Only SIDECAR fields (currently just `created_at`)
   emit `WFCTL_NEW_KEY_<UPPER>=` markers.

   Operators following the false instruction would have gotten stuck
   at Step 3 with no `--exclude-access-key` value, then either
   abandoned the multi-step path or — worse — passed an empty string
   and tripped the paranoia rail with a confusing error.

   Fix: rewrite Step 2's stderr capture comment to clarify what's
   actually emitted, then add an explicit access_key recovery block
   with two options:
   - Option A: `gh secret view SPACES_access_key --repo <owner>/<repo>`
     (read directly from the GH Secrets store).
   - Option B: `wfctl infra audit-keys` + match the row whose
     CREATED_AT equals the captured WFCTL_NEW_KEY_CREATED_AT=.

2. **Minor** (line 27 + FAQ) — the Overview said "all three opt-ins"
   while the FAQ said "two-key opt-in". Both are correct, but the
   relationship was implicit. Fix: rewrite the Overview list to
   spell out that opt-ins (1) + (2) are the "two-key" authorization
   (named after the two-person rule from physical security) and (3)
   is the runtime confirmation; cross-link from the FAQ.

No structural change — runbook still has 7 H2 sections.

Stacked on docs/spaces-key-runbook per
feedback_commit_label_by_content_not_round.

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

* docs: rename --allowlist references to --preserve-names + explain rationale

Follow-up to abe6b7b (the code rename in PR #584). Updates the runbook
+ ADR 0017 to use --preserve-names everywhere the operator-facing flag
appears, plus adds a rename-rationale block in each that explains why
"allowlist" was retired (ambiguous verb on a destructive command —
some operators read it as "list allowed to be operated on" / delete).

Files:
- docs/runbooks/spaces-key-prune.md:
  - "Allowlist for manual keys" section retitled "Preserving
    hand-created keys"; --allowlist → --preserve-names throughout.
  - Added a blockquote-style note explaining the rename, anchored to
    ADR 0017.
- decisions/0017-prune-cli-two-key-opt-in.md:
  - Trade-study list updated (kept the prose generic — "Name regex
    as the primary filter" rather than naming a specific flag).
  - Decision section's --preserve-names paragraph extended with the
    naming rationale explicitly.

Two remaining mentions of "allowlist" (runbook line 190, ADR 0017
line 65) are intentional — they're inside the rename-rationale text.

No structural change — runbook still 7 H2 sections; ADR still
follows workspace standard format.

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

---------

Co-authored-by: Claude Opus 4.7 <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