From aa9e92013de60edfde6f8a1ede51ae9d49dd9a0a Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 14 May 2026 14:07:37 -0400 Subject: [PATCH 1/8] =?UTF-8?q?docs(decisions):=20ADR=200035=20=E2=80=94?= =?UTF-8?q?=20IaCStateBackend=20plugin=20integration=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the integration-gap investigation (post PR 1/2/3/6 merge) + the two operator decisions that unblock the rest of the cloud-SDK extraction: - Plugin-serve gap: ServeIaCPlugin has zero IaCStateBackend awareness; the plan's Task 11 assumed a seam that doesn't exist. - Backend-name advertisement: plugin.json capabilities.iacStateBackends as the authoring point, exposed at runtime via a new ListBackendNames RPC on the IaCStateBackend service; engine cross-checks ContractRegistry. - One type carries both concerns: azureIaCServer implements pb.IaCStateBackendServer alongside its provider interfaces. - Plan restructure: adds PR 7 (SDK serve hook + ListBackendNames RPC + manifest field) and PR 8 (engine host-wiring); PR 4 depends on PR 7, PR 5 depends on PR 4 + PR 8. The plan manifest amendment + re-alignment + re-lock is the next step and requires an operator unlock (the lock commit squash-merged into main, so the prior `git revert` unlock path is no longer cleanly available). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...tate-backend-plugin-integration-surface.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 decisions/0035-iac-state-backend-plugin-integration-surface.md diff --git a/decisions/0035-iac-state-backend-plugin-integration-surface.md b/decisions/0035-iac-state-backend-plugin-integration-surface.md new file mode 100644 index 00000000..8f16f33f --- /dev/null +++ b/decisions/0035-iac-state-backend-plugin-integration-surface.md @@ -0,0 +1,36 @@ +# 0035. IaCStateBackend plugin-serve + host-resolve integration surface + +**Status:** Accepted +**Date:** 2026-05-14 +**Decision-makers:** Jon (operator), autonomous pipeline +**Related:** docs/plans/2026-05-14-cloud-sdk-extraction.md (PRs 4–5, + new PRs 7–8), docs/plans/2026-05-14-cloud-sdk-extraction-design.md, decisions/0033, decisions/0034 + +## Context + +PRs 1/2/3/6 of the cloud-SDK-extraction plan merged to `workflow` main: the `IaCStateBackend` proto contract, the host-side scaffolding (`grpcIaCStateStore`, `iacStateBackendServer`, `module.iacStateBackendRegistry` + `IaCModule.Init` dispatch), and the `ctx`-widened `module.IaCStateStore`. The remaining plan work — PR 4 (`workflow-plugin-azure` serves `azure_blob`) and PR 5 (core deletes the in-core backend) — was then blocked by a gap the design and both adversarial-review passes missed: they vetted the design/plan *documents*, not the plugin-SDK and engine-plugin-loader *internals*. + +A focused read of `plugin/external/sdk/`, `plugin/external/adapter.go`, `engine.go`, and `plugin/manifest.go` surfaced three concrete gaps: + +1. **Plugin-serve.** `ServeIaCPlugin` → `registerIaCServicesOnly` is a fixed type-assertion cascade over the 7 `IaCProvider*` services + `ResourceDriver`. It has **zero `IaCStateBackend` awareness** — a plugin author has no hook to register that service. The plan's Task 11 ("register the IaCStateBackend service on the plugin's gRPC server") assumed a seam that does not exist. +2. **Backend-name advertisement.** The gRPC `ContractRegistry` tells the host a plugin serves *the `IaCStateBackend` service*, but not *which config-facing backend names* (`azure_blob`) it answers for. No path — manifest or RPC — carries that today. (The analogous IaC-*provider* name is read ad-hoc from `plugin.json` by a wfctl-side disk scan, not a host/engine mechanism.) +3. **Host-resolve.** `module.iacStateBackendRegistry` has **no exported `RegisterIaCStateBackend`** wrapper; `ExternalPluginAdapter` has the gRPC `Conn()` + `ContractRegistry()` accessors but no state-backend-names accessor; `engine.go`'s `loadPluginInternal` has no seam populating the registry. The plan's Task 14 was scoped as "wire `engine.go` to populate the registry" — that under-counts the real work (exported wrapper + adapter accessor + service-advertisement check + the loader seam). + +## Decision + +**Backend-name advertisement (operator decision).** `plugin.json` `capabilities.iacStateBackends: ["azure_blob", ...]` is the single authoring point; it is exposed at runtime via a **new `ListBackendNames` RPC on the `IaCStateBackend` service** (not on `IaCProviderRequired` — it is a state-backend concern and a future pure-storage plugin must still answer it). The engine calls the RPC for live truth and cross-checks the `ContractRegistry` that the `IaCStateBackend` service is actually registered. Rejected: manifest-only (a static file can drift/lie); RPC-only without a manifest authoring point (duplicates the value, no single source). + +**One type carries both concerns (operator decision).** `ServeIaCPlugin(provider any)` takes a single object and type-asserts it. The Azure plugin's `azureIaCServer` will *also* implement `pb.IaCStateBackendServer` (delegating to a ported `AzureBlobIaCStateStore`) — one type carrying both the Azure-provider and the Azure-blob-state-backend concerns. Defensible: the Azure plugin genuinely is both. Rejected: refactoring `ServeIaCPlugin` to accept multiple served objects — a larger SDK change touching every plugin's `main.go`. Consequence: a *pure* storage plugin (no IaC provider) cannot use `ServeIaCPlugin` today, because `registerIaCServicesOnly` hard-requires `pb.IaCProviderRequiredServer` — recorded as a deferred limitation, not addressed here. + +**Plan restructure.** The original Tasks 11–14 / PRs 4–5 were under-scoped. The plan is amended to: +- **PR 7 (workflow):** SDK serve hook (type-assert `pb.IaCStateBackendServer` in `registerIaCServicesOnly`) + `ListBackendNames` RPC added to `iac.proto` (regenerate) + `plugin.PluginManifest.iacStateBackends` field + the engine's manifest-read path. **Must merge before PR 4.** +- **PR 8 (workflow):** engine host-wiring — exported `module.RegisterIaCStateBackend` + `ExternalPluginAdapter.IaCStateBackendNames()` accessor + the `loadPluginInternal` optional-interface seam that calls `ListBackendNames`, cross-checks the `ContractRegistry`, and registers each name. May land in parallel with / just after PR 4. +- **PR 4 (cross-repo, `workflow-plugin-azure`):** unchanged in intent (port `AzureBlobIaCStateStore`, implement `pb.IaCStateBackendServer` on `azureIaCServer`, declare `capabilities.iacStateBackends: ["azure_blob"]`, release a plugin tag) — now depends on PR 7. +- **PR 5 (workflow):** core deletes `iac_state_azure.go` + strips the `azure_blob` case + drops `azure-sdk-for-go` from `go.mod` + the migration doc — depends on PR 4's plugin tag and PR 8. + +## Consequences + +- **Unblocks PR 4/5** with a fully-specified, gap-free integration path — no more "assumed seam doesn't exist" surprises. +- **Cost:** the plan grows 6→8 PRs / 15→~18 tasks; another scope-lock amendment cycle (manifest update → re-alignment → re-lock). One additive RPC on the already-merged `iac.proto` (more proto churn, but additive and small). +- **`iac.proto` is touched again** — the `ListBackendNames` RPC. Additive; the strict-contracts invariants (no structpb, etc.) are unaffected. +- **Deferred limitation:** pure-storage plugins (no IaC provider) cannot use `ServeIaCPlugin` until it is refactored for multiple served objects — out of scope; revisit if/when such a plugin is needed. +- **Process lesson:** adversarial design/plan review vets *documents*; it did not catch that a load-bearing seam (`ServeIaCPlugin` serving a non-provider service) did not exist in the SDK. Future designs that assume an extension point on existing infrastructure should grep-verify that point exists before the plan locks. (No skill change made here — recorded as a lesson.) From 8d04e44ebefacb624f1707e6088eb44901416cd3 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 14 May 2026 14:41:07 -0400 Subject: [PATCH 2/8] =?UTF-8?q?docs(plans):=20amend=20cloud-sdk-extraction?= =?UTF-8?q?=20plan=20=E2=80=94=20Amendment=20A2=20(PR=207=20+=20PR=208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-merge of PRs 1/2/3/6, the integration-surface investigation (decisions/0035) found the plugin-serve and host-resolve seams the design assumed did not exist. Amendment A2: - PR 7 (workflow): SDK serve hook auto-registering pb.IaCStateBackendServer (Task 17) + ListBackendNames RPC on the IaCStateBackend service (Task 16) + plugin.PluginManifest.IaCStateBackends field (Task 18). Must merge before PR 4. - PR 8 (workflow): engine host-wiring — exported RegisterIaCStateBackend + ExternalPluginAdapter.IaCStateBackendClients() + the loadPluginInternal optional-interface seam (Task 19). The expanded form of the original Task 14's engine half. - Task 14 trimmed to migration-doc-only. - PR 4 revised: depends on PR 7; azureIaCServer implements pb.IaCStateBackendServer directly (one type, both concerns per decisions/0035); the SDK serve hook auto-registers it. - Manifest: 6 PRs/15 tasks -> 8 PRs/19 tasks. PRs 1/2/3/6 marked MERGED. Execution order: PR 7 -> PR 4 -> PR 8 -> PR 5. Next: re-run alignment-check, then re-lock. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/plans/2026-05-14-cloud-sdk-extraction.md | 346 +++++++++++++----- 1 file changed, 251 insertions(+), 95 deletions(-) diff --git a/docs/plans/2026-05-14-cloud-sdk-extraction.md b/docs/plans/2026-05-14-cloud-sdk-extraction.md index dd9e7eb8..506fd038 100644 --- a/docs/plans/2026-05-14-cloud-sdk-extraction.md +++ b/docs/plans/2026-05-14-cloud-sdk-extraction.md @@ -16,11 +16,15 @@ ## Scope Manifest -**PR Count:** 6 -**Tasks:** 15 -**Estimated Lines of Change:** ~1950 (informational; not enforced) +**PR Count:** 8 +**Tasks:** 19 +**Estimated Lines of Change:** ~2300 (informational; not enforced) -**Amendment (2026-05-14):** PR 6 / Task 15 added by operator-approved scope amendment — `ctx context.Context` on `module.IaCStateStore` — see `decisions/0033-add-ctx-to-module-iac-state-store.md`. PR 4 de-gated from "HUMAN-GATE" to autonomous cross-repo per `decisions/0034-cross-repo-agent-operation-for-plugin-prs.md`. Original lock: 5 PRs / 14 tasks; manifest re-aligned + re-locked after amendment. +**Amendment history:** +- **A1 (2026-05-14):** PR 6 / Task 15 added — `ctx context.Context` on `module.IaCStateStore` — `decisions/0033`. PR 4 de-gated to autonomous cross-repo — `decisions/0034`. (Original lock: 5 PRs / 14 tasks.) +- **A2 (2026-05-14):** Post-merge of PRs 1/2/3/6, an integration-surface investigation found the plugin-serve and host-resolve seams the design assumed did not exist in `plugin/external/sdk` / `engine.go`. Added **PR 7** (SDK serve hook + `ListBackendNames` RPC + manifest field — Tasks 16/17/18) and **PR 8** (engine host-wiring — Task 19); Task 14 trimmed to migration-doc-only (its engine-wiring moved to Task 19). PR 4 depends on PR 7; PR 5 depends on PR 4 + PR 8. See `decisions/0035-iac-state-backend-plugin-integration-surface.md`. (Pre-A2: 6 PRs / 15 tasks.) + +**Merge status (as of A2):** PRs 1, 2, 3, 6 are **MERGED** to `workflow` main (#668/#669/#670/#671). PRs 4, 5, 7, 8 remain. **Out of scope:** - **Phases B (AWS), C (GCP), D (DigitalOcean)** — deferred to a follow-on plan authored *after* Phase A merges. Their concrete tasks genuinely depend on Phase A's outputs: the benchmark-validated `IaCStateBackend` proto shape, the host-side gRPC-client resolution pattern, and the plugin-side state-backend serve path. Planning them now would be fiction. The design (`docs/plans/2026-05-14-cloud-sdk-extraction-design.md`) is the authoritative spec for B/C/D; this plan delivers Phase 0 + Phase A, which the design explicitly designates as the "validates the contract end-to-end" increment. @@ -34,18 +38,20 @@ | PR # | Title | Tasks | Branch | |------|-------|-------|--------| -| 1 | Phase 0: split platform_kubernetes_kind.go + wire audit script into CI | Task 1, Task 2, Task 3 | feat/cloud-sdk-extraction-p0 | -| 2 | Phase A: IaCStateBackend proto + benchmark harness + proto lock | Task 4, Task 5, Task 6 | feat/cloud-sdk-extraction-pa-proto | -| 3 | Phase A: host-side IaCStateBackend resolution + secret-redaction + gRPC-logging guard | Task 7, Task 8, Task 9, Task 10 | feat/cloud-sdk-extraction-pa-host | +| 1 | Phase 0: split platform_kubernetes_kind.go + wire audit script into CI | Task 1, Task 2, Task 3 | feat/cloud-sdk-extraction-p0 *(MERGED #668)* | +| 2 | Phase A: IaCStateBackend proto + benchmark harness + proto lock | Task 4, Task 5, Task 6 | feat/cloud-sdk-extraction-pa-proto *(MERGED #669)* | +| 3 | Phase A: host-side IaCStateBackend resolution + secret-redaction + gRPC-logging guard | Task 7, Task 8, Task 9, Task 10 | feat/cloud-sdk-extraction-pa-host *(MERGED #670)* | | 4 | Phase A: workflow-plugin-azure implements azure_blob IaCStateBackend (cross-repo) | Task 11, Task 12 | cross-repo: `workflow-plugin-azure` repo, branch `feat/azure-blob-state-backend` | -| 5 | Phase A: core deletes iac_state_azure.go + strips azure_blob case → drops azure-sdk from go.mod | Task 13, Task 14 | feat/cloud-sdk-extraction-pa-core | -| 6 | Amendment: add `ctx context.Context` to `module.IaCStateStore` | Task 15 | feat/cloud-sdk-extraction-iacstore-ctx | +| 5 | Phase A: core deletes iac_state_azure.go + strips azure_blob case → drops azure-sdk from go.mod | Task 13, Task 14 | feat/cloud-sdk-extraction-p5-core | +| 6 | Amendment A1: add `ctx context.Context` to `module.IaCStateStore` | Task 15 | feat/cloud-sdk-extraction-iacstore-ctx *(MERGED #671)* | +| 7 | Amendment A2: SDK serve hook + `ListBackendNames` RPC + `iacStateBackends` manifest field | Task 16, Task 17, Task 18 | feat/cloud-sdk-extraction-p7-serve | +| 8 | Amendment A2: engine host-wiring for `iac.state` plugin backends | Task 19 | feat/cloud-sdk-extraction-p8-hostwire | -**Execution order:** PR 1 → PR 2 → PR 3 (Tasks 7–8) → **PR 6** → PR 3 (Tasks 9–10) → PR 4 → PR 5. PR 6 (the `ctx` amendment) executes right after PR 3's Task 7/8 land — it amends `grpcIaCStateStore` (Task 7's file) and `IaCModule` dispatch (Task 8's wiring) in place, so it must run before PR 3 is finalized. All work lands on the single `feat/cloud-sdk-extraction` branch; `finishing-a-development-branch` splits it into the 6 PR branches per this table (PR 6 stacks on PR 3). +**Execution order (remaining work, post-A2):** **PR 7** → **PR 4** (cross-repo, depends on PR 7) → **PR 8** (workflow, may run in parallel with PR 4) → **PR 5** (depends on PR 4's plugin tag + PR 8). PRs 1/2/3/6 are already merged to `workflow` main. PR 7 + PR 8 each branch off current `origin/main`; PR 4 is cross-repo; PR 5 branches off `origin/main` after PR 8 merges. No stacking among the remaining PRs — each branches off `main` directly (the earlier stack is merged). -**PR 4 is autonomous cross-repo work** (de-gated 2026-05-14, `decisions/0034-...md`). It lands in a *different git repository* — `/Users/jon/workspace/workflow-plugin-azure`. A dispatched agent operates in that repo directly; **every cross-repo agent dispatch MUST state, explicitly in its prompt, the absolute path of the repo it works in and that it is a *different* repo than the worktree** (see "Notes for the executor"). Push + PR-creation follow normal review discipline (feature branch, PR — never direct-to-default-branch). PR 5 is **blocked on PR 4's plugin release tag** existing and being installable (Task 13 Step 8 + Task 14 Step 4 runtime-launch validation load the tagged plugin binary); the release tag (Task 12) is an explicit, deliberate step but not a human gate. +**PR 4 is autonomous cross-repo work** (de-gated, `decisions/0034`). It lands in a *different git repository* — `/Users/jon/workspace/workflow-plugin-azure`. A dispatched agent operates in that repo directly; **every cross-repo agent dispatch MUST state, explicitly in its prompt, the absolute path of the repo it works in and that it is a *different* repo than the worktree** (see "Notes for the executor"). Push + PR-creation follow normal review discipline (feature branch, PR — never direct-to-default-branch). PR 4 depends on **PR 7** being merged + a `workflow` version (a `main` pseudo-version or release) carrying the `ListBackendNames` RPC + the SDK serve hook. PR 5 is **blocked on PR 4's plugin release tag** existing and being installable; the release tag (Task 12) is an explicit, deliberate step but not a human gate. -**Status:** Locked 2026-05-14T10:37:04Z +**Status:** Draft (unlock for amendment — ADR 0035) --- @@ -977,54 +983,58 @@ Rollback: `git revert` — test-only. ## PR 4 — Phase A: `workflow-plugin-azure` implements `azure_blob` `IaCStateBackend` (cross-repo) -**Repository:** `/Users/jon/workspace/workflow-plugin-azure` — a **different git repository** than the `workflow` worktree the rest of this plan runs in. Branch: `feat/azure-blob-state-backend`. Autonomous cross-repo work, not a human gate (`decisions/0034-...md`). **The agent dispatched for Tasks 11–12 MUST be told, explicitly and up front, that it operates in `/Users/jon/workspace/workflow-plugin-azure` — a different repo — and every file path in Tasks 11–12 is relative to that repo, not the worktree.** This PR depends on PR 2 (published proto) + PR 6 (ctx-ful `module.IaCStateStore`, so the plugin's `IaCStateBackendServer` is written ctx-ful); it is a prerequisite for PR 5. +**Repository:** `/Users/jon/workspace/workflow-plugin-azure` — a **different git repository** than the `workflow` worktree the rest of this plan runs in. Branch: `feat/azure-blob-state-backend`. Autonomous cross-repo work, not a human gate (`decisions/0034`). **The agent dispatched for Tasks 11–12 MUST be told, explicitly and up front, that it operates in `/Users/jon/workspace/workflow-plugin-azure` — a different repo — and every file path in Tasks 11–12 is relative to that repo, not the worktree.** + +**Depends on PR 7** (Amendment A2, `decisions/0035`): the plugin needs a `workflow` version that carries (a) the SDK serve hook so `ServeIaCPlugin` auto-registers `pb.IaCStateBackendServer`, (b) the `ListBackendNames` RPC on the `IaCStateBackend` service, and (c) the `plugin.PluginManifest.iacStateBackends` field. PRs 2 (proto) and 6 (ctx) are already on `workflow` main; PR 7 must be merged and a `workflow` `main`-pseudo-version (or release) available before this PR can `go get` it. Prerequisite for PR 5. ### Task 11: Port `AzureBlobIaCStateStore` into workflow-plugin-azure + serve it as `IaCStateBackend` **Files (in `/Users/jon/workspace/workflow-plugin-azure`):** -- Create: `internal/statebackend/azure_blob.go` (the ported store — copy from workflow's `module/iac_state_azure.go`) -- Create: `internal/statebackend/server.go` (the `IaCStateBackendServer` gRPC impl delegating to the store) -- Modify: the plugin's main entrypoint + `plugin.json` to advertise the `azure_blob` `IaCStateBackend` -- Test: `internal/statebackend/azure_blob_test.go` (port the existing tests from workflow's `module/iac_state_azure_test.go` if present; otherwise test against the `AzureBlobClient` interface with a fake) +- Modify: `go.mod` / `go.sum` (bump the `github.com/GoCodeAlone/workflow` pin to a version carrying PR 7) +- Create: `internal/statebackend/azure_blob.go` (the ported store — copy from `workflow` `origin/main`'s `module/iac_state_azure.go`) +- Modify: `internal/iacserver.go` (`azureIaCServer` gains the 6 `IaCStateBackendServer` RPCs + `ListBackendNames`, delegating to an `*AzureBlobIaCStateStore`) — possibly a new `internal/statebackend_server.go` for the delegation methods + local JSON converters if `iacserver.go` gets crowded +- Modify: `plugin.json` (`capabilities.iacStateBackends: ["azure_blob"]`) +- Test: `internal/statebackend/azure_blob_test.go` (port from `workflow` `origin/main`'s `module/iac_state_azure_test.go` if present; otherwise test against the `AzureBlobClient` interface with a fake) + a test that `azureIaCServer` satisfies `pb.IaCStateBackendServer` **Step 1: Inspect the current plugin structure** Run: `ls -R /Users/jon/workspace/workflow-plugin-azure/{cmd,internal,provider,drivers} 2>/dev/null; cat /Users/jon/workspace/workflow-plugin-azure/plugin.json` Expected: understand where `sdk.ServeIaCPlugin` is called and how `plugin.json` declares capabilities. -**Step 2: Port the store** +**Step 2: Bump the `workflow` dependency + port the store** -Copy `module/iac_state_azure.go` from the workflow worktree into `internal/statebackend/azure_blob.go` in the plugin repo. It already carries its own `AzureBlobClient` interface + `azureRealClient` (azblob-backed) impl — it is self-contained. Adjust the package name. The plugin repo *gains* the `Azure/azure-sdk-for-go/sdk/storage/azblob` dependency (it likely already has it for its IaC resource-provider role — confirm with `grep azblob go.mod`). +In the plugin's `go.mod`, bump `github.com/GoCodeAlone/workflow` to a version that carries PR 7 (`go get github.com/GoCodeAlone/workflow@main` for the `main`-pseudo-version, or the PR-7 merge SHA — confirm it resolves `pb.IaCStateBackend_ListBackendNames` + the SDK serve hook + `plugin.PluginManifest.IaCStateBackends`). Then copy `module/iac_state_azure.go` from `workflow` `origin/main` into `internal/statebackend/azure_blob.go` in the plugin repo — it carries its own `AzureBlobClient` interface + `azureRealClient` (azblob-backed) impl, self-contained; its methods are already ctx-ful (PR 6/#671 widened them). Adjust the package name. The plugin already depends on `Azure/azure-sdk-for-go/sdk/storage/azblob` (confirmed — `go.mod` lists `azblob v1.6.4`) — no new dependency. **Step 3: Port the tests, run them** -Copy `module/iac_state_azure_test.go` (if it exists in the worktree) into `internal/statebackend/azure_blob_test.go`. Run: `go test ./internal/statebackend/ -v` +Copy `module/iac_state_azure_test.go` from `workflow` `origin/main` into `internal/statebackend/azure_blob_test.go`. Run: `go test ./internal/statebackend/ -v` Expected: PASS — the store's logic is unchanged, only its home moved. -**Step 4: Write the `IaCStateBackendServer` impl** +**Step 4: Implement `pb.IaCStateBackendServer` on `azureIaCServer`** -Create `internal/statebackend/server.go` implementing `proto.IaCStateBackendServer` (from `github.com/GoCodeAlone/workflow/plugin/external/proto`) by delegating each RPC to an `AzureBlobIaCStateStore`. Use **JSON `Marshal`/`Unmarshal`** for the `Outputs`/`Config` ⇄ `OutputsJson`/`ConfigJson` `[]byte` fields — mirror the workflow-core converters from Task 7 (`iacStateToProto`/`iacStateFromProto`) exactly; the plugin imports the same `proto` package so the wire types are identical. **No `structpb`** — the `iac.proto:6-10` hard invariant forbids it. +Per `decisions/0035` (operator decision: one type carries both concerns), make the plugin's existing `azureIaCServer` (`internal/iacserver.go` — already implements `pb.IaCProviderRequiredServer` etc.) **also** implement `pb.IaCStateBackendServer`: embed `pb.UnimplementedIaCStateBackendServer`, add the 6 RPC methods delegating to an `*AzureBlobIaCStateStore` field, plus the `ListBackendNames` RPC returning `["azure_blob"]`. Use **JSON `Marshal`/`Unmarshal`** for the `Outputs`/`Config` ⇄ `OutputsJson`/`ConfigJson` `[]byte` fields — re-implement the trivial converters locally (the workflow-core `iacStateToProto`/`iacStateFromProto` are unexported in `package module`; ~40 LOC of plain JSON, do not try to import them). **No `structpb`** — `iac.proto`'s hard invariant forbids it. -**Step 5: Wire it into the plugin's serve path + manifest** +**Step 5: Advertise the backend name; serve registration is automatic** -Register the `IaCStateBackend` service on the plugin's gRPC server alongside its existing `IaCProviderRequired` service, and add `azure_blob` to the plugin's advertised state-backend capabilities in `plugin.json` (mirror how the existing `iacProvider` capability is declared — the engine's registry-population step in workflow Task 14 reads this). +The SDK serve hook from PR 7 makes `ServeIaCPlugin` auto-register `pb.IaCStateBackendServer` via type-assertion — since `azureIaCServer` now satisfies it (Step 4), **no manual registration is needed**; `cmd/workflow-plugin-azure/main.go` is unchanged. Add `"capabilities": { "iacStateBackends": ["azure_blob"] }` to `plugin.json` (the manifest field PR 7 added to `plugin.PluginManifest`) — the engine's host-wiring (PR 8 / Task 19) reads this to know the plugin serves the `azure_blob` backend name. **Step 6: Build + load-test the plugin** Run: `go build ./... && go test ./...` in the plugin repo. Expected: exit 0, PASS. -Then load-test: build the plugin binary, point a minimal workflow config with `iac.state` `backend: azure_blob` at it (using the workflow worktree's `server` binary built from PR 3's branch), and confirm the engine resolves the plugin-served backend. **Verification (plugin change class — load into host + exercise):** the engine logs the `iac.state` module constructing a `grpcIaCStateStore` for `azure_blob`, and a `SaveState`/`GetState` round-trips. Capture the transcript. +Then load-test: build the plugin binary, point a minimal `workflow` config with `iac.state` `backend: azure_blob` at it (using a `workflow` `server` binary built from `origin/main` *with PR 7 + PR 8 merged* — if PR 8 isn't merged yet, this load-test is deferred to after PR 8 and noted as such). **Verification (plugin change class — load into host + exercise):** the engine logs the `iac.state` module constructing a `grpcIaCStateStore` for `azure_blob`, and a `SaveState`/`GetState` round-trips. Capture the transcript. **Step 7: Commit (in the plugin repo)** ```bash -git add internal/statebackend/ plugin.json cmd/ +git add go.mod go.sum internal/ plugin.json git commit -m "feat: serve azure_blob IaCStateBackend -Ports AzureBlobIaCStateStore from workflow core and serves it behind the -new proto.IaCStateBackend gRPC contract. Advertises azure_blob in -plugin.json so the workflow engine resolves it at plugin-load time. This -plugin version is the prerequisite for workflow dropping its in-core +Ports AzureBlobIaCStateStore from workflow core; azureIaCServer now also +implements pb.IaCStateBackendServer (+ ListBackendNames) — the SDK serve +hook auto-registers it. Advertises azure_blob in plugin.json's +capabilities.iacStateBackends so the workflow engine resolves it at +plugin-load time. Prerequisite for workflow dropping its in-core azure_blob backend." ``` @@ -1151,82 +1161,26 @@ Rollback: revert the commit + `go mod tidy` (restores `iac_state_azure.go`, the --- -### Task 14: Migration doc + wire engine plugin-load → `iac.state` backend registry +### Task 14: Migration doc -**Integration seam (resolved at plan time — `engine.go:311-326` was read).** `loadPluginInternal` deliberately never references concrete plugin types; it injects engine capabilities into plugins via **optional-interface type-asserts** — the `stepRegistrySetter` and `slogLoggerSetter` pattern at `engine.go:316-325` (`type X interface {...}; if v, ok := p.(X); ok { ... }`). Task 14 follows that exact precedent **in reverse** (reading *from* the plugin, not injecting *into* it): define an optional interface the external-plugin adapter satisfies, type-assert `p` against it, and populate the registry. This keeps `engine.go` free of a `plugin/external` import + concrete type-assert. +**Note (Amendment A2):** this task was *trimmed* — its original engine-wiring half moved to **Task 19 (PR 8)**, because the integration-surface investigation (`decisions/0035`) showed that wiring is larger than "one optional-interface type-assert" and depends on PR 7's `ListBackendNames` RPC + manifest field. Task 14 is now the migration doc only. **Files:** - Create: `docs/migrations/2026-05-14-cloud-sdk-extraction.md` -- Create: `plugin/iac_state_backend_provider.go` — the `IaCStateBackendProvider` optional interface (in the `plugin` package, which `engine.go` already imports) -- Modify: `engine.go` — add the optional-interface type-assert in `loadPluginInternal` (beside `stepRegistrySetter` / `slogLoggerSetter`, `engine.go:311-326`) -- Modify: `plugin/external/adapter.go` — `*ExternalPluginAdapter` implements `IaCStateBackendClients()` (it has the gRPC `ClientConn` + `ContractRegistry`; this is in-repo, not cross-repo) -- Modify: `module/iac_state_plugin_registry.go` — add an exported `module.RegisterIaCStateBackend(name string, client pb.IaCStateBackendClient) error` wrapper (the registry struct itself stays unexported) -- Test: `plugin/external/adapter_test.go` (extend) + `module/iac_state_plugin_registry_test.go` (extend) + a launch check **Step 1: Write the migration doc** -Create `docs/migrations/2026-05-14-cloud-sdk-extraction.md` covering (per the design's Migration section, Phase A scope only): `iac.state` with `backend: azure_blob` now requires `wfctl plugin install workflow-plugin-azure` (≥ the Task 12 tag); the yaml `backend: azure_blob` value is unchanged; `memory`/`filesystem`/`postgres` are unaffected. Note that Phases B/C/D (AWS/GCP/DO) follow the same pattern in subsequent releases. - -**Step 2: Define the optional interface + `ExternalPluginAdapter` impl** - -In a shared location both `engine.go` and `plugin/external` can see the type (e.g. `plugin/iac_state_backend_provider.go` in the `plugin` package, which `engine.go` already imports — `engine.go:21`): - -```go -// IaCStateBackendProvider is the optional interface an external plugin adapter -// implements when it serves one or more iac.state backends. The engine -// type-asserts loaded plugins against it (same pattern as stepRegistrySetter) -// and populates module's iac.state backend registry from the result. -type IaCStateBackendProvider interface { - IaCStateBackendClients() map[string]proto.IaCStateBackendClient -} -``` - -In `plugin/external/adapter.go`, make `*ExternalPluginAdapter` implement `IaCStateBackendClients()`: it reads its own `ContractRegistry` for services advertising `workflow.plugin.external.iac.IaCStateBackend`, builds a `proto.IaCStateBackendClient` per advertised backend name off the adapter's existing gRPC `ClientConn` (mirror `typedIaCAdapter` construction in `cmd/wfctl/iac_typed_adapter.go`), and returns `name → client`. If the plugin advertises no state backend, return `nil` — the type-assert still succeeds, the map is just empty. - -**Step 3: Wire the type-assert into `loadPluginInternal`** - -In `engine.go` `loadPluginInternal`, beside the existing `stepRegistrySetter` / `slogLoggerSetter` asserts (`engine.go:311-326`), add: - -```go -if provider, ok := p.(plugin.IaCStateBackendProvider); ok { - for name, client := range provider.IaCStateBackendClients() { - if err := module.RegisterIaCStateBackend(name, client); err != nil { - return fmt.Errorf("load plugin %q: %w", p.EngineManifest().Name, err) - } - } -} -``` - -`module.RegisterIaCStateBackend` (new exported wrapper, this task) delegates to the unexported `iacStateBackendRegistry.register` from Task 8 — which already rejects reserved names, so a plugin advertising `memory`/`filesystem`/`postgres` fails plugin-load with a clear error (design Failure-modes "reserved-name collision", now actually wired). - -**Step 4: Write/extend the tests** +Create `docs/migrations/2026-05-14-cloud-sdk-extraction.md` covering (per the design's Migration section, Phase A scope only): `iac.state` with `backend: azure_blob` now requires `wfctl plugin install workflow-plugin-azure` (≥ the Task 12 release tag); the yaml `backend: azure_blob` value is unchanged; `memory`/`filesystem`/`postgres` are unaffected. Note that Phases B/C/D (AWS/GCP/DO) follow the same pattern in subsequent releases. -- `plugin/external/adapter_test.go`: a fake adapter with a `ContractRegistry` advertising `azure_blob` → `IaCStateBackendClients()` returns a one-entry map keyed `azure_blob`. -- `module/iac_state_plugin_registry_test.go`: `module.RegisterIaCStateBackend("azure_blob", fakeClient)` then `resolve("azure_blob")` succeeds; `module.RegisterIaCStateBackend("memory", fakeClient)` returns the reserved-name error. - -**Step 5: Build + test + launch validation** - -Run: `go build ./... && go test ./module/ -run 'IaCStateBackend|IaCModule' ./plugin/external/ -v` -Expected: exit 0, PASS. -Then the end-to-end launch check from Task 13 Step 8 should now work *without manual registry seeding* — the engine auto-populates from the loaded plugin. Re-run that launch with the Task 11 plugin in `./data/plugins/` and confirm `azure_blob` resolves with zero manual wiring. Capture the transcript. **Rollback note (runtime-affecting — plugin loading path):** revert the commit; the registry + dispatch plumbing from Task 8 survive, only the engine auto-population is removed; relaunch with a `memory`-backend config to confirm core backends unaffected. - -**Step 6: Commit** +**Step 2: Verify + commit** +Run: spell-check / render-preview the doc (documentation change class — no broken anchors). ```bash -git add docs/migrations/2026-05-14-cloud-sdk-extraction.md module/ engine.go plugin/ -git commit -m "feat(engine): auto-populate iac.state backend registry from loaded plugins - -At plugin-load time the engine reads each plugin's advertised -IaCStateBackend capabilities and registers a gRPC client into the -iac.state backend registry, so iac.state backend: azure_blob resolves -with zero manual wiring. Adds the user-facing migration doc. - -Rollback: revert this commit — iac.state plugin backends then require -manual registry seeding (the registry + dispatch from Task 8 remain); -core in-process backends (memory/filesystem/postgres) are unaffected." +git add docs/migrations/2026-05-14-cloud-sdk-extraction.md +git commit -m "docs(migrations): cloud-SDK extraction — azure_blob backend migration guide" ``` -Rollback: revert the commit; the registry + dispatch plumbing (Task 8) survive, only the auto-population is removed. Core backends unaffected. Relaunch with a `memory` backend config to confirm. +Rollback: `git revert` — documentation-only. --- @@ -1319,11 +1273,213 @@ Rollback: revert the commit — a mechanical signature-only widening, no data-fo --- +## PR 7 — Amendment A2: SDK serve hook + `ListBackendNames` RPC + `iacStateBackends` manifest field + +Operator-approved scope amendment (`decisions/0035`). Closes the **plugin-serve** gap the integration investigation found: `plugin/external/sdk`'s `ServeIaCPlugin` had zero `IaCStateBackend` awareness, so a plugin author had no hook to serve one. This PR adds the SDK serve hook, the `ListBackendNames` RPC (the runtime backend-name advertisement decided in `decisions/0035`), and the `plugin.PluginManifest` field (the authoring point). **Must merge before PR 4.** Branch `feat/cloud-sdk-extraction-p7-serve`, off current `origin/main` (PRs 1/2/3/6 already merged there). + +### Task 16: Add the `ListBackendNames` RPC to the `IaCStateBackend` service + +**Files:** +- Modify: `plugin/external/proto/iac.proto` +- Modify (generated): `plugin/external/proto/iac.pb.go`, `plugin/external/proto/iac_grpc.pb.go` (regenerate via `buf`) +- Test: `plugin/external/proto/iac_statebackend_test.go` (extend) + +**Step 1: Write the failing test.** Add to `plugin/external/proto/iac_statebackend_test.go`: +```go +func TestIaCStateBackendListBackendNamesGenerated(t *testing.T) { + _ = &ListBackendNamesRequest{} + resp := &ListBackendNamesResponse{BackendNames: []string{"azure_blob"}} + if resp.GetBackendNames()[0] != "azure_blob" { + t.Fatalf("ListBackendNamesResponse.BackendNames accessor missing") + } + // the RPC must be on the IaCStateBackend service interfaces: + var _ interface { + ListBackendNames(context.Context, *ListBackendNamesRequest) (*ListBackendNamesResponse, error) + } = (IaCStateBackendServer)(nil) +} +``` +(add `"context"` to the test imports if needed) + +**Step 2: Run it — verify it FAILS.** `GOWORK=off go test ./plugin/external/proto/ -run TestIaCStateBackendListBackendNamesGenerated` → FAIL (`ListBackendNamesRequest` etc. undefined). + +**Step 3: Add the RPC to `iac.proto`.** In the `service IaCStateBackend` block in `plugin/external/proto/iac.proto`, add a 7th RPC; and add the two messages alongside the existing `IaCStateBackend` messages: +```proto +service IaCStateBackend { + // ... existing 6 RPCs ... + rpc ListBackendNames(ListBackendNamesRequest) returns (ListBackendNamesResponse); +} + +// ListBackendNames lets the engine ask a loaded plugin which iac.state backend +// NAMES it serves (e.g. "azure_blob"). The plugin answers from its +// plugin.json capabilities.iacStateBackends (PluginManifest.IaCStateBackends). +message ListBackendNamesRequest {} +message ListBackendNamesResponse { repeated string backend_names = 1; } +``` +Honors the `iac.proto:6-10` hard invariant (no structpb/Any — these are plain scalar/repeated-string messages). + +**Step 4: Regenerate.** `buf generate` from the worktree root. `git diff --stat` shows only `iac.proto` + the 2 `*.pb.go` files. + +**Step 5: Run the test — verify it PASSES.** `GOWORK=off go test ./plugin/external/proto/ -run TestIaCStateBackend -v` → PASS (the new test + the existing `TestIaCStateBackendGeneratedTypesExist`). + +**Step 6: Build + commit.** `GOWORK=off go build ./...` exit 0. (No `wftest/bdd` change — `iacServiceChecks` is per-*service*; `IaCStateBackend` is already in that table from PR 2. Adding an RPC to an existing service does not change service-coverage.) +```bash +git add plugin/external/proto/iac.proto plugin/external/proto/iac.pb.go plugin/external/proto/iac_grpc.pb.go plugin/external/proto/iac_statebackend_test.go +git commit -m "feat(proto): add ListBackendNames RPC to IaCStateBackend service + +Lets the engine ask a loaded plugin which iac.state backend NAMES it +serves. Additive; honors the iac.proto no-structpb invariant. Part of +Amendment A2 (decisions/0035)." +``` +Rollback: `git revert` — additive proto + generated code, no runtime wiring yet. + +### Task 17: SDK serve hook — auto-register `pb.IaCStateBackendServer` + +**Files:** +- Modify: `plugin/external/sdk/iacserver.go` +- Test: `plugin/external/sdk/iacserver_test.go` (extend) + +**Step 1: Write the failing test.** Add a test in `plugin/external/sdk/iacserver_test.go`: construct a fake provider that satisfies BOTH `pb.IaCProviderRequiredServer` (the required minimum) AND `pb.IaCStateBackendServer` (embed both `pb.Unimplemented*` types); call `RegisterAllIaCProviderServices(grpc.NewServer(), fake)`; assert the server's `GetServiceInfo()` includes `"workflow.plugin.external.iac.IaCStateBackend"`. Mirror the existing `iacserver_test.go` registration tests' shape (read them first). + +**Step 2: Run it — verify it FAILS.** `GOWORK=off go test ./plugin/external/sdk/ -run ` → FAIL (the service is never registered). + +**Step 3: Add the type-assertion block.** In `registerIaCServicesOnly` (`plugin/external/sdk/iacserver.go`, after the existing `ResourceDriver` block, before the function returns), add: +```go +if v, ok := provider.(pb.IaCStateBackendServer); ok { + pb.RegisterIaCStateBackendServer(s, v) +} +``` +Update the `RegisterAllIaCProviderServices` doc-comment header to list `IaCStateBackend` among the optional services it auto-detects. Note in a code comment: per `decisions/0035`, `IaCStateBackend` is an *optional* service auto-detected by type-assertion exactly like the `IaCProvider*` optionals — a pure-storage plugin (no `IaCProviderRequired`) is a deferred limitation, not addressed here. + +**Step 4: Run the test — verify it PASSES.** `GOWORK=off go test ./plugin/external/sdk/ -v` → PASS (new test + all existing sdk tests). + +**Step 5: Build + commit.** `GOWORK=off go build ./... && GOWORK=off go vet ./plugin/external/...` exit 0. +```bash +git add plugin/external/sdk/iacserver.go plugin/external/sdk/iacserver_test.go +git commit -m "feat(sdk): ServeIaCPlugin auto-registers pb.IaCStateBackendServer + +registerIaCServicesOnly now type-asserts pb.IaCStateBackendServer and +registers it, alongside the IaCProvider* optionals — so a plugin whose +provider type also implements the state-backend interface serves it with +no extra wiring. Amendment A2 (decisions/0035)." +``` +Rollback: `git revert` — additive type-assertion; plugins that don't implement `IaCStateBackendServer` are unaffected. + +### Task 18: `plugin.PluginManifest.IaCStateBackends` field + engine manifest-read path + +**Files:** +- Modify: `plugin/manifest.go` (add the field to `PluginManifest`) +- Modify: the engine's `plugin.json`→`PluginManifest` decode path (confirm via Step 1) +- Test: `plugin/manifest_test.go` (extend) or the engine manifest-load test + +**Step 1: Investigate the manifest shape + read path (≤15 min).** Read `plugin/manifest.go` — the `PluginManifest` struct + how `capabilities` is modeled (the investigation noted `CapabilityDecl` has only `Name`/`Role`/`Priority`, and the disk `plugin.json` `capabilities` is an object — there may be a separate decode shape). Find where the engine decodes a plugin's `plugin.json` into `PluginManifest` (grep `PluginManifest` + `json.Unmarshal` + `plugin.json` across `plugin/`). Record the exact decode path in a one-line comment on the new field. **Decide the field's home** based on what you find: most likely a new `IaCStateBackends []string \`json:"iacStateBackends,omitempty"\`` on `PluginManifest` (or on whatever struct decodes the `capabilities` object). The plugin's `plugin.json` will carry `"capabilities": { ..., "iacStateBackends": ["azure_blob"] }` — match the JSON path to that. + +**Step 2: Write the failing test.** A test that decodes a `plugin.json` fixture containing `capabilities.iacStateBackends: ["azure_blob"]` and asserts the decoded `PluginManifest` exposes `["azure_blob"]` via the new field. + +**Step 3: Run it — verify it FAILS.** Field doesn't exist yet → compile error or empty field. + +**Step 4: Add the field + decode wiring.** Add the `IaCStateBackends []string` field per Step 1's decision; ensure the `plugin.json` decode path populates it. Keep it `omitempty` — the vast majority of plugins serve no state backend. + +**Step 5: Run the test — verify it PASSES.** Plus `GOWORK=off go build ./... && GOWORK=off go test ./plugin/... -run Manifest`. + +**Step 6: Commit.** +```bash +git add plugin/manifest.go plugin/manifest_test.go +# + any other file in the decode path +git commit -m "feat(plugin): PluginManifest.IaCStateBackends — plugin.json backend-name advertisement + +A plugin declares the iac.state backend names it serves via +plugin.json capabilities.iacStateBackends; this adds the field to +PluginManifest + the decode path. The engine (PR 8 / Task 19) reads it +and cross-checks against the ListBackendNames RPC. Amendment A2." +``` +Rollback: `git revert` — additive optional field; absent → empty slice, no behavior change. + +--- + +## PR 8 — Amendment A2: engine host-wiring for `iac.state` plugin backends + +Operator-approved scope amendment (`decisions/0035`). Closes the **host-resolve** gap: `module.iacStateBackendRegistry` has no exported populator, `ExternalPluginAdapter` exposes no state-backend accessor, and `engine.go`'s `loadPluginInternal` has no seam to populate the registry from loaded plugins. This is the expanded form of the original Task 14's engine half. Branch `feat/cloud-sdk-extraction-p8-hostwire`, off current `origin/main`. May run in parallel with PR 4; **must merge before PR 5** (PR 5's launch validation needs the engine to actually resolve a plugin-served `azure_blob`). Depends on PR 7 being merged (uses the `ListBackendNames` RPC + `PluginManifest.IaCStateBackends`). + +### Task 19: Wire engine plugin-load → `iacStateBackendRegistry` + +**Integration seam (`decisions/0035`):** the engine, at plugin-load, for each loaded external plugin: (1) confirms the plugin's `ContractRegistry` advertises the `IaCStateBackend` service, (2) calls the `ListBackendNames` RPC for the live backend-name list, (3) builds one `pb.IaCStateBackendClient` off the adapter's gRPC conn, (4) registers each name → that client. The RPC call + cross-check live in `ExternalPluginAdapter` (it has the conn + `ContractRegistry()`); `engine.go` stays a thin optional-interface type-assert (the `stepRegistrySetter`/`slogLoggerSetter` pattern at `engine.go:311-326`). + +**Files:** +- Modify: `module/iac_state_plugin_registry.go` — exported `RegisterIaCStateBackend(name string, client pb.IaCStateBackendClient) error` wrapper around the unexported `iacStateBackendRegistryInstance.register` +- Create: `plugin/iac_state_backend_provider.go` — the `IaCStateBackendProvider` optional interface (package `plugin`, which `engine.go` already imports) +- Modify: `plugin/external/adapter.go` — `*ExternalPluginAdapter` implements `IaCStateBackendClients()` +- Modify: `engine.go` — the optional-interface type-assert in `loadPluginInternal` +- Test: `module/iac_state_plugin_registry_test.go`, `plugin/external/adapter_test.go` (extend) + a launch check + +**Step 1: Exported registry wrapper.** In `module/iac_state_plugin_registry.go` add: +```go +// RegisterIaCStateBackend registers a plugin-served iac.state backend client +// under name. Exported so engine.go (package workflow) can populate the +// registry at plugin-load time. Rejects reserved core names. +func RegisterIaCStateBackend(name string, client pb.IaCStateBackendClient) error { + return iacStateBackendRegistryInstance.register(name, client) +} +``` +Test in `module/iac_state_plugin_registry_test.go`: `RegisterIaCStateBackend("azure_blob", fake)` then `resolve("azure_blob")` succeeds; `RegisterIaCStateBackend("memory", fake)` returns the reserved-name error. + +**Step 2: The optional interface.** Create `plugin/iac_state_backend_provider.go` (package `plugin`): +```go +// IaCStateBackendProvider is the optional interface an external-plugin adapter +// implements when its plugin serves one or more iac.state backends. The engine +// type-asserts loaded plugins against it (same pattern as stepRegistrySetter) +// and populates module's iac.state backend registry. +type IaCStateBackendProvider interface { + IaCStateBackendClients() (map[string]proto.IaCStateBackendClient, error) +} +``` + +**Step 3: `ExternalPluginAdapter.IaCStateBackendClients()`.** In `plugin/external/adapter.go`, implement the method on `*ExternalPluginAdapter`: +- If `ContractRegistry()` does NOT advertise `workflow.plugin.external.iac.IaCStateBackend` → return `(nil, nil)` (plugin serves no state backend; harmless). +- Else: build `client := proto.NewIaCStateBackendClient(a.Conn())`; call `client.ListBackendNames(ctx, &proto.ListBackendNamesRequest{})` for the names; **cross-check** against the plugin's `PluginManifest.IaCStateBackends` (from PR 7) — if the RPC and the manifest disagree, return an error naming the discrepancy (a plugin whose live RPC contradicts its manifest is misconfigured). Return `map[name]client` (every name → the same client; the client is per-plugin-connection, not per-backend). +- Use a bounded context (`context.Background()` with a timeout) for the `ListBackendNames` call. + +**Step 4: Wire `loadPluginInternal`.** In `engine.go`, beside the existing `stepRegistrySetter`/`slogLoggerSetter` type-asserts (`engine.go:311-326`): +```go +if sb, ok := p.(plugin.IaCStateBackendProvider); ok { + clients, err := sb.IaCStateBackendClients() + if err != nil { + return fmt.Errorf("load plugin %q: iac.state backends: %w", p.EngineManifest().Name, err) + } + for name, client := range clients { + if err := module.RegisterIaCStateBackend(name, client); err != nil { + return fmt.Errorf("load plugin %q: %w", p.EngineManifest().Name, err) + } + } +} +``` + +**Step 5: Tests.** `plugin/external/adapter_test.go`: a fake adapter whose `ContractRegistry` advertises `IaCStateBackend` and whose `ListBackendNames` returns `["azure_blob"]` → `IaCStateBackendClients()` returns a one-entry map; a fake where RPC and manifest disagree → error. Build + `GOWORK=off go test ./module/ ./plugin/... -run 'IaCStateBackend|Manifest'`. + +**Step 6: Launch validation (runtime-affecting — plugin loading path).** Build the `server` binary; with the Task 11 plugin (PR 4) installed in `./data/plugins/`, launch with an `iac.state` `backend: azure_blob` config; confirm the engine populates the registry + `IaCModule.Init` resolves a `grpcIaCStateStore`. If PR 4 isn't merged/tagged yet, this launch check is deferred until it is, and noted as such in the PR body. **Rollback note:** revert the commit — the registry + `IaCModule.Init` dispatch (from the merged PR 3) survive; only the engine auto-population is removed; core `memory`/`filesystem`/`postgres` backends are unaffected. + +**Step 7: Commit.** +```bash +git add module/iac_state_plugin_registry.go module/iac_state_plugin_registry_test.go plugin/iac_state_backend_provider.go plugin/external/adapter.go plugin/external/adapter_test.go engine.go +git commit -m "feat(engine): populate iac.state backend registry from loaded plugins + +At plugin-load, the engine type-asserts plugins for IaCStateBackendProvider, +calls the plugin's ListBackendNames RPC (cross-checked against its +PluginManifest.IaCStateBackends), and registers each backend name. So +iac.state backend: azure_blob resolves to a plugin-served gRPC backend +with zero manual wiring. Amendment A2 (decisions/0035). + +Rollback: revert — registry + IaCModule.Init dispatch survive; only the +engine auto-population is removed; core backends unaffected." +``` + +--- + ## Notes for the executor - **TDD discipline:** every task above follows write-test → see-it-fail → implement → see-it-pass → commit. Do not skip the "see it fail" step — it proves the test exercises the new behavior. (Task 15 is a mechanical interface widening — there the *compiler* is the failing test: Step 1 widens the interface, Step 2 confirms the build breaks everywhere, Steps 3–5 fix it.) - **Cross-repo PR 4 (autonomous, NOT a human gate):** Tasks 11–12 run in `/Users/jon/workspace/workflow-plugin-azure` — a *different repo*. The dispatched agent operates there directly; its prompt MUST state the absolute repo path and that it is a different repo than the worktree (`decisions/0034-...md`). Push + PR follow normal review discipline (feature branch, never direct-to-default). PR 4 must merge + the release tag (Task 12) must exist before PR 5. - **Every cross-repo agent dispatch** (PR 4 here, and all plugin PRs in the deferred B/C/D plan) carries a fixed prompt obligation: state the absolute path of the repo it works in + that it differs from the worktree + which repo each file path belongs to. The orchestrator verifies `git -C log` after cross-repo commits. -- **PR ordering:** PR 1 → PR 2 → PR 3 (Tasks 7–8) → PR 6 → PR 3 (Tasks 9–10) → PR 4 → PR 5. PR 5 is the only `go.mod`-touching breaking change. PR 6 stacks on PR 3; `finishing-a-development-branch` splits the single working branch into the 6 PR branches. +- **PR ordering:** PRs 1/2/3/6 are **MERGED** (#668/#669/#670/#671). Remaining: **PR 7 → PR 4 → PR 8 → PR 5** (PR 8 may run in parallel with PR 4; both must merge before PR 5). PR 7 + PR 8 + PR 5 each branch off current `origin/main` (no stacking — the earlier stack is merged). PR 4 is cross-repo. PR 5 is the only `go.mod`-touching breaking change. Each remaining PR: branch off latest `origin/main`, execute its task(s), push, PR, monitor, admin-merge on green. +- **Amendment A2 dependency chain:** PR 4 cannot `go get` what it needs until PR 7 is merged AND a `workflow` `main`-pseudo-version (or release) carrying it exists. PR 5's launch validation needs PR 8 merged (so the engine actually resolves a plugin-served backend) AND PR 4's plugin tag. So: merge PR 7 → (PR 4 plugin work + PR 8 in parallel) → merge both → PR 5. - **Benchmark gate (Task 6) — RESOLVED:** the benchmark measured 6.51 ms (1 MB state); root-cause analysis showed the cost is JSON serialization (inherent to the `bytes *_json` wire format), not gRPC transport, so the plan's streaming-redesign contingency was mis-targeted. Operator confirmed unary is acceptable. **Unary is LOCKED** — see `docs/plans/2026-05-14-iac-state-backend-benchmark.md`. No streaming redesign. - **Follow-on plan:** once PR 5 merges, author the Phase B/C/D plan. Phase B (AWS) reuses Task 7's converters + Task 8's registry + Task 11's plugin pattern + the now-ctx-ful interface from PR 6; Phase C (GCP) additionally runs the `kubernetesBackend` interface-audit spike for the `gke` contract decision (design Architecture §2); Phase D (DigitalOcean `spaces`) rides Phase B's `iac_state_spaces.go` deletion. The IaC state at-rest format follow-up (`docs/plans/2026-05-14-iac-state-backend-benchmark.md` §"Logged follow-up") is a separate post-extraction item. From 7ded3e14f490453b672e8409250a8c87edd8f89c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 14 May 2026 14:44:15 -0400 Subject: [PATCH 3/8] =?UTF-8?q?chore:=20re-lock=20scope=20for=20cloud-sdk-?= =?UTF-8?q?extraction=20(Amendment=20A2=20=E2=80=94=20alignment=20re-passe?= =?UTF-8?q?d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-05-14-cloud-sdk-extraction.md | 2 +- docs/plans/2026-05-14-cloud-sdk-extraction.md.scope-lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-05-14-cloud-sdk-extraction.md b/docs/plans/2026-05-14-cloud-sdk-extraction.md index 506fd038..d9fdb110 100644 --- a/docs/plans/2026-05-14-cloud-sdk-extraction.md +++ b/docs/plans/2026-05-14-cloud-sdk-extraction.md @@ -51,7 +51,7 @@ **PR 4 is autonomous cross-repo work** (de-gated, `decisions/0034`). It lands in a *different git repository* — `/Users/jon/workspace/workflow-plugin-azure`. A dispatched agent operates in that repo directly; **every cross-repo agent dispatch MUST state, explicitly in its prompt, the absolute path of the repo it works in and that it is a *different* repo than the worktree** (see "Notes for the executor"). Push + PR-creation follow normal review discipline (feature branch, PR — never direct-to-default-branch). PR 4 depends on **PR 7** being merged + a `workflow` version (a `main` pseudo-version or release) carrying the `ListBackendNames` RPC + the SDK serve hook. PR 5 is **blocked on PR 4's plugin release tag** existing and being installable; the release tag (Task 12) is an explicit, deliberate step but not a human gate. -**Status:** Draft (unlock for amendment — ADR 0035) +**Status:** Locked 2026-05-14T18:43:55Z --- diff --git a/docs/plans/2026-05-14-cloud-sdk-extraction.md.scope-lock b/docs/plans/2026-05-14-cloud-sdk-extraction.md.scope-lock index 25192df5..a1589691 100644 --- a/docs/plans/2026-05-14-cloud-sdk-extraction.md.scope-lock +++ b/docs/plans/2026-05-14-cloud-sdk-extraction.md.scope-lock @@ -1 +1 @@ -9e5a436fd1335fb1b3b530675b3b9b3dab6a271178910ab434655713e7569868 +7febd8ef774cfaf8c493f72edd7d6d4f662fcaba9f622e3ef5786f63a5d9e6ae From 16aa7f02eb2cec909668d708179b900e867ed496 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 14 May 2026 14:47:06 -0400 Subject: [PATCH 4/8] feat(proto): add ListBackendNames RPC to IaCStateBackend service Lets the engine ask a loaded plugin which iac.state backend NAMES it serves. Additive; honors the iac.proto no-structpb invariant. Part of Amendment A2 (decisions/0035). --- plugin/external/proto/iac.pb.go | 221 ++++++++++++------ plugin/external/proto/iac.proto | 7 + plugin/external/proto/iac_grpc.pb.go | 50 +++- .../external/proto/iac_statebackend_test.go | 17 +- 4 files changed, 223 insertions(+), 72 deletions(-) diff --git a/plugin/external/proto/iac.pb.go b/plugin/external/proto/iac.pb.go index 25ddcc40..99236980 100644 --- a/plugin/external/proto/iac.pb.go +++ b/plugin/external/proto/iac.pb.go @@ -5191,6 +5191,89 @@ func (*UnlockResponse) Descriptor() ([]byte, []int) { return file_iac_proto_rawDescGZIP(), []int{91} } +// ListBackendNames lets the engine ask a loaded plugin which iac.state backend +// NAMES it serves (e.g. "azure_blob"). The plugin answers from its +// plugin.json capabilities.iacStateBackends (PluginManifest.IaCStateBackends). +type ListBackendNamesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListBackendNamesRequest) Reset() { + *x = ListBackendNamesRequest{} + mi := &file_iac_proto_msgTypes[92] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListBackendNamesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListBackendNamesRequest) ProtoMessage() {} + +func (x *ListBackendNamesRequest) ProtoReflect() protoreflect.Message { + mi := &file_iac_proto_msgTypes[92] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListBackendNamesRequest.ProtoReflect.Descriptor instead. +func (*ListBackendNamesRequest) Descriptor() ([]byte, []int) { + return file_iac_proto_rawDescGZIP(), []int{92} +} + +type ListBackendNamesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + BackendNames []string `protobuf:"bytes,1,rep,name=backend_names,json=backendNames,proto3" json:"backend_names,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListBackendNamesResponse) Reset() { + *x = ListBackendNamesResponse{} + mi := &file_iac_proto_msgTypes[93] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListBackendNamesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListBackendNamesResponse) ProtoMessage() {} + +func (x *ListBackendNamesResponse) ProtoReflect() protoreflect.Message { + mi := &file_iac_proto_msgTypes[93] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListBackendNamesResponse.ProtoReflect.Descriptor instead. +func (*ListBackendNamesResponse) Descriptor() ([]byte, []int) { + return file_iac_proto_rawDescGZIP(), []int{93} +} + +func (x *ListBackendNamesResponse) GetBackendNames() []string { + if x != nil { + return x.BackendNames + } + return nil +} + var File_iac_proto protoreflect.FileDescriptor const file_iac_proto_rawDesc = "" + @@ -5557,7 +5640,10 @@ const file_iac_proto_rawDesc = "" + "\rUnlockRequest\x12\x1f\n" + "\vresource_id\x18\x01 \x01(\tR\n" + "resourceId\"\x10\n" + - "\x0eUnlockResponse*m\n" + + "\x0eUnlockResponse\"\x19\n" + + "\x17ListBackendNamesRequest\"?\n" + + "\x18ListBackendNamesResponse\x12#\n" + + "\rbackend_names\x18\x01 \x03(\tR\fbackendNames*m\n" + "\n" + "DriftClass\x12\x17\n" + "\x13DRIFT_CLASS_UNKNOWN\x10\x00\x12\x17\n" + @@ -5604,7 +5690,7 @@ const file_iac_proto_rawDesc = "" + "\x05Scale\x122.workflow.plugin.external.iac.ResourceScaleRequest\x1a3.workflow.plugin.external.iac.ResourceScaleResponse\x12\x82\x01\n" + "\vHealthCheck\x128.workflow.plugin.external.iac.ResourceHealthCheckRequest\x1a9.workflow.plugin.external.iac.ResourceHealthCheckResponse\x12x\n" + "\rSensitiveKeys\x122.workflow.plugin.external.iac.SensitiveKeysRequest\x1a3.workflow.plugin.external.iac.SensitiveKeysResponse\x12u\n" + - "\fTroubleshoot\x121.workflow.plugin.external.iac.TroubleshootRequest\x1a2.workflow.plugin.external.iac.TroubleshootResponse2\x93\x05\n" + + "\fTroubleshoot\x121.workflow.plugin.external.iac.TroubleshootRequest\x1a2.workflow.plugin.external.iac.TroubleshootResponse2\x97\x06\n" + "\x0fIaCStateBackend\x12i\n" + "\bGetState\x12-.workflow.plugin.external.iac.GetStateRequest\x1a..workflow.plugin.external.iac.GetStateResponse\x12l\n" + "\tSaveState\x12..workflow.plugin.external.iac.SaveStateRequest\x1a/.workflow.plugin.external.iac.SaveStateResponse\x12o\n" + @@ -5612,7 +5698,8 @@ const file_iac_proto_rawDesc = "" + "ListStates\x12/.workflow.plugin.external.iac.ListStatesRequest\x1a0.workflow.plugin.external.iac.ListStatesResponse\x12r\n" + "\vDeleteState\x120.workflow.plugin.external.iac.DeleteStateRequest\x1a1.workflow.plugin.external.iac.DeleteStateResponse\x12]\n" + "\x04Lock\x12).workflow.plugin.external.iac.LockRequest\x1a*.workflow.plugin.external.iac.LockResponse\x12c\n" + - "\x06Unlock\x12+.workflow.plugin.external.iac.UnlockRequest\x1a,.workflow.plugin.external.iac.UnlockResponseB=Z;github.com/GoCodeAlone/workflow/plugin/external/proto;protob\x06proto3" + "\x06Unlock\x12+.workflow.plugin.external.iac.UnlockRequest\x1a,.workflow.plugin.external.iac.UnlockResponse\x12\x81\x01\n" + + "\x10ListBackendNames\x125.workflow.plugin.external.iac.ListBackendNamesRequest\x1a6.workflow.plugin.external.iac.ListBackendNamesResponseB=Z;github.com/GoCodeAlone/workflow/plugin/external/proto;protob\x06proto3" var ( file_iac_proto_rawDescOnce sync.Once @@ -5627,7 +5714,7 @@ func file_iac_proto_rawDescGZIP() []byte { } var file_iac_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_iac_proto_msgTypes = make([]protoimpl.MessageInfo, 101) +var file_iac_proto_msgTypes = make([]protoimpl.MessageInfo, 103) var file_iac_proto_goTypes = []any{ (DriftClass)(0), // 0: workflow.plugin.external.iac.DriftClass (PlanDiagnosticSeverity)(0), // 1: workflow.plugin.external.iac.PlanDiagnosticSeverity @@ -5723,41 +5810,43 @@ var file_iac_proto_goTypes = []any{ (*LockResponse)(nil), // 91: workflow.plugin.external.iac.LockResponse (*UnlockRequest)(nil), // 92: workflow.plugin.external.iac.UnlockRequest (*UnlockResponse)(nil), // 93: workflow.plugin.external.iac.UnlockResponse - nil, // 94: workflow.plugin.external.iac.ResourceOutput.SensitiveEntry - nil, // 95: workflow.plugin.external.iac.IaCPlan.InputSnapshotEntry - nil, // 96: workflow.plugin.external.iac.ApplyResult.InitialInputSnapshotEntry - nil, // 97: workflow.plugin.external.iac.ApplyResult.ReplaceIdMapEntry - nil, // 98: workflow.plugin.external.iac.BootstrapResult.EnvVarsEntry - nil, // 99: workflow.plugin.external.iac.MigrationRepairRequest.EnvEntry - nil, // 100: workflow.plugin.external.iac.DetectDriftWithSpecsRequest.SpecsEntry - nil, // 101: workflow.plugin.external.iac.DetectDriftConfigRequest.SpecsEntry - nil, // 102: workflow.plugin.external.iac.ListStatesRequest.FilterEntry - (*timestamppb.Timestamp)(nil), // 103: google.protobuf.Timestamp + (*ListBackendNamesRequest)(nil), // 94: workflow.plugin.external.iac.ListBackendNamesRequest + (*ListBackendNamesResponse)(nil), // 95: workflow.plugin.external.iac.ListBackendNamesResponse + nil, // 96: workflow.plugin.external.iac.ResourceOutput.SensitiveEntry + nil, // 97: workflow.plugin.external.iac.IaCPlan.InputSnapshotEntry + nil, // 98: workflow.plugin.external.iac.ApplyResult.InitialInputSnapshotEntry + nil, // 99: workflow.plugin.external.iac.ApplyResult.ReplaceIdMapEntry + nil, // 100: workflow.plugin.external.iac.BootstrapResult.EnvVarsEntry + nil, // 101: workflow.plugin.external.iac.MigrationRepairRequest.EnvEntry + nil, // 102: workflow.plugin.external.iac.DetectDriftWithSpecsRequest.SpecsEntry + nil, // 103: workflow.plugin.external.iac.DetectDriftConfigRequest.SpecsEntry + nil, // 104: workflow.plugin.external.iac.ListStatesRequest.FilterEntry + (*timestamppb.Timestamp)(nil), // 105: google.protobuf.Timestamp } var file_iac_proto_depIdxs = []int32{ 4, // 0: workflow.plugin.external.iac.ResourceSpec.hints:type_name -> workflow.plugin.external.iac.ResourceHints - 103, // 1: workflow.plugin.external.iac.ResourceState.created_at:type_name -> google.protobuf.Timestamp - 103, // 2: workflow.plugin.external.iac.ResourceState.updated_at:type_name -> google.protobuf.Timestamp - 103, // 3: workflow.plugin.external.iac.ResourceState.last_drift_check:type_name -> google.protobuf.Timestamp - 94, // 4: workflow.plugin.external.iac.ResourceOutput.sensitive:type_name -> workflow.plugin.external.iac.ResourceOutput.SensitiveEntry + 105, // 1: workflow.plugin.external.iac.ResourceState.created_at:type_name -> google.protobuf.Timestamp + 105, // 2: workflow.plugin.external.iac.ResourceState.updated_at:type_name -> google.protobuf.Timestamp + 105, // 3: workflow.plugin.external.iac.ResourceState.last_drift_check:type_name -> google.protobuf.Timestamp + 96, // 4: workflow.plugin.external.iac.ResourceOutput.sensitive:type_name -> workflow.plugin.external.iac.ResourceOutput.SensitiveEntry 10, // 5: workflow.plugin.external.iac.DiffResult.changes:type_name -> workflow.plugin.external.iac.FieldChange 0, // 6: workflow.plugin.external.iac.DriftResult.class:type_name -> workflow.plugin.external.iac.DriftClass - 103, // 7: workflow.plugin.external.iac.Diagnostic.at:type_name -> google.protobuf.Timestamp + 105, // 7: workflow.plugin.external.iac.Diagnostic.at:type_name -> google.protobuf.Timestamp 1, // 8: workflow.plugin.external.iac.PlanDiagnostic.severity:type_name -> workflow.plugin.external.iac.PlanDiagnosticSeverity 2, // 9: workflow.plugin.external.iac.PlanAction.resource:type_name -> workflow.plugin.external.iac.ResourceSpec 7, // 10: workflow.plugin.external.iac.PlanAction.current:type_name -> workflow.plugin.external.iac.ResourceState 10, // 11: workflow.plugin.external.iac.PlanAction.changes:type_name -> workflow.plugin.external.iac.FieldChange 17, // 12: workflow.plugin.external.iac.IaCPlan.actions:type_name -> workflow.plugin.external.iac.PlanAction - 103, // 13: workflow.plugin.external.iac.IaCPlan.created_at:type_name -> google.protobuf.Timestamp - 95, // 14: workflow.plugin.external.iac.IaCPlan.input_snapshot:type_name -> workflow.plugin.external.iac.IaCPlan.InputSnapshotEntry + 105, // 13: workflow.plugin.external.iac.IaCPlan.created_at:type_name -> google.protobuf.Timestamp + 97, // 14: workflow.plugin.external.iac.IaCPlan.input_snapshot:type_name -> workflow.plugin.external.iac.IaCPlan.InputSnapshotEntry 8, // 15: workflow.plugin.external.iac.ApplyResult.resources:type_name -> workflow.plugin.external.iac.ResourceOutput 19, // 16: workflow.plugin.external.iac.ApplyResult.errors:type_name -> workflow.plugin.external.iac.ActionError - 96, // 17: workflow.plugin.external.iac.ApplyResult.initial_input_snapshot:type_name -> workflow.plugin.external.iac.ApplyResult.InitialInputSnapshotEntry + 98, // 17: workflow.plugin.external.iac.ApplyResult.initial_input_snapshot:type_name -> workflow.plugin.external.iac.ApplyResult.InitialInputSnapshotEntry 13, // 18: workflow.plugin.external.iac.ApplyResult.input_drift_report:type_name -> workflow.plugin.external.iac.DriftEntry - 97, // 19: workflow.plugin.external.iac.ApplyResult.replace_id_map:type_name -> workflow.plugin.external.iac.ApplyResult.ReplaceIdMapEntry + 99, // 19: workflow.plugin.external.iac.ApplyResult.replace_id_map:type_name -> workflow.plugin.external.iac.ApplyResult.ReplaceIdMapEntry 19, // 20: workflow.plugin.external.iac.DestroyResult.errors:type_name -> workflow.plugin.external.iac.ActionError - 98, // 21: workflow.plugin.external.iac.BootstrapResult.env_vars:type_name -> workflow.plugin.external.iac.BootstrapResult.EnvVarsEntry - 99, // 22: workflow.plugin.external.iac.MigrationRepairRequest.env:type_name -> workflow.plugin.external.iac.MigrationRepairRequest.EnvEntry + 100, // 21: workflow.plugin.external.iac.BootstrapResult.env_vars:type_name -> workflow.plugin.external.iac.BootstrapResult.EnvVarsEntry + 101, // 22: workflow.plugin.external.iac.MigrationRepairRequest.env:type_name -> workflow.plugin.external.iac.MigrationRepairRequest.EnvEntry 15, // 23: workflow.plugin.external.iac.MigrationRepairResult.diagnostics:type_name -> workflow.plugin.external.iac.Diagnostic 6, // 24: workflow.plugin.external.iac.CapabilitiesResponse.capabilities:type_name -> workflow.plugin.external.iac.IaCCapabilityDeclaration 2, // 25: workflow.plugin.external.iac.PlanRequest.desired:type_name -> workflow.plugin.external.iac.ResourceSpec @@ -5778,14 +5867,14 @@ var file_iac_proto_depIdxs = []int32{ 3, // 40: workflow.plugin.external.iac.DetectDriftRequest.refs:type_name -> workflow.plugin.external.iac.ResourceRef 12, // 41: workflow.plugin.external.iac.DetectDriftResponse.drifts:type_name -> workflow.plugin.external.iac.DriftResult 3, // 42: workflow.plugin.external.iac.DetectDriftWithSpecsRequest.refs:type_name -> workflow.plugin.external.iac.ResourceRef - 100, // 43: workflow.plugin.external.iac.DetectDriftWithSpecsRequest.specs:type_name -> workflow.plugin.external.iac.DetectDriftWithSpecsRequest.SpecsEntry + 102, // 43: workflow.plugin.external.iac.DetectDriftWithSpecsRequest.specs:type_name -> workflow.plugin.external.iac.DetectDriftWithSpecsRequest.SpecsEntry 12, // 44: workflow.plugin.external.iac.DetectDriftWithSpecsResponse.drifts:type_name -> workflow.plugin.external.iac.DriftResult 23, // 45: workflow.plugin.external.iac.RepairDirtyMigrationRequest.request:type_name -> workflow.plugin.external.iac.MigrationRepairRequest 24, // 46: workflow.plugin.external.iac.RepairDirtyMigrationResponse.result:type_name -> workflow.plugin.external.iac.MigrationRepairResult 18, // 47: workflow.plugin.external.iac.ValidatePlanRequest.plan:type_name -> workflow.plugin.external.iac.IaCPlan 16, // 48: workflow.plugin.external.iac.ValidatePlanResponse.diagnostics:type_name -> workflow.plugin.external.iac.PlanDiagnostic 3, // 49: workflow.plugin.external.iac.DetectDriftConfigRequest.refs:type_name -> workflow.plugin.external.iac.ResourceRef - 101, // 50: workflow.plugin.external.iac.DetectDriftConfigRequest.specs:type_name -> workflow.plugin.external.iac.DetectDriftConfigRequest.SpecsEntry + 103, // 50: workflow.plugin.external.iac.DetectDriftConfigRequest.specs:type_name -> workflow.plugin.external.iac.DetectDriftConfigRequest.SpecsEntry 12, // 51: workflow.plugin.external.iac.DetectDriftConfigResponse.drifts:type_name -> workflow.plugin.external.iac.DriftResult 2, // 52: workflow.plugin.external.iac.ResourceCreateRequest.spec:type_name -> workflow.plugin.external.iac.ResourceSpec 8, // 53: workflow.plugin.external.iac.ResourceCreateResponse.output:type_name -> workflow.plugin.external.iac.ResourceOutput @@ -5806,7 +5895,7 @@ var file_iac_proto_depIdxs = []int32{ 15, // 68: workflow.plugin.external.iac.TroubleshootResponse.diagnostics:type_name -> workflow.plugin.external.iac.Diagnostic 81, // 69: workflow.plugin.external.iac.GetStateResponse.state:type_name -> workflow.plugin.external.iac.IaCState 81, // 70: workflow.plugin.external.iac.SaveStateRequest.state:type_name -> workflow.plugin.external.iac.IaCState - 102, // 71: workflow.plugin.external.iac.ListStatesRequest.filter:type_name -> workflow.plugin.external.iac.ListStatesRequest.FilterEntry + 104, // 71: workflow.plugin.external.iac.ListStatesRequest.filter:type_name -> workflow.plugin.external.iac.ListStatesRequest.FilterEntry 81, // 72: workflow.plugin.external.iac.ListStatesResponse.states:type_name -> workflow.plugin.external.iac.IaCState 2, // 73: workflow.plugin.external.iac.DetectDriftWithSpecsRequest.SpecsEntry.value:type_name -> workflow.plugin.external.iac.ResourceSpec 2, // 74: workflow.plugin.external.iac.DetectDriftConfigRequest.SpecsEntry.value:type_name -> workflow.plugin.external.iac.ResourceSpec @@ -5844,42 +5933,44 @@ var file_iac_proto_depIdxs = []int32{ 88, // 106: workflow.plugin.external.iac.IaCStateBackend.DeleteState:input_type -> workflow.plugin.external.iac.DeleteStateRequest 90, // 107: workflow.plugin.external.iac.IaCStateBackend.Lock:input_type -> workflow.plugin.external.iac.LockRequest 92, // 108: workflow.plugin.external.iac.IaCStateBackend.Unlock:input_type -> workflow.plugin.external.iac.UnlockRequest - 26, // 109: workflow.plugin.external.iac.IaCProviderRequired.Initialize:output_type -> workflow.plugin.external.iac.InitializeResponse - 28, // 110: workflow.plugin.external.iac.IaCProviderRequired.Name:output_type -> workflow.plugin.external.iac.NameResponse - 30, // 111: workflow.plugin.external.iac.IaCProviderRequired.Version:output_type -> workflow.plugin.external.iac.VersionResponse - 32, // 112: workflow.plugin.external.iac.IaCProviderRequired.Capabilities:output_type -> workflow.plugin.external.iac.CapabilitiesResponse - 34, // 113: workflow.plugin.external.iac.IaCProviderRequired.Plan:output_type -> workflow.plugin.external.iac.PlanResponse - 36, // 114: workflow.plugin.external.iac.IaCProviderRequired.Apply:output_type -> workflow.plugin.external.iac.ApplyResponse - 38, // 115: workflow.plugin.external.iac.IaCProviderRequired.Destroy:output_type -> workflow.plugin.external.iac.DestroyResponse - 40, // 116: workflow.plugin.external.iac.IaCProviderRequired.Status:output_type -> workflow.plugin.external.iac.StatusResponse - 42, // 117: workflow.plugin.external.iac.IaCProviderRequired.Import:output_type -> workflow.plugin.external.iac.ImportResponse - 44, // 118: workflow.plugin.external.iac.IaCProviderRequired.ResolveSizing:output_type -> workflow.plugin.external.iac.ResolveSizingResponse - 46, // 119: workflow.plugin.external.iac.IaCProviderRequired.BootstrapStateBackend:output_type -> workflow.plugin.external.iac.BootstrapStateBackendResponse - 48, // 120: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateAll:output_type -> workflow.plugin.external.iac.EnumerateAllResponse - 50, // 121: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateByTag:output_type -> workflow.plugin.external.iac.EnumerateByTagResponse - 52, // 122: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDrift:output_type -> workflow.plugin.external.iac.DetectDriftResponse - 54, // 123: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDriftWithSpecs:output_type -> workflow.plugin.external.iac.DetectDriftWithSpecsResponse - 56, // 124: workflow.plugin.external.iac.IaCProviderCredentialRevoker.RevokeProviderCredential:output_type -> workflow.plugin.external.iac.RevokeProviderCredentialResponse - 58, // 125: workflow.plugin.external.iac.IaCProviderMigrationRepairer.RepairDirtyMigration:output_type -> workflow.plugin.external.iac.RepairDirtyMigrationResponse - 60, // 126: workflow.plugin.external.iac.IaCProviderValidator.ValidatePlan:output_type -> workflow.plugin.external.iac.ValidatePlanResponse - 62, // 127: workflow.plugin.external.iac.IaCProviderDriftConfigDetector.DetectDriftConfig:output_type -> workflow.plugin.external.iac.DetectDriftConfigResponse - 64, // 128: workflow.plugin.external.iac.ResourceDriver.Create:output_type -> workflow.plugin.external.iac.ResourceCreateResponse - 66, // 129: workflow.plugin.external.iac.ResourceDriver.Read:output_type -> workflow.plugin.external.iac.ResourceReadResponse - 68, // 130: workflow.plugin.external.iac.ResourceDriver.Update:output_type -> workflow.plugin.external.iac.ResourceUpdateResponse - 70, // 131: workflow.plugin.external.iac.ResourceDriver.Delete:output_type -> workflow.plugin.external.iac.ResourceDeleteResponse - 72, // 132: workflow.plugin.external.iac.ResourceDriver.Diff:output_type -> workflow.plugin.external.iac.ResourceDiffResponse - 74, // 133: workflow.plugin.external.iac.ResourceDriver.Scale:output_type -> workflow.plugin.external.iac.ResourceScaleResponse - 76, // 134: workflow.plugin.external.iac.ResourceDriver.HealthCheck:output_type -> workflow.plugin.external.iac.ResourceHealthCheckResponse - 78, // 135: workflow.plugin.external.iac.ResourceDriver.SensitiveKeys:output_type -> workflow.plugin.external.iac.SensitiveKeysResponse - 80, // 136: workflow.plugin.external.iac.ResourceDriver.Troubleshoot:output_type -> workflow.plugin.external.iac.TroubleshootResponse - 83, // 137: workflow.plugin.external.iac.IaCStateBackend.GetState:output_type -> workflow.plugin.external.iac.GetStateResponse - 85, // 138: workflow.plugin.external.iac.IaCStateBackend.SaveState:output_type -> workflow.plugin.external.iac.SaveStateResponse - 87, // 139: workflow.plugin.external.iac.IaCStateBackend.ListStates:output_type -> workflow.plugin.external.iac.ListStatesResponse - 89, // 140: workflow.plugin.external.iac.IaCStateBackend.DeleteState:output_type -> workflow.plugin.external.iac.DeleteStateResponse - 91, // 141: workflow.plugin.external.iac.IaCStateBackend.Lock:output_type -> workflow.plugin.external.iac.LockResponse - 93, // 142: workflow.plugin.external.iac.IaCStateBackend.Unlock:output_type -> workflow.plugin.external.iac.UnlockResponse - 109, // [109:143] is the sub-list for method output_type - 75, // [75:109] is the sub-list for method input_type + 94, // 109: workflow.plugin.external.iac.IaCStateBackend.ListBackendNames:input_type -> workflow.plugin.external.iac.ListBackendNamesRequest + 26, // 110: workflow.plugin.external.iac.IaCProviderRequired.Initialize:output_type -> workflow.plugin.external.iac.InitializeResponse + 28, // 111: workflow.plugin.external.iac.IaCProviderRequired.Name:output_type -> workflow.plugin.external.iac.NameResponse + 30, // 112: workflow.plugin.external.iac.IaCProviderRequired.Version:output_type -> workflow.plugin.external.iac.VersionResponse + 32, // 113: workflow.plugin.external.iac.IaCProviderRequired.Capabilities:output_type -> workflow.plugin.external.iac.CapabilitiesResponse + 34, // 114: workflow.plugin.external.iac.IaCProviderRequired.Plan:output_type -> workflow.plugin.external.iac.PlanResponse + 36, // 115: workflow.plugin.external.iac.IaCProviderRequired.Apply:output_type -> workflow.plugin.external.iac.ApplyResponse + 38, // 116: workflow.plugin.external.iac.IaCProviderRequired.Destroy:output_type -> workflow.plugin.external.iac.DestroyResponse + 40, // 117: workflow.plugin.external.iac.IaCProviderRequired.Status:output_type -> workflow.plugin.external.iac.StatusResponse + 42, // 118: workflow.plugin.external.iac.IaCProviderRequired.Import:output_type -> workflow.plugin.external.iac.ImportResponse + 44, // 119: workflow.plugin.external.iac.IaCProviderRequired.ResolveSizing:output_type -> workflow.plugin.external.iac.ResolveSizingResponse + 46, // 120: workflow.plugin.external.iac.IaCProviderRequired.BootstrapStateBackend:output_type -> workflow.plugin.external.iac.BootstrapStateBackendResponse + 48, // 121: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateAll:output_type -> workflow.plugin.external.iac.EnumerateAllResponse + 50, // 122: workflow.plugin.external.iac.IaCProviderEnumerator.EnumerateByTag:output_type -> workflow.plugin.external.iac.EnumerateByTagResponse + 52, // 123: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDrift:output_type -> workflow.plugin.external.iac.DetectDriftResponse + 54, // 124: workflow.plugin.external.iac.IaCProviderDriftDetector.DetectDriftWithSpecs:output_type -> workflow.plugin.external.iac.DetectDriftWithSpecsResponse + 56, // 125: workflow.plugin.external.iac.IaCProviderCredentialRevoker.RevokeProviderCredential:output_type -> workflow.plugin.external.iac.RevokeProviderCredentialResponse + 58, // 126: workflow.plugin.external.iac.IaCProviderMigrationRepairer.RepairDirtyMigration:output_type -> workflow.plugin.external.iac.RepairDirtyMigrationResponse + 60, // 127: workflow.plugin.external.iac.IaCProviderValidator.ValidatePlan:output_type -> workflow.plugin.external.iac.ValidatePlanResponse + 62, // 128: workflow.plugin.external.iac.IaCProviderDriftConfigDetector.DetectDriftConfig:output_type -> workflow.plugin.external.iac.DetectDriftConfigResponse + 64, // 129: workflow.plugin.external.iac.ResourceDriver.Create:output_type -> workflow.plugin.external.iac.ResourceCreateResponse + 66, // 130: workflow.plugin.external.iac.ResourceDriver.Read:output_type -> workflow.plugin.external.iac.ResourceReadResponse + 68, // 131: workflow.plugin.external.iac.ResourceDriver.Update:output_type -> workflow.plugin.external.iac.ResourceUpdateResponse + 70, // 132: workflow.plugin.external.iac.ResourceDriver.Delete:output_type -> workflow.plugin.external.iac.ResourceDeleteResponse + 72, // 133: workflow.plugin.external.iac.ResourceDriver.Diff:output_type -> workflow.plugin.external.iac.ResourceDiffResponse + 74, // 134: workflow.plugin.external.iac.ResourceDriver.Scale:output_type -> workflow.plugin.external.iac.ResourceScaleResponse + 76, // 135: workflow.plugin.external.iac.ResourceDriver.HealthCheck:output_type -> workflow.plugin.external.iac.ResourceHealthCheckResponse + 78, // 136: workflow.plugin.external.iac.ResourceDriver.SensitiveKeys:output_type -> workflow.plugin.external.iac.SensitiveKeysResponse + 80, // 137: workflow.plugin.external.iac.ResourceDriver.Troubleshoot:output_type -> workflow.plugin.external.iac.TroubleshootResponse + 83, // 138: workflow.plugin.external.iac.IaCStateBackend.GetState:output_type -> workflow.plugin.external.iac.GetStateResponse + 85, // 139: workflow.plugin.external.iac.IaCStateBackend.SaveState:output_type -> workflow.plugin.external.iac.SaveStateResponse + 87, // 140: workflow.plugin.external.iac.IaCStateBackend.ListStates:output_type -> workflow.plugin.external.iac.ListStatesResponse + 89, // 141: workflow.plugin.external.iac.IaCStateBackend.DeleteState:output_type -> workflow.plugin.external.iac.DeleteStateResponse + 91, // 142: workflow.plugin.external.iac.IaCStateBackend.Lock:output_type -> workflow.plugin.external.iac.LockResponse + 93, // 143: workflow.plugin.external.iac.IaCStateBackend.Unlock:output_type -> workflow.plugin.external.iac.UnlockResponse + 95, // 144: workflow.plugin.external.iac.IaCStateBackend.ListBackendNames:output_type -> workflow.plugin.external.iac.ListBackendNamesResponse + 110, // [110:145] is the sub-list for method output_type + 75, // [75:110] is the sub-list for method input_type 75, // [75:75] is the sub-list for extension type_name 75, // [75:75] is the sub-list for extension extendee 0, // [0:75] is the sub-list for field type_name @@ -5896,7 +5987,7 @@ func file_iac_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_iac_proto_rawDesc), len(file_iac_proto_rawDesc)), NumEnums: 2, - NumMessages: 101, + NumMessages: 103, NumExtensions: 0, NumServices: 9, }, diff --git a/plugin/external/proto/iac.proto b/plugin/external/proto/iac.proto index 76fde9a2..86467ed5 100644 --- a/plugin/external/proto/iac.proto +++ b/plugin/external/proto/iac.proto @@ -611,6 +611,7 @@ service IaCStateBackend { rpc DeleteState(DeleteStateRequest) returns (DeleteStateResponse); rpc Lock (LockRequest) returns (LockResponse); rpc Unlock (UnlockRequest) returns (UnlockResponse); + rpc ListBackendNames(ListBackendNamesRequest) returns (ListBackendNamesResponse); } // IaCState mirrors module.IaCState (module/iac_state.go:4-18). The free-form @@ -644,3 +645,9 @@ message LockRequest { string resource_id = 1; } message LockResponse {} message UnlockRequest { string resource_id = 1; } message UnlockResponse {} + +// ListBackendNames lets the engine ask a loaded plugin which iac.state backend +// NAMES it serves (e.g. "azure_blob"). The plugin answers from its +// plugin.json capabilities.iacStateBackends (PluginManifest.IaCStateBackends). +message ListBackendNamesRequest {} +message ListBackendNamesResponse { repeated string backend_names = 1; } diff --git a/plugin/external/proto/iac_grpc.pb.go b/plugin/external/proto/iac_grpc.pb.go index 12e75a24..41aed1ee 100644 --- a/plugin/external/proto/iac_grpc.pb.go +++ b/plugin/external/proto/iac_grpc.pb.go @@ -1649,12 +1649,13 @@ var ResourceDriver_ServiceDesc = grpc.ServiceDesc{ } const ( - IaCStateBackend_GetState_FullMethodName = "/workflow.plugin.external.iac.IaCStateBackend/GetState" - IaCStateBackend_SaveState_FullMethodName = "/workflow.plugin.external.iac.IaCStateBackend/SaveState" - IaCStateBackend_ListStates_FullMethodName = "/workflow.plugin.external.iac.IaCStateBackend/ListStates" - IaCStateBackend_DeleteState_FullMethodName = "/workflow.plugin.external.iac.IaCStateBackend/DeleteState" - IaCStateBackend_Lock_FullMethodName = "/workflow.plugin.external.iac.IaCStateBackend/Lock" - IaCStateBackend_Unlock_FullMethodName = "/workflow.plugin.external.iac.IaCStateBackend/Unlock" + IaCStateBackend_GetState_FullMethodName = "/workflow.plugin.external.iac.IaCStateBackend/GetState" + IaCStateBackend_SaveState_FullMethodName = "/workflow.plugin.external.iac.IaCStateBackend/SaveState" + IaCStateBackend_ListStates_FullMethodName = "/workflow.plugin.external.iac.IaCStateBackend/ListStates" + IaCStateBackend_DeleteState_FullMethodName = "/workflow.plugin.external.iac.IaCStateBackend/DeleteState" + IaCStateBackend_Lock_FullMethodName = "/workflow.plugin.external.iac.IaCStateBackend/Lock" + IaCStateBackend_Unlock_FullMethodName = "/workflow.plugin.external.iac.IaCStateBackend/Unlock" + IaCStateBackend_ListBackendNames_FullMethodName = "/workflow.plugin.external.iac.IaCStateBackend/ListBackendNames" ) // IaCStateBackendClient is the client API for IaCStateBackend service. @@ -1675,6 +1676,7 @@ type IaCStateBackendClient interface { DeleteState(ctx context.Context, in *DeleteStateRequest, opts ...grpc.CallOption) (*DeleteStateResponse, error) Lock(ctx context.Context, in *LockRequest, opts ...grpc.CallOption) (*LockResponse, error) Unlock(ctx context.Context, in *UnlockRequest, opts ...grpc.CallOption) (*UnlockResponse, error) + ListBackendNames(ctx context.Context, in *ListBackendNamesRequest, opts ...grpc.CallOption) (*ListBackendNamesResponse, error) } type iaCStateBackendClient struct { @@ -1745,6 +1747,16 @@ func (c *iaCStateBackendClient) Unlock(ctx context.Context, in *UnlockRequest, o return out, nil } +func (c *iaCStateBackendClient) ListBackendNames(ctx context.Context, in *ListBackendNamesRequest, opts ...grpc.CallOption) (*ListBackendNamesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListBackendNamesResponse) + err := c.cc.Invoke(ctx, IaCStateBackend_ListBackendNames_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // IaCStateBackendServer is the server API for IaCStateBackend service. // All implementations must embed UnimplementedIaCStateBackendServer // for forward compatibility. @@ -1763,6 +1775,7 @@ type IaCStateBackendServer interface { DeleteState(context.Context, *DeleteStateRequest) (*DeleteStateResponse, error) Lock(context.Context, *LockRequest) (*LockResponse, error) Unlock(context.Context, *UnlockRequest) (*UnlockResponse, error) + ListBackendNames(context.Context, *ListBackendNamesRequest) (*ListBackendNamesResponse, error) mustEmbedUnimplementedIaCStateBackendServer() } @@ -1791,6 +1804,9 @@ func (UnimplementedIaCStateBackendServer) Lock(context.Context, *LockRequest) (* func (UnimplementedIaCStateBackendServer) Unlock(context.Context, *UnlockRequest) (*UnlockResponse, error) { return nil, status.Error(codes.Unimplemented, "method Unlock not implemented") } +func (UnimplementedIaCStateBackendServer) ListBackendNames(context.Context, *ListBackendNamesRequest) (*ListBackendNamesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListBackendNames not implemented") +} func (UnimplementedIaCStateBackendServer) mustEmbedUnimplementedIaCStateBackendServer() {} func (UnimplementedIaCStateBackendServer) testEmbeddedByValue() {} @@ -1920,6 +1936,24 @@ func _IaCStateBackend_Unlock_Handler(srv interface{}, ctx context.Context, dec f return interceptor(ctx, in, info, handler) } +func _IaCStateBackend_ListBackendNames_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListBackendNamesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(IaCStateBackendServer).ListBackendNames(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: IaCStateBackend_ListBackendNames_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(IaCStateBackendServer).ListBackendNames(ctx, req.(*ListBackendNamesRequest)) + } + return interceptor(ctx, in, info, handler) +} + // IaCStateBackend_ServiceDesc is the grpc.ServiceDesc for IaCStateBackend service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -1951,6 +1985,10 @@ var IaCStateBackend_ServiceDesc = grpc.ServiceDesc{ MethodName: "Unlock", Handler: _IaCStateBackend_Unlock_Handler, }, + { + MethodName: "ListBackendNames", + Handler: _IaCStateBackend_ListBackendNames_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "iac.proto", diff --git a/plugin/external/proto/iac_statebackend_test.go b/plugin/external/proto/iac_statebackend_test.go index fbb20a4d..20560bac 100644 --- a/plugin/external/proto/iac_statebackend_test.go +++ b/plugin/external/proto/iac_statebackend_test.go @@ -1,6 +1,9 @@ package proto -import "testing" +import ( + "context" + "testing" +) // Compile-level guard: the IaCStateBackend service + its messages must exist // in the generated package with the IaCStateStore-mirroring shape. @@ -21,3 +24,15 @@ func TestIaCStateBackendGeneratedTypesExist(t *testing.T) { t.Fatalf("IaCState.ResourceId accessor missing") } } + +func TestIaCStateBackendListBackendNamesGenerated(t *testing.T) { + _ = &ListBackendNamesRequest{} + resp := &ListBackendNamesResponse{BackendNames: []string{"azure_blob"}} + if resp.GetBackendNames()[0] != "azure_blob" { + t.Fatalf("ListBackendNamesResponse.BackendNames accessor missing") + } + // the RPC must be on the IaCStateBackend service interfaces: + var _ interface { + ListBackendNames(context.Context, *ListBackendNamesRequest) (*ListBackendNamesResponse, error) + } = (IaCStateBackendServer)(nil) +} From 113440ee229465e3e6875e6f552ac3dcaec43b52 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 14 May 2026 14:49:58 -0400 Subject: [PATCH 5/8] feat(sdk): ServeIaCPlugin auto-registers pb.IaCStateBackendServer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit registerIaCServicesOnly now type-asserts pb.IaCStateBackendServer and registers it, alongside the IaCProvider* optionals — so a plugin whose provider type also implements the state-backend interface serves it with no extra wiring. Amendment A2 (decisions/0035). --- plugin/external/sdk/iacserver.go | 19 +++++++++++++++---- plugin/external/sdk/iacserver_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/plugin/external/sdk/iacserver.go b/plugin/external/sdk/iacserver.go index bd3a298e..64e20190 100644 --- a/plugin/external/sdk/iacserver.go +++ b/plugin/external/sdk/iacserver.go @@ -34,6 +34,7 @@ import ( // pb.IaCProviderMigrationRepairerServer // pb.IaCProviderValidatorServer // pb.IaCProviderDriftConfigDetectorServer +// pb.IaCStateBackendServer // // ResourceDriver: // @@ -91,10 +92,11 @@ func registerAllIaCProviderServicesWithOpts(s *grpc.Server, provider any, opts I // registerIaCServicesOnly extracts the body of the original // RegisterAllIaCProviderServices (nil checks + typed-nil hardening + // IaCProviderRequired assertion + all optional-service auto-registration + -// ResourceDriver auto-registration), EXCLUDING the PluginService bridge -// registration. Kept as a separate helper so the typed-nil + nil-provider -// hardening (R2-3) survives the extraction — moving the registration block -// alone would have split the hardening across two call sites. +// ResourceDriver + IaCStateBackend auto-registration), EXCLUDING the +// PluginService bridge registration. Kept as a separate helper so the +// typed-nil + nil-provider hardening (R2-3) survives the extraction — moving +// the registration block alone would have split the hardening across two +// call sites. func registerIaCServicesOnly(s *grpc.Server, provider any) error { if s == nil { return fmt.Errorf("RegisterAllIaCProviderServices: grpc server is nil") @@ -141,6 +143,15 @@ func registerIaCServicesOnly(s *grpc.Server, provider any) error { if v, ok := provider.(pb.ResourceDriverServer); ok { pb.RegisterResourceDriverServer(s, v) } + // Per decisions/0035 (Amendment A2): IaCStateBackend is an optional + // service auto-detected exactly like the IaCProvider* optionals — a + // plugin whose provider type also implements pb.IaCStateBackendServer + // serves it with no extra wiring. Note: a pure-storage plugin (one that + // does NOT satisfy IaCProviderRequired) cannot use ServeIaCPlugin today + // — a deferred limitation, not addressed here. + if v, ok := provider.(pb.IaCStateBackendServer); ok { + pb.RegisterIaCStateBackendServer(s, v) + } return nil } diff --git a/plugin/external/sdk/iacserver_test.go b/plugin/external/sdk/iacserver_test.go index 43895d69..1be7d915 100644 --- a/plugin/external/sdk/iacserver_test.go +++ b/plugin/external/sdk/iacserver_test.go @@ -114,6 +114,30 @@ func TestRegisterAllIaCProviderServices_AllOptionals_AllRegistered(t *testing.T) } } +// TestRegisterAll_RegistersIaCStateBackend asserts that a provider whose type +// also satisfies pb.IaCStateBackendServer gets the IaCStateBackend service +// auto-registered — exactly like the IaCProvider* optionals. Amendment A2 +// (decisions/0035). +func TestRegisterAll_RegistersIaCStateBackend(t *testing.T) { + grpcSrv := grpc.NewServer() + provider := &stateBackendProviderStub{} + if err := sdk.RegisterAllIaCProviderServices(grpcSrv, provider); err != nil { + t.Fatalf("unexpected error: %v", err) + } + info := grpcSrv.GetServiceInfo() + if _, ok := info["workflow.plugin.external.iac.IaCStateBackend"]; !ok { + t.Fatalf("IaCStateBackend service NOT registered despite provider satisfying interface; have: %v", serviceNames(info)) + } +} + +// stateBackendProviderStub satisfies IaCProviderRequired (the required minimum +// for ServeIaCPlugin) AND IaCStateBackend — representative of an IaC plugin +// whose provider type also serves state storage. +type stateBackendProviderStub struct { + pb.UnimplementedIaCProviderRequiredServer + pb.UnimplementedIaCStateBackendServer +} + func serviceNames(info map[string]grpc.ServiceInfo) []string { out := make([]string, 0, len(info)) for k := range info { From b9ccdf5f7a39925ab6d0c1dcb1c3e7be69eb127b Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 14 May 2026 14:53:07 -0400 Subject: [PATCH 6/8] =?UTF-8?q?feat(plugin):=20PluginManifest=20gains=20ia?= =?UTF-8?q?cStateBackends=20=E2=80=94=20backend-name=20advertisement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin declares the iac.state backend names it serves via plugin.json capabilities.iacStateBackends; this adds the matching Go field + ensures the decode path populates it. The engine (PR 8 / Task 19) reads it and cross-checks against the ListBackendNames RPC. Amendment A2 (decisions/0035). --- plugin/manifest.go | 17 +++++++++++++---- plugin/manifest_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/plugin/manifest.go b/plugin/manifest.go index aa2fb28b..a22fcba4 100644 --- a/plugin/manifest.go +++ b/plugin/manifest.go @@ -46,6 +46,13 @@ type PluginManifest struct { WorkflowTypes []string `json:"workflowTypes,omitempty" yaml:"workflowTypes,omitempty"` WiringHooks []string `json:"wiringHooks,omitempty" yaml:"wiringHooks,omitempty"` + // IaCStateBackends lists the iac.state backend names this plugin serves + // (e.g. "azure_blob"). Authored in plugin.json under the capabilities + // object as capabilities.iacStateBackends and promoted here by + // UnmarshalJSON (legacy object branch). The engine cross-checks these + // against the plugin's ListBackendNames RPC. Amendment A2 (decisions/0035). + IaCStateBackends []string `json:"iacStateBackends,omitempty" yaml:"iacStateBackends,omitempty"` + // StepSchemas provides schema definitions for step types registered by this plugin. // Used by MCP/LSP for hover docs, completions, and output documentation. StepSchemas []*schema.StepSchema `json:"stepSchemas,omitempty" yaml:"stepSchemas,omitempty"` @@ -134,10 +141,11 @@ func (m *PluginManifest) UnmarshalJSON(data []byte) error { // Legacy format: object with configProvider, moduleTypes, stepTypes, triggerTypes. // Merge type lists into the top-level fields so callers see them consistently. var legacyCaps struct { - ModuleTypes []string `json:"moduleTypes"` - StepTypes []string `json:"stepTypes"` - TriggerTypes []string `json:"triggerTypes"` - WorkflowTypes []string `json:"workflowTypes"` + ModuleTypes []string `json:"moduleTypes"` + StepTypes []string `json:"stepTypes"` + TriggerTypes []string `json:"triggerTypes"` + WorkflowTypes []string `json:"workflowTypes"` + IaCStateBackends []string `json:"iacStateBackends"` } if err := json.Unmarshal(raw.Capabilities, &legacyCaps); err != nil { return fmt.Errorf("invalid capabilities object: %w", err) @@ -146,6 +154,7 @@ func (m *PluginManifest) UnmarshalJSON(data []byte) error { m.StepTypes = appendUnique(m.StepTypes, legacyCaps.StepTypes...) m.TriggerTypes = appendUnique(m.TriggerTypes, legacyCaps.TriggerTypes...) m.WorkflowTypes = appendUnique(m.WorkflowTypes, legacyCaps.WorkflowTypes...) + m.IaCStateBackends = appendUnique(m.IaCStateBackends, legacyCaps.IaCStateBackends...) default: return fmt.Errorf("capabilities: unsupported JSON type (expected array or object, got %q)", string(raw.Capabilities)) diff --git a/plugin/manifest_test.go b/plugin/manifest_test.go index b6755e50..c2bd3dd3 100644 --- a/plugin/manifest_test.go +++ b/plugin/manifest_test.go @@ -528,6 +528,35 @@ func TestManifestLegacyCapabilitiesObjectFile(t *testing.T) { } } +// TestManifestCapabilitiesIaCStateBackends verifies that a plugin.json whose +// legacy "capabilities" object declares "iacStateBackends" has those backend +// names promoted to the manifest's top-level IaCStateBackends field. This is +// how a plugin advertises which iac.state backend names it serves +// (Amendment A2, decisions/0035). +func TestManifestCapabilitiesIaCStateBackends(t *testing.T) { + const manifestJSON = `{ + "name": "workflow-plugin-azure", + "version": "1.0.0", + "description": "Azure IaC provider plugin", + "author": "GoCodeAlone", + "capabilities": { + "moduleTypes": ["infra.azure"], + "stepTypes": [], + "triggerTypes": [], + "iacStateBackends": ["azure_blob"] + } + }` + + var m PluginManifest + if err := json.Unmarshal([]byte(manifestJSON), &m); err != nil { + t.Fatalf("unexpected unmarshal error: %v", err) + } + + if len(m.IaCStateBackends) != 1 || m.IaCStateBackends[0] != "azure_blob" { + t.Errorf("IaCStateBackends = %v, want [azure_blob]", m.IaCStateBackends) + } +} + // TestManifestCapabilitiesInvalidFormat verifies that a plugin.json whose // "capabilities" field is neither an array nor an object (e.g. a bare string) // is rejected with a descriptive error. From dba9aa9a72f3a60a81ec1edf54b76c2fd40f13f1 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 14 May 2026 14:56:53 -0400 Subject: [PATCH 7/8] test(plugin): cover top-level + absent iacStateBackends decode paths Code-review Minors: the IaCStateBackends field has two authoring paths (top-level key + nested under capabilities); add a test for the top-level path and the absent case, and broaden the field doc comment to mention both paths. --- plugin/manifest.go | 10 ++++++---- plugin/manifest_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/plugin/manifest.go b/plugin/manifest.go index a22fcba4..e2334289 100644 --- a/plugin/manifest.go +++ b/plugin/manifest.go @@ -47,10 +47,12 @@ type PluginManifest struct { WiringHooks []string `json:"wiringHooks,omitempty" yaml:"wiringHooks,omitempty"` // IaCStateBackends lists the iac.state backend names this plugin serves - // (e.g. "azure_blob"). Authored in plugin.json under the capabilities - // object as capabilities.iacStateBackends and promoted here by - // UnmarshalJSON (legacy object branch). The engine cross-checks these - // against the plugin's ListBackendNames RPC. Amendment A2 (decisions/0035). + // (e.g. "azure_blob"). Authored in plugin.json either as a top-level + // "iacStateBackends" key or nested under the legacy capabilities object + // as capabilities.iacStateBackends (UnmarshalJSON's object branch promotes + // the nested form here, same as ModuleTypes/StepTypes/etc.). The engine + // cross-checks these against the plugin's ListBackendNames RPC. Amendment + // A2 (decisions/0035). IaCStateBackends []string `json:"iacStateBackends,omitempty" yaml:"iacStateBackends,omitempty"` // StepSchemas provides schema definitions for step types registered by this plugin. diff --git a/plugin/manifest_test.go b/plugin/manifest_test.go index c2bd3dd3..715cdcba 100644 --- a/plugin/manifest_test.go +++ b/plugin/manifest_test.go @@ -557,6 +557,47 @@ func TestManifestCapabilitiesIaCStateBackends(t *testing.T) { } } +// TestManifestTopLevelIaCStateBackends verifies the other authoring path: a +// top-level "iacStateBackends" key (not nested under the capabilities object) +// decodes directly into PluginManifest.IaCStateBackends, same as ModuleTypes etc. +func TestManifestTopLevelIaCStateBackends(t *testing.T) { + const manifestJSON = `{ + "name": "workflow-plugin-azure", + "version": "1.0.0", + "description": "Azure IaC provider plugin", + "author": "GoCodeAlone", + "iacStateBackends": ["azure_blob"] + }` + + var m PluginManifest + if err := json.Unmarshal([]byte(manifestJSON), &m); err != nil { + t.Fatalf("unexpected unmarshal error: %v", err) + } + if len(m.IaCStateBackends) != 1 || m.IaCStateBackends[0] != "azure_blob" { + t.Errorf("IaCStateBackends = %v, want [azure_blob]", m.IaCStateBackends) + } +} + +// TestManifestNoIaCStateBackends verifies that a plugin.json declaring no +// state backends leaves IaCStateBackends nil/empty (the common case). +func TestManifestNoIaCStateBackends(t *testing.T) { + const manifestJSON = `{ + "name": "workflow-plugin-foo", + "version": "1.0.0", + "description": "no state backend", + "author": "GoCodeAlone", + "capabilities": {"moduleTypes": ["infra.foo"]} + }` + + var m PluginManifest + if err := json.Unmarshal([]byte(manifestJSON), &m); err != nil { + t.Fatalf("unexpected unmarshal error: %v", err) + } + if len(m.IaCStateBackends) != 0 { + t.Errorf("IaCStateBackends = %v, want empty", m.IaCStateBackends) + } +} + // TestManifestCapabilitiesInvalidFormat verifies that a plugin.json whose // "capabilities" field is neither an array nor an object (e.g. a bare string) // is rejected with a descriptive error. From 894c96eac7df71dc950f8c85f8cfe94703005ef2 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 14 May 2026 15:11:42 -0400 Subject: [PATCH 8/8] test(module): add ListBackendNames to fakeStateBackendClient The ListBackendNames RPC added to the IaCStateBackend service made the test-only fakeStateBackendClient stub no longer satisfy the pb.IaCStateBackendClient interface, breaking the module package typecheck (Lint) and the Test (Go 1.26) job. Add the no-op method. Co-Authored-By: Claude Opus 4.7 (1M context) --- module/iac_state_plugin_registry_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/module/iac_state_plugin_registry_test.go b/module/iac_state_plugin_registry_test.go index 38334f82..16b71c21 100644 --- a/module/iac_state_plugin_registry_test.go +++ b/module/iac_state_plugin_registry_test.go @@ -30,6 +30,9 @@ func (*fakeStateBackendClient) Lock(context.Context, *pb.LockRequest, ...grpc.Ca func (*fakeStateBackendClient) Unlock(context.Context, *pb.UnlockRequest, ...grpc.CallOption) (*pb.UnlockResponse, error) { return nil, nil } +func (*fakeStateBackendClient) ListBackendNames(context.Context, *pb.ListBackendNamesRequest, ...grpc.CallOption) (*pb.ListBackendNamesResponse, error) { + return nil, nil +} func TestIaCStateBackendRegistry(t *testing.T) { reg := newIaCStateBackendRegistry()