Skip to main content
Some target APIs evolve per release: fields are introduced, graduate from alpha to GA, get renamed, or are removed. A single static schema can’t honestly model “this field is valid against API v1.32 but not v1.28.” This page describes the contract between formae core and a plugin that ships per-version schemas, and the reference implementation in formae-plugin-kubernetes. The pattern was first developed for the Kubernetes plugin (where the cluster’s minor version determines which fields are accepted by the API server). It generalizes to any provider whose API evolves on a version axis you can detect at apply time.

When you need this

You need versioned schemas when all of the following hold:
  1. The target API’s accepted field set changes between releases your users will run.
  2. Users target different versions concurrently (e.g. one cluster on 1.31, another on 1.34).
  3. Submitting an unsupported field is a hard error at the provider, not a no-op.
If only (1) and (2) apply but unsupported fields are silently ignored, you can often get away with a single schema plus runtime-side preflight checks. The full versioning pipeline below is what you reach for when authoring against the wrong field set rots in production.

The pattern in one paragraph

Ship a schema package containing per-version subtrees (schema/pkl/<pkg>/v<X.Y>/...). Expose an ApiVersion field on the plugin’s Config class so users can declare which version their target speaks. At extract / apply time, formae reads that field, narrows the Pkl wildcard import globs to the matching v<X.Y>/** subtree, and rewrites import aliases in the generated .pkl so only version-appropriate symbols are referenced. The plugin author’s job is to publish the per-version subtrees and the ApiVersion field; everything that follows is handled by core.

How formae core handles versioning

This section describes the SDK side, the mechanisms in the formae binary itself that plugin authors get for free once they conform to the contract. Knowing this lets you debug strange extract output, understand which knobs you control, and recognize what you don’t have to reinvent.

The contract (what plugins must provide)

That’s it. Every other piece below is handled by core.

Schema manifest discovery

When the formae agent starts and a plugin is installed, the package resolver scans the plugin’s schema/pkl/<pkg>/ directory for v*/ subdirectories. The result is a SchemaManifest:
internal/schema/pkl/package_resolver.go
SchemaManifestForNamespace(ns) returns this manifest. Plugins that don’t ship v*/ subdirs return nil and get the legacy unrestricted-glob behavior. Versioning is opt-in via filesystem layout.

Version resolution per Forma

At extract time, core walks every Target in the Forma and resolves the schema version per namespace using resolveSchemaVersions:
internal/schema/pkl/pkl_serialize.go
The plugin doesn’t write this code. It writes the ApiVersion field into its Config schema. Core reads the JSON-serialized Target.Config blob, accepts both ApiVersion and apiVersion, and produces a map[namespace]version (e.g. {"K8S": "v1.34"}) for the extract pipeline.
One version per namespace per extractresolveSchemaVersions errors when two targets in the same namespace declare different ApiVersion values. The Pkl ImportsGenerator can only narrow each package one way per extract pass, so honoring conflicting versions would silently corrupt resources bound to the losing target. Split into separate Formae or align versions. Per-target schema dispatch is a future redesign.

Local-package swap

When extract runs in SchemaLocationLocal mode, core rewrites the generated PklProject dependency specs so each versioned namespace points at the local plugin install instead of the hub-published package URI:
internal/schema/pkl/pkl_serialize.go
Local dev gets per-version dispatch, and remote runs use whatever the hub-published package ships. This is intentional: versioned dispatch is a local-plugin-development feature, and remote (the production default) is never silently flipped to local because a CLI-host plugin tree happens to exist.

Pkl-side glob narrowing

Core invokes the Pkl ImportsGenerator with a schemaVersions external property, a comma-separated pkg=ver,pkg=ver string. The generator narrows the per-package wildcard glob:
internal/schema/pkl/generator/ImportsGenerator.pkl
The narrowed packages mapping flows into ResourcesGenerator (which finds all @ResourceHint-annotated classes) and ultimately into the extract codegen’s typeMap. Only resources from the chosen version (plus version-agnostic root files like target.pkl, shared.pkl) end up in the generated .pkl.

Collision-aware import aliasing

Extract emits import headers like import "<path>" as <alias>. The alias is derived by modulePathToAlias in internal/schema/pkl/generator/pklGenerator.pkl: Two sanitizers run on the parent segment to keep the alias a legal Pkl identifier:
  • Strip the leading @ (@k8s becomes k8s).
  • Replace any non-[A-Za-z0-9_] character with _ so dotted directory names (v1.34) become valid identifiers (v1_34).
The version branch fires when the parent segment matches ^v\d+(\..+)?$. This places the meaningful symbol first (k8s_v1_34 reads as “k8s for v1.34”, natural at use sites like new k8s_v1_34.PolicyRule).

Filtering speculative namespace-base imports

When extract collects allImports for collision-aware aliasing, it historically synthesized an @<pkg>/<pkg>.pkl import for every resource (for cross-package Tag types and similar). Core now filters this through the resolved package graph: if @<pkg>/<pkg>.pkl doesn’t actually exist as a file in the package, the synthesized import is skipped. Without this filter, a phantom @k8s/k8s.pkl (which doesn’t exist in the published kubernetes plugin, that package ships target.pkl at the root) would collide with the real @k8s/v1.34/k8s.pkl, forcing the version branch to fire (k8s_v1_34) while the body renderer still emitted the bare basename (k8s). Header and body would disagree and the extracted Forma would fail to evaluate. The filter prevents that.

What you get end-to-end

For a Forma with target.config.kubernetesVersion = "1.34", when a user runs formae extract:
  1. Core resolves K8S to v1.34 via resolveSchemaVersions.
  2. PklProject deps swap to the local plugin install when running in SchemaLocationLocal mode.
  3. ImportsGenerator narrows the @k8s wildcard to root + v1.34/**.
  4. ResourcesGenerator finds only resources defined in @k8s/v1.34/....
  5. Extract codegen emits a .pkl whose imports are aliased correctly (collision-aware, version-friendly).
  6. The resulting .pkl evaluates against the same v1.34/ subtree the original Forma referenced.
The plugin author writes the ApiVersion Config field + the per-version subtrees. Core handles the rest. For a full worked example of a plugin that conforms to this contract, follow the guide Version a plugin’s schema, which walks through the formae-plugin-kubernetes implementation end to end.

Pitfalls

Don’t name per-version files the same as the package root

If your package root contains a <pkg>.pkl AND your per-version directory contains another <pkg>.pkl, every Forma using both will go through the collision-disambiguation path. The result is correct, core handles it, but the aliases are uglier than they need to be. The kubernetes plugin ships its root as target.pkl (Config + Auth only), so there is no @k8s/k8s.pkl to collide with @k8s/v1.34/k8s.pkl. Clean aliases without disambiguation. If your plugin can avoid the basename collision the same way, do so. If not, and matching apple/pkl-k8s convention often means using k8s.pkl at both layers, the SDK handles it.

Version directory naming

Directory names like v1.34 contain ., which is forbidden in Pkl identifiers. Core sanitizes non-identifier characters (. to _) so the synthesized alias remains a legal Pkl identifier. The convention to follow: name version directories v<major>.<minor> (matching the API’s own version string). Don’t name them 1.34/ (bare), 1_34/ (preempting sanitization), or v1_34/ (preempting sanitization at a different layer). Those force your users’ imports to look unfamiliar.

Pin the API client library in lockstep

If the plugin uses an upstream client library (client-go for Kubernetes, official SDKs for cloud APIs), the supported API minors in the published schema layout should match the minor your client version actually understands the wire protocol for. The kubernetes plugin pins MaxSupportedK8sVersion to the highest minor client-go understands; drift means client-go can’t deserialize a new field even though the schema says it’s valid.

Local-only versioning today

Per-version dispatch only fires in SchemaLocationLocal mode. The published hub package today ships the unified schema, with no per-version narrowing during the swap step. So:
  • Local plugin development produces versioned dispatch + per-version subtrees.
  • Remote (production default) produces the unified schema, schemaVersions empty, unrestricted glob.
This is intentional. It also means that to test the full version-dispatch path you need a local plugin install (make install). formae extract against a hub-published plugin won’t exercise the narrowing.

Checklist

To add API schema versioning to a plugin:
  • Add an apiVersion (or similarly-named) field on your Config class so core can read it from extracted Forma targets.
  • Publish per-version subtrees under schema/pkl/<pkg>/v<X.Y>/ containing only the symbols valid in each minor.
  • Keep the package root version-agnostic (Config + Auth + version-aware typealiases).
  • Add a runtime version resolver (in Go) that maps apiVersion to live client + preflight checks.
  • Wire a CI matrix that runs conformance against every supported version.
  • Bump the API client library and any Max…Version constant in lockstep when a new minor lands.
  • Ship example formae files for each supported version so users have a working @<pkg>/v<X.Y>/... reference.