Skip to content

ADR 0013: Provider plugin contract (core versus provider)

  • Status: Accepted
  • Date: 2026-09-16

Context

The shipped CLI compiles provider implementations into its registration path (internal/registry/registry.go registers all six first-party modules), core configuration owns provider enums and per-provider target types (internal/config), the public SDK leaves the program contract outside the type system (Program(ModulePlan) (any, error) with a pulumi.RunFunc assertion in internal/platform/public_module.go), and the wire protocol is a narrow JSON proof (request_json / plan_json / result_json / execute_json in internal/providerhost/hostproto/ping.proto). ADRs 0008 and 0011 scoped that shape deliberately as a single-version proof: exact-version equality between CLI and provider artifact, silent fallback to embedded execution on plugin failure, and day-2 ports staying in-process.

The pre-publication audit (F01-F03, F15) found that boundary unfinished. Those notes were not kept in the public tree. Calling the boundary done would mislead every consumer and contributor. This ADR promotes the proof into the versioned contract that followers implement. It changes the target rules; it does not rewrite the history that 0008/0011 record.

Decision

Ownership split

The shipped core owns configuration envelopes, command UX, plugin discovery, trust (identity, integrity, compatibility), locking, and orchestration policy. The provider owns its cloud implementation, its Pulumi program and stack execution, and its operating capabilities. Each responsibility sits on exactly one side; there are no jointly owned rows.

Autonomous provider operations

A provider is autonomous when it provisions and independently operates the seven day-2 operations over the versioned protocol: status, logs, execution, backup, restore, teardown, and credential refresh. Cost-estimate inputs are required; full cost adapters are out of scope for alpha. A plugin that provisions but cannot operate is not the product vision.

Versioned typed operations protocol

The protocol is versioned typed operations, not a hardened envelope over the JSON host protocol: ping.proto stays frozen as the single-version proof and is not extended. The operations set is stack lifecycle (plan, preview, apply, destroy, refresh, outputs with a redacted variant) plus Describe (negotiation) plus ValidateConfig plus the seven day-2 operations. Requests and responses are typed; no operation carries an untyped JSON string or a Go any holding a Pulumi program. Errors are typed codes with human-readable detail and a retryability flag. Cancellation is per-operation context cancellation across the transport; progress is a typed event stream (started/progress/log/done). Provider state is opaque bytes to the core: the core stores and returns it without inspection, and secret values never appear in logs or evidence.

Execution boundary

The provider executes its own Pulumi Automation API stacks inside the provider process. A RunFunc closure cannot cross gRPC (established by ADR 0011), so the any-carrying Program shape is retired: stack work runs provider-side and only typed results cross. The core never imports provider SDKs or Pulumi provider packages.

Configuration split

The core owns a stable common YAML envelope (project, environments, target selectors, application, presets) and validates it. Each provider owns its target.<provider> block: it publishes the schema and validates the raw block bytes passed to ValidateConfig and Plan. The cloud resource model is not forced to a lowest common denominator. The core accepts any well-formed provider ID at parse; unknown providers fail at resolution, not at parse: no installed compatible plugin is a fail-closed error naming the provider ID, the required protocol version, and the install action: never a silent substitution.

Negotiation and fail-closed compatibility

The provider Describe call reports protocol version, provider ID and version, supported operations with versions, and target runtimes. The core requires a protocol-major match plus the operations each command needs. Checksum rejection, incompatibility, and plugin load/dial errors each fail closed. The only sanctioned fallback is an explicit --migration-mode flag for moving between embedded and plugin execution; migration runs log the selected implementation at startup, and every run logs the selected plugin ID plus version. Silent conversion of plugin errors into embedded execution is forbidden.

Independent releases

Provider implementations live in nested modules under providers/<name>/ (one go.mod each); the core never imports provider packages. Tags are vX.Y.Z for the core, sdk/vX.Y.Z for the SDK, providers/<name>/vX.Y.Z for providers. Alpha ships all three lockstep; the independent-cadence rules (additive changes minor, removals major, protocol major enforced at negotiation via the core-held compatibility matrix) are specified here and activate post-alpha. Independent releases are a build and dependency contract; separate Git repositories are explicitly not required and not created.

Proof gates for followers

gcp-autonomous-provider proves the contract with two MUST-pass gates: a provider built outside the root module using only the public SDK (clean module, GOWORK=off), and a core built without provider SDK dependencies (go list over the shipped CLI closure rejecting provider SDK packages).

Process boundary honesty

The provider subprocess runs with the user's cloud privileges. The process boundary is a deployment and compatibility boundary, not a security sandbox. No section of this contract calls it a sandbox or implies privilege separation.

Documentation updates

The boundary sections of docs/adding-a-provider.md and docs/architecture.md describe this contract as specified, and ADRs 0008 and 0011 carry reciprocal scope notes. Human docs and decision record ship together; neither lands without the other.

Supersession of 0008 and 0011

This ADR supersedes as target rules: 0008's "Magento deploy still needs the AWS and GCP adapters in the same process as the CLI for RC1" and "adapters may stay in-process for one RC1 release if extract lags"; 0011's "Single-version v1: the provider artifact version equals the CLI version", "A failed subprocess attempt falls back to the in-process backend with a stderr notice. It never fails the command.", and "Day-2 ports stay in-process for every adapter". What stands: no unsigned downloads, HashiCorp go-plugin transport with plugin.Open forbidden, lockfile digest plus Cosign identity verification before spawn, in-process loading for tests and Floci, compile-time community binaries, provider-side Pulumi execution, and operations crossing as data. The current code still behaves the old way where noted; Order 5 implements this contract, and behavior changes land there.

Consequences

  • Follower intents implement against this contract instead of freelancing the protocol; gcp-autonomous-provider is unblocked on a specified boundary.
  • internal/cloud/<provider>/ keeps topology until Order 5 performs the specified move; no directory-only reorganization happens here.
  • The SDK gains protocol types under the compatibility rules above; nothing is exported merely to make the current package graph compile.
  • Distribution trust mechanics (download, install, lockfile) are specified at policy level here and implemented in verified-provider-distribution.

Alternatives considered

  • Hardened envelope over the JSON host protocol: rejected (the JSON proof does not grow into the product; typed operations from the start).
  • Provider returns a Pulumi program handle: impossible across gRPC (same as ADR 0011); execution stays provider-side.
  • Lowest-common-denominator cloud resource model: rejected (destroys provider expressiveness; the envelope stays stable instead).
  • Separate Git repositories per provider: rejected (multiplies cross-repository changes before boundaries settle; the monorepo stays).
  • Silent migration mode: rejected (a rejected checksum must never change the selected implementation quietly).

Alpha reconciliation (2026-09-17)

The 0.1.0 alpha review (R05) found the as-built boundary narrower than this ADR's letter. Reconciled position for the alpha:

  • HELD: deploy-step execution lives in the plugin behind the typed deploy-app-phase operation; deploy inputs are an SDK-governed contract the core passes through without decoding; the GCP provider ships as a separate module and process with no core fallback.
  • HELD: the alpha core registers GCP plus AWS ECS Fargate only. EKS, OVH, and Scaleway register behind MAGELIFT_EXPERIMENTAL_PROVIDERS. Cleanup/recovery providers stay available regardless (recovery must read old ledgers).
  • DEFERRED with trigger: the provider still imports root-internal implementation helpers (kube execution primitives, automation, platform types). Full SDK-plus-support-module independence lands with aws-provider-parity at the latest: the second provider must not clone the coupling. The leanness gate pins the GCP boundary today, not full SDK independence.
  • DROPPED: nothing. The autonomous-provider goal stands; this section records what alpha proves versus what parity completes.

Provenance

Pre-publication audit F01-F03/F15 and review R05. The intent notes were not published. Code: sdk/modules.go, internal/platform/public_module.go, internal/providerhost/hostproto/ping.proto, internal/config, internal/registry/registry.go, ADRs 0003/0004/0008/0011.