> ## Documentation Index
> Fetch the complete documentation index at: https://docs.formae.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Version a plugin's schema

> Ship per-version schema subtrees, an apiVersion Config field, and a runtime resolver so formae narrows extract to the API version each target speaks.

This guide walks through how [`formae-plugin-kubernetes`](https://github.com/platform-engineering-labs/formae-plugin-kubernetes) conforms to the API schema versioning contract, step by step. The same recipe applies to any plugin whose target API evolves per version: fields get introduced, graduate from alpha to GA, get renamed, or are removed between releases. For the mechanics of how formae core resolves versions and narrows imports, read the concept page [API schema versioning](/plugin-development/concepts/api-versioning) first. This page is the task-oriented walkthrough.

## Publish the per-version schema layout

This is the `schema/pkl/` tree that ends up under `~/.pel/formae/plugins/k8s/` after `formae plugin install`. It's also the package layout consumers see when they pull the package from `hub.platform.engineering`.

```
schema/pkl/
├── PklProject                         # package root; declares "@k8s" + deps
├── shared.pkl                         # version-agnostic typealiases + K8sVersion class
├── target.pkl                         # Config + Auth (Config carries kubernetesVersion)
├── helm/                              # helm-chart wrappers (per-version + shared)
│   └── v1.34/...
├── v1.21/
│   ├── PklProject
│   ├── k8s.pkl                        # SubResource classes valid in 1.21
│   ├── apps/Deployment.pkl
│   ├── core/Pod.pkl
│   └── ...
├── v1.22/
├── v1.23/
├── …
└── v1.36/                             # latest supported minor
    ├── PklProject
    ├── k8s.pkl
    ├── apps/Deployment.pkl
    ├── core/Pod.pkl
    └── …                              # all api-group subdirs
```

Important properties of this layout:

* **No top-level `k8s.pkl`.** The package root is `target.pkl` (Config + Auth only). Per-version subdirs each hold their own `k8s.pkl` containing the SubResource classes valid for that minor. Avoids the basename collision that would otherwise force `<baseName>_<sanitizedVersion>` aliases.
* **`shared.pkl` at the root.** Version-agnostic typealiases (`KubernetesMinorVersion`, etc.) and the `K8sVersion` annotation class live here. Per-version files reference them through the package root, so the version dropped doesn't strand orphan imports.
* **Per-version `PklProject`.** Each `v<X.Y>/` ships its own `PklProject` so the per-version subtree can stand alone if needed (Pkl resolves `@k8s/v1.34/...` paths through that nested project).
* **`helm/` is parallel.** Helm-chart wrappers also ship per-version, in their own subtree, because they import per-version subresource files (`@k8s/v1.34/k8s.pkl`) and therefore need to be regenerated when versions change.

The kubernetes plugin includes 16 minor versions today (v1.21 to v1.36). Each new minor adds one subdir; an unsupported minor would be dropped from this list. CI runs a conformance matrix that exercises every minor against a real kind cluster.

## Add the apiVersion field to Config

```kotlin title="schema/pkl/target.pkl" theme={"languages":{"custom":["/languages/pkl.json"]}}
typealias KubernetesMinorVersion = String(matches(Regex(#"^\d+\.\d+$"#)))

open class Config {
    kubernetesVersion: KubernetesMinorVersion?       // <-- The contract field
    auth: Auth
    // ...
}
```

Core picks up `kubernetesVersion` (matched by the case-insensitive `ApiVersion` / `apiVersion` lookup) and feeds it into `resolveSchemaVersions`. Users write:

```kotlin theme={"languages":{"custom":["/languages/pkl.json"]}}
new k8s.Config {
    kubernetesVersion = "1.34"
    auth = new k8s.KubeconfigAuth {}
}
```

## Define the version annotation class

```kotlin title="schema/pkl/shared.pkl" theme={"languages":{"custom":["/languages/pkl.json"]}}
/// API version a field/class is gated to.
///
/// `introducedIn` is the first K8s minor in which the field is valid.
/// `removedIn`    is the minor in which the API was removed.
/// `deprecatedIn` is the minor in which the field was deprecated.
class K8sVersion {
    introducedIn: KubernetesMinorVersion?
    deprecatedIn: KubernetesMinorVersion?
    removedIn: KubernetesMinorVersion?
    reference: String?            // KEP URL, RFC, vendor doc
}
```

Inside each per-version `v<X.Y>/` subtree, fields that were added in a later minor are simply absent from the file. The annotation is what drives that selection at publish time, so when a consumer imports `@k8s/v1.30/core/Service.pkl`, they only see fields that were available in K8s 1.30. `trafficDistribution` (Beta in 1.30) is present, while `tolerance` (Beta in 1.35) is not.

Three granularities of gating, all visible in the per-version trees as additions or omissions:

```kotlin title="Property-level, field landed in 1.30, beta then GA" theme={"languages":{"custom":["/languages/pkl.json"]}}
@k8s.FieldHint {}
@k8s.K8sVersion { introducedIn = "1.30"; reference = "https://kep.k8s.io/4444" }
trafficDistribution: String?
```

```kotlin title="Module-level, whole resource available from 1.29" theme={"languages":{"custom":["/languages/pkl.json"]}}
@K8sVersion { introducedIn = "1.29"; reference = "https://github.com/kubernetes/enhancements/issues/3000" }
module flowschema extends "../k8s.pkl"
```

```kotlin title="Class-level, nested class gated independently" theme={"languages":{"custom":["/languages/pkl.json"]}}
@SubResourceHint {}
@K8sVersion { introducedIn = "1.32"; reference = "https://kep.k8s.io/2837" }
open class ContainerResizePolicy extends formae.SubResource {
    // ...
}
```

## Resolve the runtime version (Go side)

Beyond the Config field, the plugin also resolves the runtime version (for client construction + preflight). Priority order:

```go title="pkg/config/version.go" theme={"languages":{"custom":["/languages/pkl.json"]}}
const (
    MinSupportedK8sVersion = "1.31"
    MaxSupportedK8sVersion = "1.36"
    ClientGoVersion        = "0.36.0"   // keep in sync with go.mod
)

// ResolveK8sVersion: cfg.KubernetesVersion -> FORMAE_K8S_VERSION env ->
//                    live discovery via the cluster's /version endpoint.
func ResolveK8sVersion(ctx context.Context, cfg *Config, disc discovery.DiscoveryInterface) (string, error)
```

A preflight `CheckField` helper walks the per-version gate metadata and refuses to apply when the user pins a gated field on a cluster that doesn't accept it. Catches the "I added `trafficDistribution` to my Service.pkl but my cluster is 1.29" mistake before any RPC is sent.

## Author against versioned schemas

Users in their `forma.pkl` import paths matching the version they're targeting:

```kotlin title="examples/nginx-v1.34.pkl" theme={"languages":{"custom":["/languages/pkl.json"]}}
amends "@formae/forma.pkl"

import "@formae/formae.pkl"
import "@k8s/target.pkl" as k8s
import "@k8s/v1.34/core/Namespace.pkl" as ns
import "@k8s/v1.34/apps/Deployment.pkl" as dep

forma {
  new formae.Target {
    label = "k8s-local"
    config = new k8s.Config {
      kubernetesVersion = "1.34"
      auth = new k8s.KubeconfigAuth {}
    }
  }
  // ...
}
```

The `@k8s/v1.34/...` paths are the per-version subtrees in the published package. The package root `@k8s/target.pkl` carries only `Config` + `Auth` (version-agnostic). When the Forma is extracted later, core's `ImportsGenerator` narrowing makes sure only v1.34-relevant resources show up in the regenerated file.

For the full contract, the version-resolution priority order, and the extract-time narrowing that ties this together, see [API schema versioning](/plugin-development/concepts/api-versioning).
