Capability Schema
Design: Capability Rendering Schema
Status: Final | Issue: #60
| Version | Date | Summary |
|---|---|---|
| 1.0 | 2026-05-16 | Initial — records built-in struct pattern and custom CapabilityDefinition plan |
1. Scope
This document answers two questions deferred from design-cluster-profile.md §2:
- Built-in handlers — where does the schema of accepted rendering keys live, and how is it validated?
- Custom capabilities — how does a downstream consumer declare the rendering schema for a non-built-in handler?
Out of scope here:
- App-facing property schema for custom traits or components (application developer concern, different ownership boundary from rendering schema)
- Plugin-style external handler dispatch (Phase 4+)
- Editor integration for
cluster.yaml(future; enabled by the JSON Schema output described in §2)
2. Built-in Handler Rendering Schema
2.1 Typed Go structs as the authoritative schema
Each built-in handler defines a typed Go struct in pkg/oam/builtin/ that represents the
rendering keys it accepts. The struct is the single source of truth — it is both the schema
declaration and the parse target. There is no separate schema file to maintain.
Structs carry dual yaml: and json: tags so the same type serves both the YAML decoder
(gopkg.in/yaml.v3) and the JSON Schema generator (github.com/invopop/jsonschema).
2.2 The ValidateAndApplyDefaults interface
All built-in trait handlers that accept rendering keys implement:
Validation and defaulting happen in a single pass at ClusterProfile evaluation time —
before any Application is processed. This gives immediate feedback when cluster.yaml is
loaded, not when a specific trait is dispatched.
A shared decodeStrict[T] helper handles the strict YAML decode for all handlers:
Example implementation for the expose handler:
Note on conditional constraints: Mutual exclusivity and conditional required fields
(e.g. “gatewayName is required when controllerType is gateway”) are expressed as Go code
inside ValidateAndApplyDefaults. The struct tags themselves cannot express these
constraints. The generated JSON Schema (§2.3) is authoritative for simple required/optional
constraints and approximate for conditional ones.
Note on defaults: Defaults are conditional Go logic, not declarative struct annotations.
A default is only applied when the relevant condition is true. Applying a gateway default
when the controller type is ingress would be wrong; the handler’s method handles this
naturally with an if statement.
2.3 JSON Schema generation
Each handler can expose a machine-readable schema derived from its rendering struct via
github.com/invopop/jsonschema:
This schema is approximate for conditional constraints (see §2.2). It is suitable for
tooling (editor autocomplete, kurel schema expose) but not authoritative for validation —
validation is the handler’s ValidateAndApplyDefaults method.
The dual yaml:/json: tag requirement exists because invopop/jsonschema reads json:
tags for field names.
2.4 Handlers with no rendering keys
A built-in handler that accepts no rendering keys (e.g. configmap, networkpolicy) must
still implement ValidateAndApplyDefaults with an empty struct:
KnownFields(true) on an empty struct rejects any key the operator accidentally provides.
This is a build error, not a silent pass-through — consistent with the strict-by-default
principle in design-gvk.md.
2.5 Startup assertion
Every handler that implements CapabilityAware (i.e. where CapabilityRequired() can
return true) must also implement ValidateAndApplyDefaults. This invariant is enforced by
a registry-level startup assertion: if a CapabilityAware handler is registered without
ValidateAndApplyDefaults, the binary panics at startup. This catches omissions during
development, not at runtime or in production.
2.6 Universal scope
The typed struct pattern and ValidateAndApplyDefaults interface apply uniformly across
all handler input types — not only capability rendering. The same approach governs:
- Capability rendering (ClusterProfile → handler, platform operator concern)
- Trait properties (Application → handler, application developer concern)
- Component properties (Application → handler, application developer concern)
All handler inputs are decoded via decodeStrict[T] into their respective typed structs.
The principle — typed struct as authoritative schema, strict decode, validate-and-default
in one pass — applies uniformly across all handler input types. The specific interface
methods for component and trait property validation (as distinct from capability rendering)
are follow-on design work, not specified here.
3. Custom Capability Schema
3.1 Schema ≠ implementation
A custom capability is a trait type implemented by a handler registered by a downstream
consumer — not one of the built-in handlers in pkg/oam/builtin/. This document addresses
the schema of custom capability rendering (what rendering keys the operator provides in
cluster.yaml). The implementation (the TraitHandler Go code that produces Kubernetes
manifests) is a separate concern.
Downstream consumers register custom handlers TODAY via library embedding — launcher is
designed to be consumed as a library, and consumers register additional TraitHandler
implementations at startup. What is deferred to Phase 4+ is plugin-style or
launcher-native external dispatch (external binaries, gRPC plugins). See
dot-github/docs/design/kure-launcher-architecture.md for the extension model.
A CapabilityDefinition document (§3.2) validates the rendering map for a registered
custom handler. Without a registered handler, the build still fails with ErrUnknownTrait
at dispatch — the CapabilityDefinition does not replace the handler.
3.2 CapabilityDefinition document kind (Phase 2/3)
The CapabilityDefinition document kind will be added in Phase 2/3. It declares the
rendering schema for a custom capability in the same vocabulary used by kurel.yaml
parameters (type, required, default, description).
metadata.name is the trait type. There is no spec.traitType field — following the
same convention as Package and ClusterProfile, where metadata.name is the primary
semantic identifier.
Scope: CapabilityDefinition covers rendering schema only — the platform-facing
spec.capabilities.<type>.rendering values. App-facing property schema for custom traits
(what the application developer writes in app.yaml) is a separate concern with a
different owner; it is not in scope for this document kind.
3.3 Package layout and discovery (Phase 2/3)
A package author ships CapabilityDefinition files in a definitions/ directory alongside
kurel.yaml and app.yaml:
kurel build discovers CapabilityDefinition files via:
- Auto-discovery — any
*.yamlfile in<package-dir>/definitions/withkind: CapabilityDefinitionis loaded automatically. - Explicit flag —
--capability-def path/to/def.yaml; repeatable.
Built-in handlers do NOT use CapabilityDefinition files — their schema lives in typed Go
structs (§2). The CapabilityDefinition document format applies exclusively to custom
(non-built-in) handlers.
3.4 Validation timing and behavior
When a CapabilityDefinition is found for a given trait type: validate that capability
binding unconditionally at ClusterProfile evaluation time — before any Application
processing begins. This is consistent with the strict-by-default principle in design-gvk.md.
When no CapabilityDefinition is found for a custom capability that is actually used
in the current build (the trait appears in an Application being built AND a handler is
registered for it): emit a warning that rendering is passing through unvalidated.
--strict-capabilities upgrades this warning to a build error.
The warning is scoped to capabilities actually dispatched in the current build. Unused
capability entries sitting in a shared cluster.yaml do not trigger warnings — a
multi-cluster platform profile with entries for capabilities not used by the package being
built remains quiet.
ErrUnknownTrait is a separate error from schema warnings. If no handler is registered
for a trait type used in an Application, the build fails at dispatch regardless of whether
a CapabilityDefinition exists. Schema validation is only meaningful when a handler is
registered.
3.5 Conflict resolution
If two packages in the same build ship CapabilityDefinition for the same trait type:
- Identical schemas (same properties, types, required flags): de-duplicated silently.
- Differing schemas: build error naming both source files. There is no merge or last-writer-wins behavior.
3.6 Naming: CapabilityBinding vs CapabilityDefinition
The per-slot entry in ClusterProfile.spec.capabilities is named CapabilityBinding in
pkg/oam — not CapabilityDefinition. The binding is the operator’s configuration
attachment (what rendering values to inject); the definition is the schema document (what
keys are accepted). These are distinct concepts with distinct Go types.
The rename from CapabilityDefinition → CapabilityBinding for the per-slot struct is
tracked in #45 and happens there, not in
this PR.
4. What Is Deferred
| Concern | Deferred to |
|---|---|
CapabilityDefinition document kind implementation | Phase 3 follow-up implementation issue (#66) |
CapabilityBinding rename in pkg/oam | #45 (Phase 1) |
| App-facing property schema for custom traits | Future (schema-provider interface or separate document kinds) |
| Plugin-style external handler dispatch | Phase 4+ |
Editor integration for cluster.yaml (schema publishing) | Phase 3+ (enabled by RenderingSchema() output) |