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:- The target API’s accepted field set changes between releases your users will run.
- Users target different versions concurrently (e.g. one cluster on 1.31, another on 1.34).
- Submitting an unsupported field is a hard error at the provider, not a no-op.
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 theformae 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’sschema/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 everyTarget in the Forma and resolves the schema version per namespace using resolveSchemaVersions:
internal/schema/pkl/pkl_serialize.go
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.
Local-package swap
When extract runs inSchemaLocationLocal 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
Pkl-side glob narrowing
Core invokes the PklImportsGenerator 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
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 likeimport "<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
@(@k8sbecomesk8s). - Replace any non-
[A-Za-z0-9_]character with_so dotted directory names (v1.34) become valid identifiers (v1_34).
^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 collectsallImports 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 withtarget.config.kubernetesVersion = "1.34", when a user runs formae extract:
- Core resolves
K8Stov1.34viaresolveSchemaVersions. - PklProject deps swap to the local plugin install when running in
SchemaLocationLocalmode. ImportsGeneratornarrows the@k8swildcard to root +v1.34/**.ResourcesGeneratorfinds only resources defined in@k8s/v1.34/....- Extract codegen emits a
.pklwhose imports are aliased correctly (collision-aware, version-friendly). - The resulting
.pklevaluates against the samev1.34/subtree the original Forma referenced.
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 likev1.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 inSchemaLocationLocal 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.
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 yourConfigclass 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
apiVersionto live client + preflight checks. - Wire a CI matrix that runs conformance against every supported version.
- Bump the API client library and any
Max…Versionconstant 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.

