ADR 0002 - AppCat Framework 2.0: Claim Lifecycle and Provisioner (CLAP)
Author |
Mike Ditton |
|---|---|
Owner |
Schedar |
Reviewers |
Schedar |
Date Created |
2026-06-09 |
Date Updated |
2026-06-19 |
Status |
draft |
Tags |
framework, framework-2-0, crossplane, claim |
Summary
Introduce clap (Claim Lifecycle and Provisioning), a small Go/kubebuilder operator (module github.com/vshn/clap) that restores the claim/composite split on top of Crossplane v2. It watches the Crossplane XRDs in the appslap.io group and generates a matching claim CRD for each, watches those claim CRDs and starts one dynamic controller per claim kind, provisions an isolated instance namespace per claim, creates the matching composite there with the claim’s spec, syncs the composite status back to the claim, and tears both down in order when the claim is deleted.
Context
Crossplane v2 removed the Claim resource: every composite is now a namespaced XR and there is no separate user-facing Claim/XR split. The Slapper converter (ADR 0001) targets that v2 model and emits XR-only XRDs. The product model, however, still wants the separation Claims used to provide: a customer-facing object the user owns, decoupled from the composite and its managed resources, which must stay isolated from the customer.
Constraints feeding the decision:
-
Crossplane v2 is XR-only; there is no Claim primitive to provide the customer-facing/infrastructure split anymore. The framework targets v2.
-
Customers manage instances via a claim in their own namespace. The composite and everything it provisions must live in an isolated namespace the customer cannot interfear with.
-
The set of service kinds is open and discovered at runtime. Slapper emits one XR-only XRD per service; Crossplane turns that XRD into the composite CRD, but a v2 XRD no longer carries
claimNames, so nothing produces the customer-facing claim CRD. The operator must supply that claim CRD and handle arbitrary kinds with no compile-time Go types and no codegen step. -
GitOps tooling (ArgoCD) tracks labels and annotations for ownership. Propagating claim metadata onto the composite causes ownership conflicts.
-
A reconcile can fail between creating the instance namespace and persisting that fact to the claim status. The operator must not leak or duplicate namespaces when that happens.
-
The composition behind the composite is still work in progress (see Slapper’s stdlib). CLAP must work without depending on any Crossplane SDK or on a stable composition.
Decision
We will introduce a Go/kubebuilder operator clap (module github.com/vshn/clap) that reconciles claims in the appslap.io group into isolated composites.
The implementation is anchored on the following decisions.
Claim and composite distinguished by an "X" prefix convention
A claim and its composite share a single name that differs only by an X prefix on the composite’s kind. A VSHNPostgreSQL claim maps to an XVSHNPostgreSQL composite, in the same group and version. This convention is the operator’s only way to tell the two apart: anything whose kind starts with X followed by a capital letter is treated as a composite and left alone, everything else is treated as a claim. It assumes no real service is ever named with that same X-then-capital shape.
Claim CRDs generated from the XRDs
Nothing upstream creates the claim CRD: Slapper emits the XRD and Crossplane turns it into the composite CRD, but the claim CRD is missing. So CLAP generates it. A watcher observes the Crossplane XRDs in the appslap.io group and, for each one, renders a matching claim CRD.
The claim CRD is the XRD with the composite identity removed: the names have their leading X stripped (XVSHNPostgreSQL → VSHNPostgreSQL), the schema is carried over as-is, and the scope is Namespaced so customers create claims in their own namespace. Writes are idempotent, so a schema change on the XRD flows through to the claim CRD.
CLAP does not clean up these CRDs itself. Each claim CRD points back to its XRD as owner, so Kubernetes deletes it automatically when the XRD goes away. XRDs whose kind is not X-prefixed, or that are malformed, are skipped.
Dynamic per-kind controllers driven by a CRD watcher
Because services are discovered at runtime, CLAP does not register a controller per service kind ahead of time. Instead, a single CRD watcher observes the CRDs in the appslap.io group and, for each claim kind it sees, spins up a dedicated controller on the fly. Composite kinds are ignored; only claims get a controller. The watcher remembers which kinds it has already started so a given kind is only wired up once, and the controllers it creates are tied to the operator’s lifecycle, shutting down cleanly when the operator stops.
These controllers work against the claims generically rather than through generated Go types, which is what lets a brand-new service be handled without recompiling the operator.
One isolated instance namespace per claim
Each claim gets its own namespace, whose name CLAP records on the claim under status.instanceNamespace. The name is derived from the claim’s own name plus a random suffix the API server appends, so two claims with the same name in different customer namespaces never collide.
The tricky case is a crash that happens after the namespace is created but before that name is written back to the claim. Without care, the next reconcile would create a second namespace and orphan the first. To avoid that, CLAP stamps each namespace with the claim’s unique ID, and on every reconcile first looks for a namespace already carrying that ID and re-adopts it. Once the namespace name is recorded on the claim, later reconciles simply make sure it still exists.
Spec-down, status-up synchronization
The claim’s desired state flows down into the composite, and the composite’s observed state flows up into the claim. On the way down, CLAP copies the claim’s spec onto the composite in the instance namespace, creating it the first time and updating it thereafter. On the way up, it copies the composite’s status back onto the claim (preserving the namespace name CLAP owns there) and only writes when something has actually changed, so an unchanged status does not churn the claim.
So that the claim updates promptly when its composite changes, each composite carries a back-reference to the namespace of the claim that owns it, and the operator watches composites as well as claims, routing any composite change back to the right claim.
No metadata propagation
Only the claim’s spec flows to the composite. Labels and annotations on the claim are deliberately left behind, so GitOps tools do not mistake the composite for being owned by the same source as the claim. The Crossplane-specific portion of the spec is also dropped on the way down.
Upstream-agnostic, no Crossplane SDK
CLAP takes no dependency on Crossplane’s code; it treats claims, composites and the XRDs it reads as ordinary Kubernetes objects rather than importing Crossplane’s Go types. It does watch the Crossplane XRD type, so that type has to be installed for CLAP to read XRDs, but the operator still builds without a Crossplane dependency and stays decoupled from the in-progress composition and stdlib work.
Ordered teardown on deletion
When a claim is deleted, CLAP tears down what it provisioned, in reverse order: the composite first, then the instance namespace. A finalizer on the claim holds it in place until that finishes, and the finalizer is added before anything is created, so the operator never provisions something it could not later clean up.
The composite is removed first, and the operator waits until it has fully disappeared before touching the namespace. This gives Crossplane time to tear down the external resources the composite manages, rather than pulling the namespace out from under them. Once the composite is gone the namespace is deleted, and only after it finishes terminating is the finalizer released and the claim allowed to disappear. Because nothing reports when a namespace finishes winding down, the operator re-checks an in-progress teardown on a short timer.
Consequences
Positive:
-
The claim/composite isolation that Crossplane v1 Claims provided is restored on v2: the customer edits a claim in their own namespace while the composite and its managed resources live in an operator-owned, isolated namespace.
-
Any service works with no code generation. A new service XRD is picked up automatically: CLAP derives its claim CRD and starts a controller for it, with no rebuild and no typed API.
-
The claim CRD is never hand-authored or kept in sync separately. It is derived from the XRD, so its schema always matches, and it is cleaned up automatically with its XRD via the owner reference.
-
Because each namespace is stamped with its claim’s ID, an interrupted reconcile re-adopts the orphaned namespace instead of leaking or duplicating one.
-
The claim is only rewritten when its status has genuinely changed, so a steady composite does not churn the claim.
-
Leaving the claim’s labels and annotations behind avoids ArgoCD ownership fights between the claim and the composite.
-
Deleting a claim cleans up after itself: the composite is removed first so Crossplane can tear down its external resources, then the instance namespace, and only then is the claim released, leaving no orphaned namespaces or composites.
-
Small footprint: a single deployment with leader election for HA, no webhooks and no Crossplane dependency.
Negative / neutral:
-
The
X-prefix convention is the only thing that separates claims from composites. A real service whose name happens to start withXfollowed by a capital letter would be mistaken for a composite and silently skipped. -
The per-kind controllers are started outside the operator’s normal controller machinery, and the operator only ever adds to its list of known kinds. Removing a service’s CRD at runtime does not stop the controller that was started for it. There is no teardown path.
-
Treating claims and composites as generic objects gives the operator no schema validation or type safety of its own; correctness rests entirely on the XRDs Slapper emits, the claim CRDs CLAP derives from them, and the composition behind the composite.
-
The claim CRD generation depends on the
X-prefix convention holding: a service whose composite kind is notX-prefixed will not get a CLAP-managed claim CRD. -
The claim’s status is just a copy of the composite’s, apart from the namespace name CLAP owns. Any status the claim needs to show that the composite does not already report has to be added as a special case.
-
Teardown is only as reliable as the composite’s own deletion. If Crossplane gets stuck removing an external resource, the composite never finishes deleting and the claim stays around, held by its finalizer, until that resolves. This is intentional: the alternative is silently abandoning resources.
-
One namespace per claim multiplies the namespace count and requires the operator to hold cluster-wide permission to create namespaces.
-
Leader election is enabled, but each instance rebuilds its picture of the running controllers from scratch when it takes over; the design assumes a single active operator at a time.