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-09

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 service CRDs in the appslap.io group, 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, one CRD per service, generated by Slapper. The operator must 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.

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 and never refers to Crossplane’s types directly; it treats claims and composites as ordinary Kubernetes objects. As a result it builds and runs without a Crossplane installation 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 Slapper-generated service CRD is picked up automatically and gets its own controller, with no rebuild and no typed API.

  • 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 with X followed 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 Slapper-generated CRDs and the composition behind the composite.

  • 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.