Repository: crossplane/crossplane

Issue:
[v2] Add MRD type and controller
<!--
Thank you for helping to improve Crossplane!

Please be sure to search for open issues before raising a new one. We use issues
for bug reports and feature requests. Please find us at https://slack.crossplane.io
for questions, support, and discussion.
-->

### What problem are you facing?
<!--
Please tell us a little about your use case - it's okay if it's hypothetical!
Leading with this context helps frame the feature request so we can ensure we
implement it sensibly.
--->

MRD design: https://github.com/crossplane/crossplane/blob/f5f6aac/design/one-pager-mrds.md

### How could Crossplane help solve your problem?
<!--
Let us know how you think Crossplane could help with your use case. 
-->

Per the design:

* Add a new MRD type that's basically a CRD + connection details and `spec.state: Active | Inactive` field.
* If MRD is active, its controller should create a CRD
* MRDs may only transition inactive -> active, not the other way around 


Hints from issue comments:

Solution patch:
diff --git a/apis/apiextensions/v2alpha1/conditions.go b/apis/apiextensions/v2alpha1/conditions.go
new file mode 100644
index 000000000..d3ca9e712
--- /dev/null
+++ b/apis/apiextensions/v2alpha1/conditions.go
@@ -0,0 +1,92 @@
+/*
+Copyright 2020 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v2alpha1
+
+import (
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+)
+
+// Condition types.
+const (
+	// A TypeEstablished XRD has created the CRD for its composite resource and
+	// started a controller to reconcile instances of said resource.
+	TypeEstablished xpv1.ConditionType = "Established"
+)
+
+// Reasons a resource is or is not established or offered.
+const (
+	EstablishedManagedResource xpv1.ConditionReason = "EstablishedManagedResource"
+	ReasonPendingManaged       xpv1.ConditionReason = "PendingManagedResource"
+	ReasonInactiveManaged      xpv1.ConditionReason = "InactiveManagedResource"
+	ReasonTerminatingManaged   xpv1.ConditionReason = "TerminatingManagedResource"
+)
+
+// EstablishedManaged indicates that Crossplane has defined new kind of managed resource.
+func EstablishedManaged() xpv1.Condition {
+	return xpv1.Condition{
+		Type:               TypeEstablished,
+		Status:             corev1.ConditionTrue,
+		LastTransitionTime: metav1.Now(),
+		Reason:             EstablishedManagedResource,
+	}
+}
+
+// InactiveManaged indicates this managed resource is in the inactive state.
+func InactiveManaged() xpv1.Condition {
+	return xpv1.Condition{
+		Type:               TypeEstablished,
+		Status:             corev1.ConditionFalse,
+		LastTransitionTime: metav1.Now(),
+		Reason:             ReasonInactiveManaged,
+	}
+}
+
+// PendingManaged indicates that Crossplane has defined and is waiting for a
+// new kind of managed resource to become accepted.
+func PendingManaged() xpv1.Condition {
+	return xpv1.Condition{
+		Type:               TypeEstablished,
+		Status:             corev1.ConditionUnknown,
+		LastTransitionTime: metav1.Now(),
+		Reason:             ReasonPendingManaged,
+	}
+}
+
+// BlockedManaged indicates that Crossplane has encountered an error attempting to
+// reconcile a managed resource definition.
+func BlockedManaged() xpv1.Condition {
+	return xpv1.Condition{
+		Type:               TypeEstablished,
+		Status:             corev1.ConditionFalse,
+		LastTransitionTime: metav1.Now(),
+		Reason:             ReasonPendingManaged,
+	}
+}
+
+// TerminatingManaged indicates that Crossplane is terminating the controller
+// for and removing the definition of a managed resource.
+func TerminatingManaged() xpv1.Condition {
+	return xpv1.Condition{
+		Type:               TypeEstablished,
+		Status:             corev1.ConditionFalse,
+		LastTransitionTime: metav1.Now(),
+		Reason:             ReasonTerminatingManaged,
+	}
+}
diff --git a/apis/apiextensions/v2alpha1/crd_types.go b/apis/apiextensions/v2alpha1/crd_types.go
index 4edfbf162..70b83da11 100644
--- a/apis/apiextensions/v2alpha1/crd_types.go
+++ b/apis/apiextensions/v2alpha1/crd_types.go
@@ -38,11 +38,18 @@ type CustomResourceDefinitionSpec struct {
 	// Group is the API group of the defined custom resource.
 	// The custom resources are served under `/apis/<group>/...`.
 	// Must match the name of the CustomResourceDefinition (in the form `<names.plural>.<group>`).
+	// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable"
 	Group string `json:"group"`
 	// Names specify the resource and kind names for the custom resource.
+	// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable"
+	// +kubebuilder:validation:XValidation:rule="self.plural == self.plural.lowerAscii()",message="Plural name must be lowercase"
+	// +kubebuilder:validation:XValidation:rule="!has(self.singular) || self.singular == self.singular.lowerAscii()",message="Singular name must be lowercase"
 	Names extv1.CustomResourceDefinitionNames `json:"names"`
 	// Scope indicates whether the defined custom resource is cluster- or namespace-scoped.
 	// Allowed values are `Cluster` and `Namespaced`.
+	// +kubebuilder:validation:Enum=Namespaced;Cluster
+	// +kubebuilder:default=Namespaced
+	// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable"
 	Scope extv1.ResourceScope `json:"scope"`
 	// Versions is the list of all API versions of the defined custom resource.
 	// Version names are used to compute the order in which served versions are listed in API discovery.
@@ -78,11 +85,12 @@ type CustomResourceDefinitionVersion struct {
 	// Storage indicates this version should be used when persisting custom resources to storage.
 	// There must be exactly one version with storage=true.
 	Storage bool `json:"storage"`
-	// Voldemort indicates this version of the custom resource API is deprecated.
+	//nolint:gocritic // the field is called deprecated, it isn't deprecated itself
+	// Deprecated indicates this version of the custom resource API is deprecated.
 	// When set to true, API requests to this version receive a warning header in the server response.
 	// Defaults to false.
 	// +optional
-	Voldemort bool `json:"deprecated,omitempty"`
+	Deprecated bool `json:"deprecated,omitempty"`
 	// DeprecationWarning overrides the default warning returned to API clients.
 	// May only be set when `deprecated` is true.
 	// The default warning indicates this version is deprecated and recommends use
diff --git a/apis/apiextensions/v2alpha1/mrd_types.go b/apis/apiextensions/v2alpha1/mrd_types.go
index 313e84add..0a84a265c 100644
--- a/apis/apiextensions/v2alpha1/mrd_types.go
+++ b/apis/apiextensions/v2alpha1/mrd_types.go
@@ -31,6 +31,8 @@ type ManagedResourceDefinitionSpec struct {
 	ConnectionDetails []ConnectionDetail `json:"connectionDetails,omitempty"`
 
 	// State toggles whether the underlying CRD is created or not.
+	// +kubebuilder:validation:Enum=Active;Inactive
+	// +kubebuilder:default=Inactive
 	State ManagedResourceDefinitionState `json:"state"`
 }
 
@@ -45,6 +47,11 @@ const (
 	ManagedResourceDefinitionInactive ManagedResourceDefinitionState = "Inactive"
 )
 
+// IsActive returns if this ManagedResourceDefinitionState is "Active".
+func (s ManagedResourceDefinitionState) IsActive() bool {
+	return s == ManagedResourceDefinitionActive
+}
+
 // ConnectionDetail holds keys and descriptions of connection secrets.
 type ConnectionDetail struct {
 	// Name of the key.
@@ -77,6 +84,16 @@ type ManagedResourceDefinition struct {
 	Status ManagedResourceDefinitionStatus `json:"status,omitempty"`
 }
 
+// GetCondition of this ManagedResourceDefinition.
+func (p *ManagedResourceDefinition) GetCondition(ct xpv1.ConditionType) xpv1.Condition {
+	return p.Status.GetCondition(ct)
+}
+
+// SetConditions of this ManagedResourceDefinition.
+func (p *ManagedResourceDefinition) SetConditions(c ...xpv1.Condition) {
+	p.Status.SetConditions(c...)
+}
+
 // +kubebuilder:object:root=true
 
 // ManagedResourceDefinitionList contains a list of ManagedResourceDefinitions.
diff --git a/cluster/crds/apiextensions.crossplane.io_managedresourcedefinitions.yaml b/cluster/crds/apiextensions.crossplane.io_managedresourcedefinitions.yaml
index 4a18aed6d..d3c67f4b2 100644
--- a/cluster/crds/apiextensions.crossplane.io_managedresourcedefinitions.yaml
+++ b/cluster/crds/apiextensions.crossplane.io_managedresourcedefinitions.yaml
@@ -183,6 +183,9 @@ spec:
                   The custom resources are served under `/apis/<group>/...`.
                   Must match the name of the CustomResourceDefinition (in the form `<names.plural>.<group>`).
                 type: string
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
               names:
                 description: Names specify the resource and kind names for the custom
                   resource.
@@ -229,6 +232,13 @@ spec:
                 - kind
                 - plural
                 type: object
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
+                - message: Plural name must be lowercase
+                  rule: self.plural == self.plural.lowerAscii()
+                - message: Singular name must be lowercase
+                  rule: '!has(self.singular) || self.singular == self.singular.lowerAscii()'
               preserveUnknownFields:
                 description: |-
                   PreserveUnknownFields indicates that object fields which are not specified
@@ -238,13 +248,24 @@ spec:
                   See https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#field-pruning for details.
                 type: boolean
               scope:
+                default: Namespaced
                 description: |-
                   Scope indicates whether the defined custom resource is cluster- or namespace-scoped.
                   Allowed values are `Cluster` and `Namespaced`.
+                enum:
+                - Namespaced
+                - Cluster
                 type: string
+                x-kubernetes-validations:
+                - message: Value is immutable
+                  rule: self == oldSelf
               state:
+                default: Inactive
                 description: State toggles whether the underlying CRD is created or
                   not.
+                enum:
+                - Active
+                - Inactive
                 type: string
               versions:
                 description: |-
@@ -308,7 +329,7 @@ spec:
                       x-kubernetes-list-type: atomic
                     deprecated:
                       description: |-
-                        Voldemort indicates this version of the custom resource API is deprecated.
+                        Deprecated indicates this version of the custom resource API is deprecated.
                         When set to true, API requests to this version receive a warning header in the server response.
                         Defaults to false.
                       type: boolean
diff --git a/internal/controller/apiextensions/apiextensions.go b/internal/controller/apiextensions/apiextensions.go
index e5bdf68ce..1ec0b5c67 100644
--- a/internal/controller/apiextensions/apiextensions.go
+++ b/internal/controller/apiextensions/apiextensions.go
@@ -23,6 +23,7 @@ import (
 	"github.com/crossplane/crossplane/internal/controller/apiextensions/composition"
 	"github.com/crossplane/crossplane/internal/controller/apiextensions/controller"
 	"github.com/crossplane/crossplane/internal/controller/apiextensions/definition"
+	"github.com/crossplane/crossplane/internal/controller/apiextensions/managed"
 	"github.com/crossplane/crossplane/internal/controller/apiextensions/offered"
 	"github.com/crossplane/crossplane/internal/controller/apiextensions/revision"
 )
@@ -41,5 +42,9 @@ func Setup(mgr ctrl.Manager, o controller.Options) error {
 		return err
 	}
 
+	if err := managed.Setup(mgr, o); err != nil {
+		return err
+	}
+
 	return offered.Setup(mgr, o)
 }
diff --git a/internal/controller/apiextensions/managed/reconciler.go b/internal/controller/apiextensions/managed/reconciler.go
new file mode 100644
index 000000000..984ec300f
--- /dev/null
+++ b/internal/controller/apiextensions/managed/reconciler.go
@@ -0,0 +1,207 @@
+/*
+Copyright 2025 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package managed manages the lifecycle of MRD controllers.
+package managed
+
+import (
+	"context"
+	"time"
+
+	extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	"k8s.io/apimachinery/pkg/api/equality"
+	kerrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/types"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+	"github.com/crossplane/crossplane-runtime/pkg/conditions"
+	"github.com/crossplane/crossplane-runtime/pkg/errors"
+	"github.com/crossplane/crossplane-runtime/pkg/event"
+	"github.com/crossplane/crossplane-runtime/pkg/logging"
+	"github.com/crossplane/crossplane-runtime/pkg/meta"
+	"github.com/crossplane/crossplane-runtime/pkg/resource"
+
+	"github.com/crossplane/crossplane/apis/apiextensions/v2alpha1"
+	"github.com/crossplane/crossplane/internal/controller/apiextensions/managed/resources"
+	"github.com/crossplane/crossplane/internal/xcrd"
+)
+
+const (
+	timeout = 2 * time.Minute
+)
+
+// Event reasons.
+const (
+	reasonReconcile event.Reason = "Reconcile"
+	reasonPaused    event.Reason = "ReconciliationPaused"
+	reasonCreateCRD event.Reason = "CreateCustomResourceDefinition"
+	reasonUpdateCRD event.Reason = "UpdateCustomResourceDefinition"
+)
+
+// A Reconciler reconciles CompositeResourceDefinitions.
+type Reconciler struct {
+	client.Client
+
+	log        logging.Logger
+	record     event.Recorder
+	conditions conditions.Manager
+}
+
+// Reconcile a CompositeResourceDefinition by defining a new kind of composite
+// resource and starting a controller to reconcile it.
+func (r *Reconciler) Reconcile(ogctx context.Context, req reconcile.Request) (reconcile.Result, error) {
+	log := r.log.WithValues("request", req)
+	log.Debug("Reconciling")
+
+	ctx, cancel := context.WithTimeout(ogctx, timeout)
+	defer cancel()
+
+	mrd := &v2alpha1.ManagedResourceDefinition{}
+	if err := r.Get(ctx, req.NamespacedName, mrd); err != nil {
+		// In case object is not found, most likely the object was deleted and
+		// then disappeared while the event was in the processing queue. We
+		// don't need to take any action in that case.
+		log.Debug("cannot get ManagedResourceDefinition", "error", err)
+		return reconcile.Result{}, errors.Wrap(resource.IgnoreNotFound(err), "cannot get ManagedResourceDefinition")
+	}
+
+	status := r.conditions.For(mrd)
+
+	log = log.WithValues(
+		"uid", mrd.GetUID(),
+		"version", mrd.GetResourceVersion(),
+		"name", mrd.GetName(),
+	)
+
+	if meta.WasDeleted(mrd) {
+		status.MarkConditions(v2alpha1.TerminatingManaged())
+		if err := r.Status().Update(ogctx, mrd); err != nil {
+			log.Debug("cannot update status of ManagedResourceDefinition", "error", err)
+			if kerrors.IsConflict(err) {
+				return reconcile.Result{Requeue: true}, nil
+			}
+			err = errors.Wrap(err, "cannot update status of ManagedResourceDefinition")
+			return reconcile.Result{}, err
+		}
+
+		return reconcile.Result{}, nil
+	}
+	// Check for pause annotation
+	if meta.IsPaused(mrd) {
+		log.Info("reconciliation is paused")
+		r.record.Event(mrd, event.Normal(reasonPaused, "Reconciliation is paused via the pause annotation"))
+		return reconcile.Result{}, nil
+	}
+
+	if !mrd.Spec.State.IsActive() {
+		status.MarkConditions(v2alpha1.InactiveManaged())
+		return reconcile.Result{}, errors.Wrap(r.Status().Update(ogctx, mrd), "cannot update status of ManagedResourceDefinition")
+	}
+
+	// Check that the CRD exists and is up to date.
+	crd, err := r.reconcileCustomResourceDefinition(ctx, log, mrd)
+	if err != nil {
+		log.Debug("failed to reconcile CustomResourceDefinition", "error", err)
+		r.record.Event(mrd, event.Warning(reasonReconcile, err))
+		status.MarkConditions(v2alpha1.BlockedManaged().WithMessage("unable to reconcile CustomResourceDefinition, see events"))
+		if err := r.Status().Update(ogctx, mrd); err != nil {
+			log.Info("cannot update status of ManagedResourceDefinition", "error", err)
+		}
+		return reconcile.Result{}, errors.Wrap(err, "cannot reconcile CustomResourceDefinition")
+	}
+
+	if xcrd.IsEstablished(crd.Status) {
+		status.MarkConditions(v2alpha1.EstablishedManaged())
+	} else {
+		log.Debug("waiting for managed resource CustomResourceDefinition to be established")
+		status.MarkConditions(v2alpha1.PendingManaged())
+	}
+
+	return reconcile.Result{}, errors.Wrap(r.Status().Update(ogctx, mrd), "cannot update status of ManagedResourceDefinition")
+}
+
+const (
+	actionCreate = iota
+	actionUpdate
+)
+
+func (r *Reconciler) reconcileCustomResourceDefinition(ctx context.Context, log logging.Logger, mrd *v2alpha1.ManagedResourceDefinition) (*extv1.CustomResourceDefinition, error) {
+	want := resources.EmptyCustomResourceDefinition(mrd)
+	nn := types.NamespacedName{
+		Namespace: want.Namespace,
+		Name:      want.Name,
+	}
+	action := actionUpdate
+	if err := r.Get(ctx, nn, want); err != nil && !kerrors.IsNotFound(err) {
+		return nil, errors.Wrap(err, "cannot get CustomResourceDefinition")
+	} else if err != nil && kerrors.IsNotFound(err) {
+		log.Debug("CustomResourceDefinition not found, will create", "crd", nn)
+		action = actionCreate
+	}
+	existing := want.DeepCopy()
+
+	if meta.WasDeleted(want) {
+		log.Debug("CustomResourceDefinition is being deleted", "crd", nn)
+		return nil, errors.New("crd was deleted")
+	}
+
+	// Stage changes.
+	if err := resources.MergeCustomResourceDefinitionInto(mrd, want); err != nil {
+		return nil, errors.Wrap(err, "cannot merge CustomResourceDefinition")
+	}
+
+	// Apply changes.
+	switch action {
+	case actionCreate:
+		if err := r.Create(ctx, want); err != nil {
+			return nil, r.handleErrorWithEvent(log, mrd, err, "cannot create CustomResourceDefinition", reasonCreateCRD)
+		}
+		r.record.Event(mrd, event.Normal(reasonCreateCRD, "Successfully created CustomResourceDefinition"))
+	case actionUpdate:
+		if !equality.Semantic.DeepEqual(existing.Spec, want.Spec) {
+			if err := r.Update(ctx, want); err != nil {
+				return nil, r.handleErrorWithEvent(log, mrd, err, "cannot update CustomResourceDefinition", reasonUpdateCRD)
+			}
+			r.record.Event(mrd, event.Normal(reasonUpdateCRD, "Successfully updated CustomResourceDefinition"))
+		}
+	default:
+		// Noop.
+	}
+
+	return want, nil
+}
+
+// handleErrorWithEvent provides centralized error handling with event recording.
+func (r *Reconciler) handleErrorWithEvent(log logging.Logger, obj client.Object, err error, errMsg string, reason event.Reason) error {
+	// Handle conflicts with immediate requeue - no event needed as this is transient
+	if kerrors.IsConflict(err) {
+		return err // Controller runtime will requeue automatically
+	}
+
+	// Record warning event for all other errors
+	r.record.Event(obj, event.Warning(reason, err))
+	log.Debug(errMsg, "reason", reason, "error", err)
+
+	// Handle validation errors to prevent endless reconciliation
+	if kerrors.IsInvalid(err) {
+		// API Server's invalid errors may be unstable due to pointers in
+		// the string representation, causing endless reconciliation
+		err = errors.New("invalid resource configuration")
+	}
+
+	return errors.Wrap(err, errMsg)
+}
diff --git a/internal/controller/apiextensions/managed/resources/custom_resource_definition.go b/internal/controller/apiextensions/managed/resources/custom_resource_definition.go
new file mode 100644
index 000000000..319f66603
--- /dev/null
+++ b/internal/controller/apiextensions/managed/resources/custom_resource_definition.go
@@ -0,0 +1,99 @@
+/*
+Copyright 2025 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package resources contains static methods to help render resources for the MRD reconciler.
+package resources
+
+import (
+	"encoding/json"
+	"slices"
+	"strings"
+
+	extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/crossplane/crossplane-runtime/pkg/errors"
+
+	"github.com/crossplane/crossplane/apis/apiextensions/v2alpha1"
+)
+
+const (
+	errParseValidation             = "cannot parse custom resource validation OpenAPIV3Schema"
+	errCustomResourceValidationNil = "custom resource validation cannot be nil"
+)
+
+// EmptyCustomResourceDefinition returns an empty CRD named in line with the MRD.
+func EmptyCustomResourceDefinition(mrd *v2alpha1.ManagedResourceDefinition) *extv1.CustomResourceDefinition {
+	return &extv1.CustomResourceDefinition{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: mrd.GetName(),
+		},
+	}
+}
+
+// MergeCustomResourceDefinitionInto updates the given crd to match the given mrd.
+func MergeCustomResourceDefinitionInto(mrd *v2alpha1.ManagedResourceDefinition, crd *extv1.CustomResourceDefinition) error {
+	want := mrd.Spec.CustomResourceDefinitionSpec
+
+	crd.Spec.Group = want.Group
+	crd.Spec.Names = want.Names
+	crd.Spec.Scope = want.Scope
+	crd.Spec.Conversion = want.Conversion
+	crd.Spec.PreserveUnknownFields = want.PreserveUnknownFields
+
+	crd.Spec.Versions = make([]extv1.CustomResourceDefinitionVersion, len(want.Versions))
+	for i, version := range want.Versions {
+		schema, err := toCustomResourceValidation(version.Schema)
+		if err != nil {
+			return err
+		}
+		crd.Spec.Versions[i] = extv1.CustomResourceDefinitionVersion{
+			Name:                     version.Name,
+			Served:                   version.Served,
+			Storage:                  version.Storage,
+			Deprecated:               version.Deprecated,
+			DeprecationWarning:       version.DeprecationWarning,
+			Schema:                   schema,
+			Subresources:             version.Subresources,
+			AdditionalPrinterColumns: version.AdditionalPrinterColumns,
+			SelectableFields:         version.SelectableFields,
+		}
+	}
+	// TODO: We are not merging above, we are replacing. If there is something that mutates the CRD, we will fight.
+
+	slices.SortFunc(crd.Spec.Versions, func(a, b extv1.CustomResourceDefinitionVersion) int {
+		return strings.Compare(a.Name, b.Name)
+	})
+
+	return nil
+}
+
+func toCustomResourceValidation(given *v2alpha1.CustomResourceValidation) (*extv1.CustomResourceValidation, error) {
+	if given == nil {
+		return nil, errors.New(errCustomResourceValidationNil)
+	}
+
+	schema := &extv1.JSONSchemaProps{}
+	if err := json.Unmarshal(given.OpenAPIV3Schema.Raw, schema); err != nil {
+		return nil, errors.Wrap(err, errParseValidation)
+	}
+
+	// TODO: We could choose to validate some of the CRD schema here or later.
+
+	return &extv1.CustomResourceValidation{
+		OpenAPIV3Schema: schema,
+	}, nil
+}
diff --git a/internal/controller/apiextensions/managed/setup.go b/internal/controller/apiextensions/managed/setup.go
new file mode 100644
index 000000000..4a491ac70
--- /dev/null
+++ b/internal/controller/apiextensions/managed/setup.go
@@ -0,0 +1,85 @@
+/*
+Copyright 2025 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package managed manages the lifecycle of MRD controllers.
+package managed
+
+import (
+	"strings"
+
+	extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	ctrl "sigs.k8s.io/controller-runtime"
+
+	"github.com/crossplane/crossplane-runtime/pkg/conditions"
+	"github.com/crossplane/crossplane-runtime/pkg/errors"
+	"github.com/crossplane/crossplane-runtime/pkg/event"
+	"github.com/crossplane/crossplane-runtime/pkg/logging"
+
+	"github.com/crossplane/crossplane/apis/apiextensions/v2alpha1"
+	apiextensionscontroller "github.com/crossplane/crossplane/internal/controller/apiextensions/controller"
+)
+
+// Setup adds a controller that reconciles CompositeResourceDefinitions by
+// defining a composite resource and starting a controller to reconcile it.
+func Setup(mgr ctrl.Manager, o apiextensionscontroller.Options) error {
+	name := "mrd/" + strings.ToLower(v2alpha1.ManagedResourceDefinitionKind)
+
+	r := NewReconciler(mgr,
+		WithLogger(o.Logger.WithValues("controller", name)),
+		WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))),
+	)
+
+	return ctrl.NewControllerManagedBy(mgr).
+		Named(name).
+		For(&v2alpha1.ManagedResourceDefinition{}).
+		Owns(&extv1.CustomResourceDefinition{}).
+		WithOptions(o.ForControllerRuntime()).
+		Complete(errors.WithSilentRequeueOnConflict(r))
+}
+
+// ReconcilerOption is used to configure the Reconciler.
+type ReconcilerOption func(*Reconciler)
+
+// WithLogger specifies how the Reconciler should log messages.
+func WithLogger(log logging.Logger) ReconcilerOption {
+	return func(r *Reconciler) {
+		r.log = log
+	}
+}
+
+// WithRecorder specifies how the Reconciler should record Kubernetes events.
+func WithRecorder(er event.Recorder) ReconcilerOption {
+	return func(r *Reconciler) {
+		r.record = er
+	}
+}
+
+// NewReconciler returns a Reconciler of CompositeResourceDefinitions.
+func NewReconciler(mgr ctrl.Manager, opts ...ReconcilerOption) *Reconciler {
+	r := &Reconciler{
+		Client: mgr.GetClient(),
+
+		log:        logging.NewNopLogger(),
+		record:     event.NewNopRecorder(),
+		conditions: conditions.ObservedGenerationPropagationManager{},
+	}
+
+	for _, f := range opts {
+		f(r)
+	}
+
+	return r
+}



Test patch:
diff --git a/internal/controller/apiextensions/managed/reconciler_test.go b/internal/controller/apiextensions/managed/reconciler_test.go
new file mode 100644
index 000000000..261e6e5fc
--- /dev/null
+++ b/internal/controller/apiextensions/managed/reconciler_test.go
@@ -0,0 +1,773 @@
+/*
+Copyright 2025 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package managed
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	kerrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/types"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+	"github.com/crossplane/crossplane-runtime/pkg/conditions"
+	"github.com/crossplane/crossplane-runtime/pkg/errors"
+	"github.com/crossplane/crossplane-runtime/pkg/event"
+	"github.com/crossplane/crossplane-runtime/pkg/logging"
+	"github.com/crossplane/crossplane-runtime/pkg/meta"
+	"github.com/crossplane/crossplane-runtime/pkg/test"
+
+	"github.com/crossplane/crossplane/apis/apiextensions/v2alpha1"
+)
+
+func TestReconcile(t *testing.T) {
+	errBoom := errors.New("boom")
+	now := metav1.Now()
+
+	type args struct {
+		c    client.Client
+		opts []ReconcilerOption
+	}
+	type want struct {
+		r   reconcile.Result
+		err error
+	}
+
+	cases := map[string]struct {
+		reason string
+		args   args
+		want   want
+	}{
+		"ResourceNotFound": {
+			reason: "We should not return an error if the ManagedResourceDefinition was not found",
+			args: args{
+				c: &test.MockClient{
+					MockGet: test.NewMockGetFn(kerrors.NewNotFound(schema.GroupResource{}, "")),
+				},
+			},
+			want: want{
+				r: reconcile.Result{},
+			},
+		},
+		"GetMRDError": {
+			reason: "We should return error encountered while getting the ManagedResourceDefinition",
+			args: args{
+				c: &test.MockClient{
+					MockGet: test.NewMockGetFn(errBoom),
+				},
+			},
+			want: want{
+				err: errors.Wrap(errBoom, "cannot get ManagedResourceDefinition"),
+			},
+		},
+		"MRDBeingDeleted": {
+			reason: "We should handle MRD deletion gracefully",
+			args: args{
+				c: &test.MockClient{
+					MockGet: withMRD(t, newMRD(func(mrd *v2alpha1.ManagedResourceDefinition) {
+						mrd.SetDeletionTimestamp(&now)
+					})),
+					MockStatusUpdate: wantMRD(t, newMRD(func(mrd *v2alpha1.ManagedResourceDefinition) {
+						mrd.SetDeletionTimestamp(&now)
+						mrd.SetConditions(v2alpha1.TerminatingManaged())
+					})),
+				},
+			},
+			want: want{
+				r: reconcile.Result{},
+			},
+		},
+		"MRDBeingDeletedStatusUpdateError": {
+			reason: "We should handle status update errors during deletion",
+			args: args{
+				c: &test.MockClient{
+					MockGet: withMRD(t, newMRD(func(mrd *v2alpha1.ManagedResourceDefinition) {
+						mrd.SetDeletionTimestamp(&now)
+					})),
+					MockStatusUpdate: test.NewMockSubResourceUpdateFn(errBoom),
+				},
+			},
+			want: want{
+				err: errors.Wrap(errBoom, "cannot update status of ManagedResourceDefinition"),
+			},
+		},
+		"MRDBeingDeletedStatusUpdateConflict": {
+			reason: "We should requeue on status update conflicts during deletion",
+			args: args{
+				c: &test.MockClient{
+					MockGet: withMRD(t, newMRD(func(mrd *v2alpha1.ManagedResourceDefinition) {
+						mrd.SetDeletionTimestamp(&now)
+					})),
+					MockStatusUpdate: test.NewMockSubResourceUpdateFn(kerrors.NewConflict(schema.GroupResource{}, "test", errBoom)),
+				},
+			},
+			want: want{
+				r: reconcile.Result{Requeue: true},
+			},
+		},
+		"MRDPaused": {
+			reason: "We should not reconcile a paused MRD",
+			args: args{
+				c: &test.MockClient{
+					MockGet: withMRD(t, newMRD(func(mrd *v2alpha1.ManagedResourceDefinition) {
+						mrd.SetAnnotations(map[string]string{meta.AnnotationKeyReconciliationPaused: "true"})
+					})),
+				},
+				opts: []ReconcilerOption{
+					WithRecorder(&testRecorder{}),
+				},
+			},
+			want: want{
+				r: reconcile.Result{},
+			},
+		},
+		"MRDInactiveState": {
+			reason: "We should mark MRD as inactive when state is not active",
+			args: args{
+				c: &test.MockClient{
+					MockGet: withMRD(t, newMRD(func(mrd *v2alpha1.ManagedResourceDefinition) {
+						mrd.Spec.State = v2alpha1.ManagedResourceDefinitionInactive
+					})),
+					MockStatusUpdate: wantMRD(t, newMRD(func(mrd *v2alpha1.ManagedResourceDefinition) {
+						mrd.Spec.State = v2alpha1.ManagedResourceDefinitionInactive
+						mrd.SetConditions(v2alpha1.InactiveManaged())
+					})),
+				},
+			},
+			want: want{
+				r: reconcile.Result{},
+			},
+		},
+		"MRDActiveCRDReconcileError": {
+			reason: "We should handle CRD reconcile errors",
+			args: args{
+				c: &test.MockClient{
+					MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error {
+						switch obj.(type) {
+						case *v2alpha1.ManagedResourceDefinition:
+							return withMRD(t, newMRD(func(mrd *v2alpha1.ManagedResourceDefinition) {
+								mrd.Spec.State = v2alpha1.ManagedResourceDefinitionActive
+							}))(ctx, key, obj)
+						case *extv1.CustomResourceDefinition:
+							return errBoom
+						default:
+							return kerrors.NewNotFound(schema.GroupResource{}, "")
+						}
+					},
+					MockStatusUpdate: wantMRD(t, newMRD(func(mrd *v2alpha1.ManagedResourceDefinition) {
+						mrd.Spec.State = v2alpha1.ManagedResourceDefinitionActive
+						mrd.SetConditions(v2alpha1.BlockedManaged().WithMessage("unable to reconcile CustomResourceDefinition, see events"))
+					})),
+				},
+				opts: []ReconcilerOption{
+					WithRecorder(&testRecorder{}),
+				},
+			},
+			want: want{
+				err: errors.Wrap(errors.Wrap(errBoom, "cannot get CustomResourceDefinition"), "cannot reconcile CustomResourceDefinition"),
+			},
+		},
+		"MRDActiveCRDCreateSuccess": {
+			reason: "We should create CRD when it doesn't exist",
+			args: args{
+				c: &test.MockClient{
+					MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error {
+						switch o := obj.(type) {
+						case *v2alpha1.ManagedResourceDefinition:
+							return withMRD(t, newMRD(func(mrd *v2alpha1.ManagedResourceDefinition) {
+								mrd.Spec.State = v2alpha1.ManagedResourceDefinitionActive
+								mrd.Spec.CustomResourceDefinitionSpec = v2alpha1.CustomResourceDefinitionSpec{
+									Group: "example.com",
+									Names: extv1.CustomResourceDefinitionNames{
+										Plural: "databases",
+										Kind:   "Database",
+									},
+									Scope: extv1.ClusterScoped,
+									Versions: []v2alpha1.CustomResourceDefinitionVersion{
+										{
+											Name:    "v1",
+											Served:  true,
+											Storage: true,
+											Schema: &v2alpha1.CustomResourceValidation{
+												OpenAPIV3Schema: runtime.RawExtension{
+													Raw: []byte(`{"type": "object", "properties": {"spec": {"type": "object"}}}`),
+												},
+											},
+										},
+									},
+								}
+							}))(ctx, key, o)
+						case *extv1.CustomResourceDefinition:
+							return kerrors.NewNotFound(schema.GroupResource{}, "")
+						default:
+							return kerrors.NewNotFound(schema.GroupResource{}, "")
+						}
+					},
+					MockCreate: test.NewMockCreateFn(nil),
+					MockStatusUpdate: wantMRD(t, newMRD(func(mrd *v2alpha1.ManagedResourceDefinition) {
+						mrd.Spec.State = v2alpha1.ManagedResourceDefinitionActive
+						mrd.Spec.CustomResourceDefinitionSpec = v2alpha1.CustomResourceDefinitionSpec{
+							Group: "example.com",
+							Names: extv1.CustomResourceDefinitionNames{
+								Plural: "databases",
+								Kind:   "Database",
+							},
+							Scope: extv1.ClusterScoped,
+							Versions: []v2alpha1.CustomResourceDefinitionVersion{
+								{
+									Name:    "v1",
+									Served:  true,
+									Storage: true,
+									Schema: &v2alpha1.CustomResourceValidation{
+										OpenAPIV3Schema: runtime.RawExtension{
+											Raw: []byte(`{"type": "object", "properties": {"spec": {"type": "object"}}}`),
+										},
+									},
+								},
+							},
+						}
+						mrd.SetConditions(v2alpha1.PendingManaged())
+					})),
+				},
+				opts: []ReconcilerOption{
+					WithRecorder(&testRecorder{}),
+				},
+			},
+			want: want{
+				r: reconcile.Result{},
+			},
+		},
+		"MRDActiveCRDCreateError": {
+			reason: "We should handle CRD creation errors",
+			args: args{
+				c: &test.MockClient{
+					MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error {
+						switch o := obj.(type) {
+						case *v2alpha1.ManagedResourceDefinition:
+							return withMRD(t, newMRD(func(mrd *v2alpha1.ManagedResourceDefinition) {
+								mrd.Spec.State = v2alpha1.ManagedResourceDefinitionActive
+								mrd.Spec.CustomResourceDefinitionSpec = v2alpha1.CustomResourceDefinitionSpec{
+									Group: "example.com",
+									Names: extv1.CustomResourceDefinitionNames{
+										Plural: "databases",
+										Kind:   "Database",
+									},
+									Scope: extv1.ClusterScoped,
+									Versions: []v2alpha1.CustomResourceDefinitionVersion{
+										{
+											Name:    "v1",
+											Served:  true,
+											Storage: true,
+											Schema: &v2alpha1.CustomResourceValidation{
+												OpenAPIV3Schema: runtime.RawExtension{
+													Raw: []byte(`{"type": "object", "properties": {"spec": {"type": "object"}}}`),
+												},
+											},
+										},
+									},
+								}
+							}))(ctx, key, o)
+						case *extv1.CustomResourceDefinition:
+							return kerrors.NewNotFound(schema.GroupResource{}, "")
+						default:
+							return kerrors.NewNotFound(schema.GroupResource{}, "")
+						}
+					},
+					MockCreate: test.NewMockCreateFn(errBoom),
+					MockStatusUpdate: wantMRD(t, newMRD(func(mrd *v2alpha1.ManagedResourceDefinition) {
+						mrd.Spec.State = v2alpha1.ManagedResourceDefinitionActive
+						mrd.Spec.CustomResourceDefinitionSpec = v2alpha1.CustomResourceDefinitionSpec{
+							Group: "example.com",
+							Names: extv1.CustomResourceDefinitionNames{
+								Plural: "databases",
+								Kind:   "Database",
+							},
+							Scope: extv1.ClusterScoped,
+							Versions: []v2alpha1.CustomResourceDefinitionVersion{
+								{
+									Name:    "v1",
+									Served:  true,
+									Storage: true,
+									Schema: &v2alpha1.CustomResourceValidation{
+										OpenAPIV3Schema: runtime.RawExtension{
+											Raw: []byte(`{"type": "object", "properties": {"spec": {"type": "object"}}}`),
+										},
+									},
+								},
+							},
+						}
+						mrd.SetConditions(v2alpha1.BlockedManaged().WithMessage("unable to reconcile CustomResourceDefinition, see events"))
+					})),
+				},
+				opts: []ReconcilerOption{
+					WithRecorder(&testRecorder{}),
+				},
+			},
+			want: want{
+				err: errors.Wrap(errors.Wrap(errBoom, "cannot create CustomResourceDefinition"), "cannot reconcile CustomResourceDefinition"),
+			},
+		},
+		"MRDActiveCRDUpdateSuccess": {
+			reason: "We should update CRD when it exists but is outdated",
+			args: args{
+				c: &test.MockClient{
+					MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error {
+						switch o := obj.(type) {
+						case *v2alpha1.ManagedResourceDefinition:
+							return withMRD(t, newMRD(func(mrd *v2alpha1.ManagedResourceDefinition) {
+								mrd.Spec.State = v2alpha1.ManagedResourceDefinitionActive
+								mrd.Spec.CustomResourceDefinitionSpec = v2alpha1.CustomResourceDefinitionSpec{
+									Group: "example.com",
+									Names: extv1.CustomResourceDefinitionNames{
+										Plural: "databases",
+										Kind:   "Database",
+									},
+									Scope: extv1.ClusterScoped,
+									Versions: []v2alpha1.CustomResourceDefinitionVersion{
+										{
+											Name:    "v1",
+											Served:  true,
+											Storage: true,
+											Schema: &v2alpha1.CustomResourceValidation{
+												OpenAPIV3Schema: runtime.RawExtension{
+													Raw: []byte(`{"type": "object", "properties": {"spec": {"type": "object"}}}`),
+												},
+											},
+										},
+									},
+								}
+							}))(ctx, key, o)
+						case *extv1.CustomResourceDefinition:
+							o.Name = key.Name
+							o.Spec = extv1.CustomResourceDefinitionSpec{
+								Group: "oldexample.com", // Different from MRD to force update
+								Names: extv1.CustomResourceDefinitionNames{
+									Plural: "olddatabases",
+									Kind:   "OldDatabase",
+								},
+								Scope: extv1.ClusterScoped,
+								Versions: []extv1.CustomResourceDefinitionVersion{
+									{
+										Name:    "v1beta1",
+										Served:  false,
+										Storage: false,
+									},
+								},
+							}
+							return nil
+						default:
+							return kerrors.NewNotFound(schema.GroupResource{}, "")
+						}
+					},
+					MockUpdate: test.NewMockUpdateFn(nil),
+					MockStatusUpdate: wantMRD(t, newMRD(func(mrd *v2alpha1.ManagedResourceDefinition) {
+						mrd.Spec.State = v2alpha1.ManagedResourceDefinitionActive
+						mrd.Spec.CustomResourceDefinitionSpec = v2alpha1.CustomResourceDefinitionSpec{
+							Group: "example.com",
+							Names: extv1.CustomResourceDefinitionNames{
+								Plural: "databases",
+								Kind:   "Database",
+							},
+							Scope: extv1.ClusterScoped,
+							Versions: []v2alpha1.CustomResourceDefinitionVersion{
+								{
+									Name:    "v1",
+									Served:  true,
+									Storage: true,
+									Schema: &v2alpha1.CustomResourceValidation{
+										OpenAPIV3Schema: runtime.RawExtension{
+											Raw: []byte(`{"type": "object", "properties": {"spec": {"type": "object"}}}`),
+										},
+									},
+								},
+							},
+						}
+						mrd.SetConditions(v2alpha1.PendingManaged())
+					})),
+				},
+				opts: []ReconcilerOption{
+					WithRecorder(&testRecorder{}),
+				},
+			},
+			want: want{
+				r: reconcile.Result{},
+			},
+		},
+		"MRDActiveCRDUpdateError": {
+			reason: "We should handle CRD update errors",
+			args: args{
+				c: &test.MockClient{
+					MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error {
+						switch o := obj.(type) {
+						case *v2alpha1.ManagedResourceDefinition:
+							return withMRD(t, newMRD(func(mrd *v2alpha1.ManagedResourceDefinition) {
+								mrd.Spec.State = v2alpha1.ManagedResourceDefinitionActive
+								mrd.Spec.CustomResourceDefinitionSpec = v2alpha1.CustomResourceDefinitionSpec{
+									Group: "example.com",
+									Names: extv1.CustomResourceDefinitionNames{
+										Plural: "databases",
+										Kind:   "Database",
+									},
+									Scope: extv1.ClusterScoped,
+									Versions: []v2alpha1.CustomResourceDefinitionVersion{
+										{
+											Name:    "v1",
+											Served:  true,
+											Storage: true,
+											Schema: &v2alpha1.CustomResourceValidation{
+												OpenAPIV3Schema: runtime.RawExtension{
+													Raw: []byte(`{"type": "object", "properties": {"spec": {"type": "object"}}}`),
+												},
+											},
+										},
+									},
+								}
+							}))(ctx, key, o)
+						case *extv1.CustomResourceDefinition:
+							o.Name = key.Name
+							o.Spec = extv1.CustomResourceDefinitionSpec{
+								Group: "oldexample.com", // Different from MRD to force update
+								Names: extv1.CustomResourceDefinitionNames{
+									Plural: "olddatabases",
+									Kind:   "OldDatabase",
+								},
+								Scope: extv1.ClusterScoped,
+								Versions: []extv1.CustomResourceDefinitionVersion{
+									{
+										Name:    "v1beta1",
+										Served:  false,
+										Storage: false,
+									},
+								},
+							}
+							return nil
+						default:
+							return kerrors.NewNotFound(schema.GroupResource{}, "")
+						}
+					},
+					MockUpdate: test.NewMockUpdateFn(errBoom),
+					MockStatusUpdate: wantMRD(t, newMRD(func(mrd *v2alpha1.ManagedResourceDefinition) {
+						mrd.Spec.State = v2alpha1.ManagedResourceDefinitionActive
+						mrd.Spec.CustomResourceDefinitionSpec = v2alpha1.CustomResourceDefinitionSpec{
+							Group: "example.com",
+							Names: extv1.CustomResourceDefinitionNames{
+								Plural: "databases",
+								Kind:   "Database",
+							},
+							Scope: extv1.ClusterScoped,
+							Versions: []v2alpha1.CustomResourceDefinitionVersion{
+								{
+									Name:    "v1",
+									Served:  true,
+									Storage: true,
+									Schema: &v2alpha1.CustomResourceValidation{
+										OpenAPIV3Schema: runtime.RawExtension{
+											Raw: []byte(`{"type": "object", "properties": {"spec": {"type": "object"}}}`),
+										},
+									},
+								},
+							},
+						}
+						mrd.SetConditions(v2alpha1.BlockedManaged().WithMessage("unable to reconcile CustomResourceDefinition, see events"))
+					})),
+				},
+				opts: []ReconcilerOption{
+					WithRecorder(&testRecorder{}),
+				},
+			},
+			want: want{
+				err: errors.Wrap(errors.Wrap(errBoom, "cannot update CustomResourceDefinition"), "cannot reconcile CustomResourceDefinition"),
+			},
+		},
+		"MRDActiveCRDEstablished": {
+			reason: "We should mark MRD as established when CRD is established",
+			args: args{
+				c: &test.MockClient{
+					MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error {
+						switch o := obj.(type) {
+						case *v2alpha1.ManagedResourceDefinition:
+							return withMRD(t, newMRD(func(mrd *v2alpha1.ManagedResourceDefinition) {
+								mrd.Spec.State = v2alpha1.ManagedResourceDefinitionActive
+								mrd.Spec.CustomResourceDefinitionSpec = v2alpha1.CustomResourceDefinitionSpec{
+									Group: "example.com",
+									Names: extv1.CustomResourceDefinitionNames{
+										Plural: "databases",
+										Kind:   "Database",
+									},
+									Scope: extv1.ClusterScoped,
+									Versions: []v2alpha1.CustomResourceDefinitionVersion{
+										{
+											Name:    "v1",
+											Served:  true,
+											Storage: true,
+											Schema: &v2alpha1.CustomResourceValidation{
+												OpenAPIV3Schema: runtime.RawExtension{
+													Raw: []byte(`{"type": "object", "properties": {"spec": {"type": "object"}}}`),
+												},
+											},
+										},
+									},
+								}
+							}))(ctx, key, o)
+						case *extv1.CustomResourceDefinition:
+							o.Name = key.Name
+							o.Spec = extv1.CustomResourceDefinitionSpec{
+								Group: "example.com",
+								Names: extv1.CustomResourceDefinitionNames{
+									Plural: "databases",
+									Kind:   "Database",
+								},
+								Scope: extv1.ClusterScoped,
+								Versions: []extv1.CustomResourceDefinitionVersion{
+									{
+										Name:    "v1",
+										Served:  true,
+										Storage: true,
+										Schema: &extv1.CustomResourceValidation{
+											OpenAPIV3Schema: &extv1.JSONSchemaProps{
+												Type: "object",
+												Properties: map[string]extv1.JSONSchemaProps{
+													"spec": {
+														Type: "object",
+													},
+												},
+											},
+										},
+									},
+								},
+							}
+							// Set established condition
+							o.Status.Conditions = []extv1.CustomResourceDefinitionCondition{
+								{
+									Type:   extv1.Established,
+									Status: extv1.ConditionTrue,
+								},
+							}
+							return nil
+						default:
+							return kerrors.NewNotFound(schema.GroupResource{}, "")
+						}
+					},
+					MockStatusUpdate: wantMRD(t, newMRD(func(mrd *v2alpha1.ManagedResourceDefinition) {
+						mrd.Spec.State = v2alpha1.ManagedResourceDefinitionActive
+						mrd.Spec.CustomResourceDefinitionSpec = v2alpha1.CustomResourceDefinitionSpec{
+							Group: "example.com",
+							Names: extv1.CustomResourceDefinitionNames{
+								Plural: "databases",
+								Kind:   "Database",
+							},
+							Scope: extv1.ClusterScoped,
+							Versions: []v2alpha1.CustomResourceDefinitionVersion{
+								{
+									Name:    "v1",
+									Served:  true,
+									Storage: true,
+									Schema: &v2alpha1.CustomResourceValidation{
+										OpenAPIV3Schema: runtime.RawExtension{
+											Raw: []byte(`{"type": "object", "properties": {"spec": {"type": "object"}}}`),
+										},
+									},
+								},
+							},
+						}
+						mrd.SetConditions(v2alpha1.EstablishedManaged())
+					})),
+				},
+				opts: []ReconcilerOption{
+					WithRecorder(&testRecorder{}),
+				},
+			},
+			want: want{
+				r: reconcile.Result{},
+			},
+		},
+		"MRDActiveCRDDeleted": {
+			reason: "We should handle CRD deletion errors",
+			args: args{
+				c: &test.MockClient{
+					MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error {
+						switch o := obj.(type) {
+						case *v2alpha1.ManagedResourceDefinition:
+							return withMRD(t, newMRD(func(mrd *v2alpha1.ManagedResourceDefinition) {
+								mrd.Spec.State = v2alpha1.ManagedResourceDefinitionActive
+								mrd.Spec.CustomResourceDefinitionSpec = v2alpha1.CustomResourceDefinitionSpec{
+									Group: "example.com",
+									Names: extv1.CustomResourceDefinitionNames{
+										Plural: "databases",
+										Kind:   "Database",
+									},
+									Scope: extv1.ClusterScoped,
+									Versions: []v2alpha1.CustomResourceDefinitionVersion{
+										{
+											Name:    "v1",
+											Served:  true,
+											Storage: true,
+											Schema: &v2alpha1.CustomResourceValidation{
+												OpenAPIV3Schema: runtime.RawExtension{
+													Raw: []byte(`{"type": "object", "properties": {"spec": {"type": "object"}}}`),
+												},
+											},
+										},
+									},
+								}
+							}))(ctx, key, o)
+						case *extv1.CustomResourceDefinition:
+							o.Name = key.Name
+							o.SetDeletionTimestamp(&now)
+							return nil
+						default:
+							return kerrors.NewNotFound(schema.GroupResource{}, "")
+						}
+					},
+					MockStatusUpdate: wantMRD(t, newMRD(func(mrd *v2alpha1.ManagedResourceDefinition) {
+						mrd.Spec.State = v2alpha1.ManagedResourceDefinitionActive
+						mrd.Spec.CustomResourceDefinitionSpec = v2alpha1.CustomResourceDefinitionSpec{
+							Group: "example.com",
+							Names: extv1.CustomResourceDefinitionNames{
+								Plural: "databases",
+								Kind:   "Database",
+							},
+							Scope: extv1.ClusterScoped,
+							Versions: []v2alpha1.CustomResourceDefinitionVersion{
+								{
+									Name:    "v1",
+									Served:  true,
+									Storage: true,
+									Schema: &v2alpha1.CustomResourceValidation{
+										OpenAPIV3Schema: runtime.RawExtension{
+											Raw: []byte(`{"type": "object", "properties": {"spec": {"type": "object"}}}`),
+										},
+									},
+								},
+							},
+						}
+						mrd.SetConditions(v2alpha1.BlockedManaged().WithMessage("unable to reconcile CustomResourceDefinition, see events"))
+					})),
+				},
+				opts: []ReconcilerOption{
+					WithRecorder(&testRecorder{}),
+				},
+			},
+			want: want{
+				err: errors.Wrap(errors.New("crd was deleted"), "cannot reconcile CustomResourceDefinition"),
+			},
+		},
+		"StatusUpdateError": {
+			reason: "We should handle status update errors",
+			args: args{
+				c: &test.MockClient{
+					MockGet: withMRD(t, newMRD(func(mrd *v2alpha1.ManagedResourceDefinition) {
+						mrd.Spec.State = v2alpha1.ManagedResourceDefinitionInactive
+					})),
+					MockStatusUpdate: test.NewMockSubResourceUpdateFn(errBoom),
+				},
+			},
+			want: want{
+				err: errors.Wrap(errBoom, "cannot update status of ManagedResourceDefinition"),
+			},
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			r := &Reconciler{
+				Client:     tc.args.c,
+				log:        logging.NewNopLogger(),
+				record:     event.NewNopRecorder(),
+				conditions: conditions.ObservedGenerationPropagationManager{},
+			}
+
+			for _, opt := range tc.args.opts {
+				opt(r)
+			}
+
+			got, err := r.Reconcile(context.Background(), reconcile.Request{
+				NamespacedName: types.NamespacedName{Name: "test-mrd"},
+			})
+
+			if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
+				t.Errorf("\n%s\nr.Reconcile(...): -want error, +got error:\n%s", tc.reason, diff)
+			}
+			if diff := cmp.Diff(tc.want.r, got, test.EquateErrors()); diff != "" {
+				t.Errorf("\n%s\nr.Reconcile(...): -want, +got:\n%s", tc.reason, diff)
+			}
+		})
+	}
+}
+
+// Helper functions and types for testing
+
+type mrdModifier func(mrd *v2alpha1.ManagedResourceDefinition)
+
+func newMRD(m ...mrdModifier) *v2alpha1.ManagedResourceDefinition {
+	mrd := &v2alpha1.ManagedResourceDefinition{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "test-mrd",
+			UID:  types.UID("test-uid"),
+		},
+		Spec: v2alpha1.ManagedResourceDefinitionSpec{
+			State: v2alpha1.ManagedResourceDefinitionActive,
+		},
+	}
+	for _, fn := range m {
+		fn(mrd)
+	}
+	return mrd
+}
+
+// withMRD returns a MockGetFn that supplies the input ManagedResourceDefinition.
+func withMRD(_ *testing.T, mrd *v2alpha1.ManagedResourceDefinition) func(_ context.Context, _ client.ObjectKey, obj client.Object) error {
+	return func(_ context.Context, _ client.ObjectKey, obj client.Object) error {
+		if o, ok := obj.(*v2alpha1.ManagedResourceDefinition); ok {
+			*o = *mrd
+		}
+		return nil
+	}
+}
+
+// wantMRD returns a MockStatusUpdateFn that ensures the supplied object is the MRD we want.
+func wantMRD(t *testing.T, want *v2alpha1.ManagedResourceDefinition) func(_ context.Context, obj client.Object, _ ...client.SubResourceUpdateOption) error {
+	t.Helper()
+	return func(_ context.Context, got client.Object, _ ...client.SubResourceUpdateOption) error {
+		t.Helper()
+		if diff := cmp.Diff(want, got, cmpopts.EquateApproxTime(3*time.Second)); diff != "" {
+			t.Errorf("wantMRD(...): -want, +got: %s", diff)
+		}
+		return nil
+	}
+}
+
+// testRecorder allows asserting event creation.
+type testRecorder struct {
+	events []event.Event
+}
+
+func (r *testRecorder) Event(_ runtime.Object, e event.Event) {
+	r.events = append(r.events, e)
+}
+
+func (r *testRecorder) WithAnnotations(_ ...string) event.Recorder {
+	return r
+}
diff --git a/internal/controller/apiextensions/managed/resources/custom_resource_definition_test.go b/internal/controller/apiextensions/managed/resources/custom_resource_definition_test.go
new file mode 100644
index 000000000..255b92180
--- /dev/null
+++ b/internal/controller/apiextensions/managed/resources/custom_resource_definition_test.go
@@ -0,0 +1,1084 @@
+/*
+Copyright 2025 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package resources
+
+import (
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/utils/ptr"
+
+	"github.com/crossplane/crossplane-runtime/pkg/errors"
+	"github.com/crossplane/crossplane-runtime/pkg/test"
+
+	"github.com/crossplane/crossplane/apis/apiextensions/v2alpha1"
+)
+
+func TestEmptyCustomResourceDefinition(t *testing.T) {
+	type args struct {
+		mrd *v2alpha1.ManagedResourceDefinition
+	}
+	type want struct {
+		crd *extv1.CustomResourceDefinition
+	}
+
+	cases := map[string]struct {
+		reason string
+		args   args
+		want   want
+	}{
+		"BasicMRD": {
+			reason: "Should create an empty CRD with the same name as the MRD",
+			args: args{
+				mrd: &v2alpha1.ManagedResourceDefinition{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "test-mrd",
+					},
+				},
+			},
+			want: want{
+				crd: &extv1.CustomResourceDefinition{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "test-mrd",
+					},
+				},
+			},
+		},
+		"MRDWithNamespace": {
+			reason: "Should create an empty CRD with the same name as the MRD, ignoring namespace",
+			args: args{
+				mrd: &v2alpha1.ManagedResourceDefinition{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "namespaced-mrd",
+						Namespace: "some-namespace",
+					},
+				},
+			},
+			want: want{
+				crd: &extv1.CustomResourceDefinition{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "namespaced-mrd",
+					},
+				},
+			},
+		},
+		"MRDWithComplexName": {
+			reason: "Should handle complex names correctly",
+			args: args{
+				mrd: &v2alpha1.ManagedResourceDefinition{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "databases.example.com",
+					},
+				},
+			},
+			want: want{
+				crd: &extv1.CustomResourceDefinition{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "databases.example.com",
+					},
+				},
+			},
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			got := EmptyCustomResourceDefinition(tc.args.mrd)
+			if diff := cmp.Diff(tc.want.crd, got); diff != "" {
+				t.Errorf("\n%s\nEmptyCustomResourceDefinition(...): -want, +got:\n%s", tc.reason, diff)
+			}
+		})
+	}
+}
+
+func TestMergeCustomResourceDefinitionInto(t *testing.T) {
+	validSchema := &v2alpha1.CustomResourceValidation{
+		OpenAPIV3Schema: runtime.RawExtension{
+			Raw: []byte(`{"type": "object", "properties": {"spec": {"type": "object"}}}`),
+		},
+	}
+
+	invalidSchema := &v2alpha1.CustomResourceValidation{
+		OpenAPIV3Schema: runtime.RawExtension{
+			Raw: []byte(`{invalid json`),
+		},
+	}
+
+	type args struct {
+		mrd *v2alpha1.ManagedResourceDefinition
+		crd *extv1.CustomResourceDefinition
+	}
+	type want struct {
+		crd *extv1.CustomResourceDefinition
+		err error
+	}
+
+	cases := map[string]struct {
+		reason string
+		args   args
+		want   want
+	}{
+		"BasicMerge": {
+			reason: "Should merge basic CRD spec fields from MRD",
+			args: args{
+				mrd: &v2alpha1.ManagedResourceDefinition{
+					Spec: v2alpha1.ManagedResourceDefinitionSpec{
+						CustomResourceDefinitionSpec: v2alpha1.CustomResourceDefinitionSpec{
+							Group: "example.com",
+							Names: extv1.CustomResourceDefinitionNames{
+								Plural:   "databases",
+								Singular: "database",
+								Kind:     "Database",
+							},
+							Scope: extv1.ClusterScoped,
+							Versions: []v2alpha1.CustomResourceDefinitionVersion{
+								{
+									Name:    "v1",
+									Served:  true,
+									Storage: true,
+									Schema:  validSchema,
+								},
+							},
+						},
+					},
+				},
+				crd: &extv1.CustomResourceDefinition{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "databases.example.com",
+					},
+				},
+			},
+			want: want{
+				crd: &extv1.CustomResourceDefinition{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "databases.example.com",
+					},
+					Spec: extv1.CustomResourceDefinitionSpec{
+						Group: "example.com",
+						Names: extv1.CustomResourceDefinitionNames{
+							Plural:   "databases",
+							Singular: "database",
+							Kind:     "Database",
+						},
+						Scope: extv1.ClusterScoped,
+						Versions: []extv1.CustomResourceDefinitionVersion{
+							{
+								Name:    "v1",
+								Served:  true,
+								Storage: true,
+								Schema: &extv1.CustomResourceValidation{
+									OpenAPIV3Schema: &extv1.JSONSchemaProps{
+										Type: "object",
+										Properties: map[string]extv1.JSONSchemaProps{
+											"spec": {
+												Type: "object",
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		"MultipleVersions": {
+			reason: "Should handle multiple versions and sort them alphabetically",
+			args: args{
+				mrd: &v2alpha1.ManagedResourceDefinition{
+					Spec: v2alpha1.ManagedResourceDefinitionSpec{
+						CustomResourceDefinitionSpec: v2alpha1.CustomResourceDefinitionSpec{
+							Group: "example.com",
+							Names: extv1.CustomResourceDefinitionNames{
+								Plural: "databases",
+								Kind:   "Database",
+							},
+							Scope: extv1.NamespaceScoped,
+							Versions: []v2alpha1.CustomResourceDefinitionVersion{
+								{
+									Name:    "v2",
+									Served:  true,
+									Storage: false,
+									Schema:  validSchema,
+								},
+								{
+									Name:    "v1",
+									Served:  true,
+									Storage: true,
+									Schema:  validSchema,
+								},
+								{
+									Name:    "v1beta1",
+									Served:  false,
+									Storage: false,
+									Schema:  validSchema,
+								},
+							},
+						},
+					},
+				},
+				crd: &extv1.CustomResourceDefinition{},
+			},
+			want: want{
+				crd: &extv1.CustomResourceDefinition{
+					Spec: extv1.CustomResourceDefinitionSpec{
+						Group: "example.com",
+						Names: extv1.CustomResourceDefinitionNames{
+							Plural: "databases",
+							Kind:   "Database",
+						},
+						Scope: extv1.NamespaceScoped,
+						Versions: []extv1.CustomResourceDefinitionVersion{
+							{
+								Name:    "v1",
+								Served:  true,
+								Storage: true,
+								Schema: &extv1.CustomResourceValidation{
+									OpenAPIV3Schema: &extv1.JSONSchemaProps{
+										Type: "object",
+										Properties: map[string]extv1.JSONSchemaProps{
+											"spec": {
+												Type: "object",
+											},
+										},
+									},
+								},
+							},
+							{
+								Name:    "v1beta1",
+								Served:  false,
+								Storage: false,
+								Schema: &extv1.CustomResourceValidation{
+									OpenAPIV3Schema: &extv1.JSONSchemaProps{
+										Type: "object",
+										Properties: map[string]extv1.JSONSchemaProps{
+											"spec": {
+												Type: "object",
+											},
+										},
+									},
+								},
+							},
+							{
+								Name:    "v2",
+								Served:  true,
+								Storage: false,
+								Schema: &extv1.CustomResourceValidation{
+									OpenAPIV3Schema: &extv1.JSONSchemaProps{
+										Type: "object",
+										Properties: map[string]extv1.JSONSchemaProps{
+											"spec": {
+												Type: "object",
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		"WithConversion": {
+			reason: "Should handle conversion settings",
+			args: args{
+				mrd: &v2alpha1.ManagedResourceDefinition{
+					Spec: v2alpha1.ManagedResourceDefinitionSpec{
+						CustomResourceDefinitionSpec: v2alpha1.CustomResourceDefinitionSpec{
+							Group: "example.com",
+							Names: extv1.CustomResourceDefinitionNames{
+								Plural: "databases",
+								Kind:   "Database",
+							},
+							Scope: extv1.ClusterScoped,
+							Conversion: &extv1.CustomResourceConversion{
+								Strategy: extv1.WebhookConverter,
+							},
+							Versions: []v2alpha1.CustomResourceDefinitionVersion{
+								{
+									Name:    "v1",
+									Served:  true,
+									Storage: true,
+									Schema:  validSchema,
+								},
+							},
+						},
+					},
+				},
+				crd: &extv1.CustomResourceDefinition{},
+			},
+			want: want{
+				crd: &extv1.CustomResourceDefinition{
+					Spec: extv1.CustomResourceDefinitionSpec{
+						Group: "example.com",
+						Names: extv1.CustomResourceDefinitionNames{
+							Plural: "databases",
+							Kind:   "Database",
+						},
+						Scope: extv1.ClusterScoped,
+						Conversion: &extv1.CustomResourceConversion{
+							Strategy: extv1.WebhookConverter,
+						},
+						Versions: []extv1.CustomResourceDefinitionVersion{
+							{
+								Name:    "v1",
+								Served:  true,
+								Storage: true,
+								Schema: &extv1.CustomResourceValidation{
+									OpenAPIV3Schema: &extv1.JSONSchemaProps{
+										Type: "object",
+										Properties: map[string]extv1.JSONSchemaProps{
+											"spec": {
+												Type: "object",
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		"WithDeprecation": {
+			reason: "Should handle deprecated versions with warning",
+			args: args{
+				mrd: &v2alpha1.ManagedResourceDefinition{
+					Spec: v2alpha1.ManagedResourceDefinitionSpec{
+						CustomResourceDefinitionSpec: v2alpha1.CustomResourceDefinitionSpec{
+							Group: "example.com",
+							Names: extv1.CustomResourceDefinitionNames{
+								Plural: "databases",
+								Kind:   "Database",
+							},
+							Scope: extv1.ClusterScoped,
+							Versions: []v2alpha1.CustomResourceDefinitionVersion{
+								{
+									Name:               "v1",
+									Served:             true,
+									Storage:            true,
+									Deprecated:         true,
+									DeprecationWarning: ptr.To("This version is deprecated"),
+									Schema:             validSchema,
+								},
+							},
+						},
+					},
+				},
+				crd: &extv1.CustomResourceDefinition{},
+			},
+			want: want{
+				crd: &extv1.CustomResourceDefinition{
+					Spec: extv1.CustomResourceDefinitionSpec{
+						Group: "example.com",
+						Names: extv1.CustomResourceDefinitionNames{
+							Plural: "databases",
+							Kind:   "Database",
+						},
+						Scope: extv1.ClusterScoped,
+						Versions: []extv1.CustomResourceDefinitionVersion{
+							{
+								Name:               "v1",
+								Served:             true,
+								Storage:            true,
+								Deprecated:         true,
+								DeprecationWarning: ptr.To("This version is deprecated"),
+								Schema: &extv1.CustomResourceValidation{
+									OpenAPIV3Schema: &extv1.JSONSchemaProps{
+										Type: "object",
+										Properties: map[string]extv1.JSONSchemaProps{
+											"spec": {
+												Type: "object",
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		"WithSubresources": {
+			reason: "Should handle subresources",
+			args: args{
+				mrd: &v2alpha1.ManagedResourceDefinition{
+					Spec: v2alpha1.ManagedResourceDefinitionSpec{
+						CustomResourceDefinitionSpec: v2alpha1.CustomResourceDefinitionSpec{
+							Group: "example.com",
+							Names: extv1.CustomResourceDefinitionNames{
+								Plural: "databases",
+								Kind:   "Database",
+							},
+							Scope: extv1.ClusterScoped,
+							Versions: []v2alpha1.CustomResourceDefinitionVersion{
+								{
+									Name:    "v1",
+									Served:  true,
+									Storage: true,
+									Schema:  validSchema,
+									Subresources: &extv1.CustomResourceSubresources{
+										Status: &extv1.CustomResourceSubresourceStatus{},
+									},
+								},
+							},
+						},
+					},
+				},
+				crd: &extv1.CustomResourceDefinition{},
+			},
+			want: want{
+				crd: &extv1.CustomResourceDefinition{
+					Spec: extv1.CustomResourceDefinitionSpec{
+						Group: "example.com",
+						Names: extv1.CustomResourceDefinitionNames{
+							Plural: "databases",
+							Kind:   "Database",
+						},
+						Scope: extv1.ClusterScoped,
+						Versions: []extv1.CustomResourceDefinitionVersion{
+							{
+								Name:    "v1",
+								Served:  true,
+								Storage: true,
+								Schema: &extv1.CustomResourceValidation{
+									OpenAPIV3Schema: &extv1.JSONSchemaProps{
+										Type: "object",
+										Properties: map[string]extv1.JSONSchemaProps{
+											"spec": {
+												Type: "object",
+											},
+										},
+									},
+								},
+								Subresources: &extv1.CustomResourceSubresources{
+									Status: &extv1.CustomResourceSubresourceStatus{},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		"WithAdditionalPrinterColumns": {
+			reason: "Should handle additional printer columns",
+			args: args{
+				mrd: &v2alpha1.ManagedResourceDefinition{
+					Spec: v2alpha1.ManagedResourceDefinitionSpec{
+						CustomResourceDefinitionSpec: v2alpha1.CustomResourceDefinitionSpec{
+							Group: "example.com",
+							Names: extv1.CustomResourceDefinitionNames{
+								Plural: "databases",
+								Kind:   "Database",
+							},
+							Scope: extv1.ClusterScoped,
+							Versions: []v2alpha1.CustomResourceDefinitionVersion{
+								{
+									Name:    "v1",
+									Served:  true,
+									Storage: true,
+									Schema:  validSchema,
+									AdditionalPrinterColumns: []extv1.CustomResourceColumnDefinition{
+										{
+											Name:        "Status",
+											Type:        "string",
+											JSONPath:    ".status.phase",
+											Description: "Status of the database",
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				crd: &extv1.CustomResourceDefinition{},
+			},
+			want: want{
+				crd: &extv1.CustomResourceDefinition{
+					Spec: extv1.CustomResourceDefinitionSpec{
+						Group: "example.com",
+						Names: extv1.CustomResourceDefinitionNames{
+							Plural: "databases",
+							Kind:   "Database",
+						},
+						Scope: extv1.ClusterScoped,
+						Versions: []extv1.CustomResourceDefinitionVersion{
+							{
+								Name:    "v1",
+								Served:  true,
+								Storage: true,
+								Schema: &extv1.CustomResourceValidation{
+									OpenAPIV3Schema: &extv1.JSONSchemaProps{
+										Type: "object",
+										Properties: map[string]extv1.JSONSchemaProps{
+											"spec": {
+												Type: "object",
+											},
+										},
+									},
+								},
+								AdditionalPrinterColumns: []extv1.CustomResourceColumnDefinition{
+									{
+										Name:        "Status",
+										Type:        "string",
+										JSONPath:    ".status.phase",
+										Description: "Status of the database",
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		"WithSelectableFields": {
+			reason: "Should handle selectable fields",
+			args: args{
+				mrd: &v2alpha1.ManagedResourceDefinition{
+					Spec: v2alpha1.ManagedResourceDefinitionSpec{
+						CustomResourceDefinitionSpec: v2alpha1.CustomResourceDefinitionSpec{
+							Group: "example.com",
+							Names: extv1.CustomResourceDefinitionNames{
+								Plural: "databases",
+								Kind:   "Database",
+							},
+							Scope: extv1.ClusterScoped,
+							Versions: []v2alpha1.CustomResourceDefinitionVersion{
+								{
+									Name:    "v1",
+									Served:  true,
+									Storage: true,
+									Schema:  validSchema,
+									SelectableFields: []extv1.SelectableField{
+										{
+											JSONPath: ".spec.engine",
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				crd: &extv1.CustomResourceDefinition{},
+			},
+			want: want{
+				crd: &extv1.CustomResourceDefinition{
+					Spec: extv1.CustomResourceDefinitionSpec{
+						Group: "example.com",
+						Names: extv1.CustomResourceDefinitionNames{
+							Plural: "databases",
+							Kind:   "Database",
+						},
+						Scope: extv1.ClusterScoped,
+						Versions: []extv1.CustomResourceDefinitionVersion{
+							{
+								Name:    "v1",
+								Served:  true,
+								Storage: true,
+								Schema: &extv1.CustomResourceValidation{
+									OpenAPIV3Schema: &extv1.JSONSchemaProps{
+										Type: "object",
+										Properties: map[string]extv1.JSONSchemaProps{
+											"spec": {
+												Type: "object",
+											},
+										},
+									},
+								},
+								SelectableFields: []extv1.SelectableField{
+									{
+										JSONPath: ".spec.engine",
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		"WithPreserveUnknownFields": {
+			reason: "Should handle preserve unknown fields",
+			args: args{
+				mrd: &v2alpha1.ManagedResourceDefinition{
+					Spec: v2alpha1.ManagedResourceDefinitionSpec{
+						CustomResourceDefinitionSpec: v2alpha1.CustomResourceDefinitionSpec{
+							Group: "example.com",
+							Names: extv1.CustomResourceDefinitionNames{
+								Plural: "databases",
+								Kind:   "Database",
+							},
+							Scope:                 extv1.ClusterScoped,
+							PreserveUnknownFields: true,
+							Versions: []v2alpha1.CustomResourceDefinitionVersion{
+								{
+									Name:    "v1",
+									Served:  true,
+									Storage: true,
+									Schema:  validSchema,
+								},
+							},
+						},
+					},
+				},
+				crd: &extv1.CustomResourceDefinition{},
+			},
+			want: want{
+				crd: &extv1.CustomResourceDefinition{
+					Spec: extv1.CustomResourceDefinitionSpec{
+						Group: "example.com",
+						Names: extv1.CustomResourceDefinitionNames{
+							Plural: "databases",
+							Kind:   "Database",
+						},
+						Scope:                 extv1.ClusterScoped,
+						PreserveUnknownFields: true,
+						Versions: []extv1.CustomResourceDefinitionVersion{
+							{
+								Name:    "v1",
+								Served:  true,
+								Storage: true,
+								Schema: &extv1.CustomResourceValidation{
+									OpenAPIV3Schema: &extv1.JSONSchemaProps{
+										Type: "object",
+										Properties: map[string]extv1.JSONSchemaProps{
+											"spec": {
+												Type: "object",
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		"InvalidSchemaError": {
+			reason: "Should return an error if schema conversion fails",
+			args: args{
+				mrd: &v2alpha1.ManagedResourceDefinition{
+					Spec: v2alpha1.ManagedResourceDefinitionSpec{
+						CustomResourceDefinitionSpec: v2alpha1.CustomResourceDefinitionSpec{
+							Group: "example.com",
+							Names: extv1.CustomResourceDefinitionNames{
+								Plural: "databases",
+								Kind:   "Database",
+							},
+							Scope: extv1.ClusterScoped,
+							Versions: []v2alpha1.CustomResourceDefinitionVersion{
+								{
+									Name:    "v1",
+									Served:  true,
+									Storage: true,
+									Schema:  invalidSchema,
+								},
+							},
+						},
+					},
+				},
+				crd: &extv1.CustomResourceDefinition{},
+			},
+			want: want{
+				crd: &extv1.CustomResourceDefinition{
+					Spec: extv1.CustomResourceDefinitionSpec{
+						Group: "example.com",
+						Names: extv1.CustomResourceDefinitionNames{
+							Plural: "databases",
+							Kind:   "Database",
+						},
+						Scope:    extv1.ClusterScoped,
+						Versions: []extv1.CustomResourceDefinitionVersion{{}},
+					},
+				},
+				err: errors.Wrap(errors.New("invalid character 'i' looking for beginning of object key string"), errParseValidation),
+			},
+		},
+		"EmptyVersions": {
+			reason: "Should handle empty versions list",
+			args: args{
+				mrd: &v2alpha1.ManagedResourceDefinition{
+					Spec: v2alpha1.ManagedResourceDefinitionSpec{
+						CustomResourceDefinitionSpec: v2alpha1.CustomResourceDefinitionSpec{
+							Group: "example.com",
+							Names: extv1.CustomResourceDefinitionNames{
+								Plural: "databases",
+								Kind:   "Database",
+							},
+							Scope:    extv1.ClusterScoped,
+							Versions: []v2alpha1.CustomResourceDefinitionVersion{},
+						},
+					},
+				},
+				crd: &extv1.CustomResourceDefinition{},
+			},
+			want: want{
+				crd: &extv1.CustomResourceDefinition{
+					Spec: extv1.CustomResourceDefinitionSpec{
+						Group: "example.com",
+						Names: extv1.CustomResourceDefinitionNames{
+							Plural: "databases",
+							Kind:   "Database",
+						},
+						Scope:    extv1.ClusterScoped,
+						Versions: []extv1.CustomResourceDefinitionVersion{},
+					},
+				},
+			},
+		},
+		"MergeIntoPreviousValues": {
+			reason: "Should merge into and replace existing CRD values",
+			args: args{
+				mrd: &v2alpha1.ManagedResourceDefinition{
+					Spec: v2alpha1.ManagedResourceDefinitionSpec{
+						CustomResourceDefinitionSpec: v2alpha1.CustomResourceDefinitionSpec{
+							Group: "newgroup.com",
+							Names: extv1.CustomResourceDefinitionNames{
+								Plural:   "newdatabases",
+								Singular: "newdatabase",
+								Kind:     "NewDatabase",
+							},
+							Scope: extv1.NamespaceScoped,
+							Versions: []v2alpha1.CustomResourceDefinitionVersion{
+								{
+									Name:    "v2",
+									Served:  true,
+									Storage: true,
+									Schema:  validSchema,
+								},
+							},
+							PreserveUnknownFields: true,
+						},
+					},
+				},
+				crd: &extv1.CustomResourceDefinition{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:        "olddatabases.oldgroup.com",
+						Annotations: map[string]string{"existing": "annotation"},
+					},
+					Spec: extv1.CustomResourceDefinitionSpec{
+						Group: "oldgroup.com",
+						Names: extv1.CustomResourceDefinitionNames{
+							Plural:   "olddatabases",
+							Singular: "olddatabase",
+							Kind:     "OldDatabase",
+						},
+						Scope: extv1.ClusterScoped,
+						Versions: []extv1.CustomResourceDefinitionVersion{
+							{
+								Name:    "v1beta1",
+								Served:  false,
+								Storage: false,
+							},
+						},
+						PreserveUnknownFields: false,
+					},
+				},
+			},
+			want: want{
+				crd: &extv1.CustomResourceDefinition{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:        "olddatabases.oldgroup.com",
+						Annotations: map[string]string{"existing": "annotation"},
+					},
+					Spec: extv1.CustomResourceDefinitionSpec{
+						Group: "newgroup.com",
+						Names: extv1.CustomResourceDefinitionNames{
+							Plural:   "newdatabases",
+							Singular: "newdatabase",
+							Kind:     "NewDatabase",
+						},
+						Scope: extv1.NamespaceScoped,
+						Versions: []extv1.CustomResourceDefinitionVersion{
+							{
+								Name:    "v2",
+								Served:  true,
+								Storage: true,
+								Schema: &extv1.CustomResourceValidation{
+									OpenAPIV3Schema: &extv1.JSONSchemaProps{
+										Type: "object",
+										Properties: map[string]extv1.JSONSchemaProps{
+											"spec": {
+												Type: "object",
+											},
+										},
+									},
+								},
+							},
+						},
+						PreserveUnknownFields: true,
+					},
+				},
+			},
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			err := MergeCustomResourceDefinitionInto(tc.args.mrd, tc.args.crd)
+			if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
+				t.Errorf("\n%s\nMergeCustomResourceDefinitionInto(...): -want error, +got error:\n%s", tc.reason, diff)
+			}
+			if diff := cmp.Diff(tc.want.crd, tc.args.crd); diff != "" {
+				t.Errorf("\n%s\nMergeCustomResourceDefinitionInto(...): -want, +got:\n%s", tc.reason, diff)
+			}
+		})
+	}
+}
+
+func TestToCustomResourceValidation(t *testing.T) {
+	validJSON := `{"type": "object", "properties": {"spec": {"type": "object"}}}`
+	invalidJSON := `{invalid json`
+
+	type args struct {
+		given *v2alpha1.CustomResourceValidation
+	}
+	type want struct {
+		validation *extv1.CustomResourceValidation
+		err        error
+	}
+
+	cases := map[string]struct {
+		reason string
+		args   args
+		want   want
+	}{
+		"ValidSchema": {
+			reason: "Should successfully convert valid schema",
+			args: args{
+				given: &v2alpha1.CustomResourceValidation{
+					OpenAPIV3Schema: runtime.RawExtension{
+						Raw: []byte(validJSON),
+					},
+				},
+			},
+			want: want{
+				validation: &extv1.CustomResourceValidation{
+					OpenAPIV3Schema: &extv1.JSONSchemaProps{
+						Type: "object",
+						Properties: map[string]extv1.JSONSchemaProps{
+							"spec": {
+								Type: "object",
+							},
+						},
+					},
+				},
+			},
+		},
+		"ComplexSchema": {
+			reason: "Should handle complex schema with multiple properties",
+			args: args{
+				given: &v2alpha1.CustomResourceValidation{
+					OpenAPIV3Schema: runtime.RawExtension{
+						Raw: []byte(`{
+							"type": "object",
+							"properties": {
+								"spec": {
+									"type": "object",
+									"properties": {
+										"replicas": {"type": "integer", "minimum": 1},
+										"image": {"type": "string"}
+									},
+									"required": ["image"]
+								},
+								"status": {
+									"type": "object",
+									"properties": {
+										"phase": {"type": "string"}
+									}
+								}
+							},
+							"required": ["spec"]
+						}`),
+					},
+				},
+			},
+			want: want{
+				validation: &extv1.CustomResourceValidation{
+					OpenAPIV3Schema: &extv1.JSONSchemaProps{
+						Type: "object",
+						Properties: map[string]extv1.JSONSchemaProps{
+							"spec": {
+								Type: "object",
+								Properties: map[string]extv1.JSONSchemaProps{
+									"replicas": {
+										Type:    "integer",
+										Minimum: ptr.To(1.0),
+									},
+									"image": {
+										Type: "string",
+									},
+								},
+								Required: []string{"image"},
+							},
+							"status": {
+								Type: "object",
+								Properties: map[string]extv1.JSONSchemaProps{
+									"phase": {
+										Type: "string",
+									},
+								},
+							},
+						},
+						Required: []string{"spec"},
+					},
+				},
+			},
+		},
+		"NilValidation": {
+			reason: "Should return an error for nil validation",
+			args: args{
+				given: nil,
+			},
+			want: want{
+				err: errors.New(errCustomResourceValidationNil),
+			},
+		},
+		"InvalidJSON": {
+			reason: "Should return an error for invalid JSON in schema",
+			args: args{
+				given: &v2alpha1.CustomResourceValidation{
+					OpenAPIV3Schema: runtime.RawExtension{
+						Raw: []byte(invalidJSON),
+					},
+				},
+			},
+			want: want{
+				err: errors.Wrap(errors.New("invalid character 'i' looking for beginning of object key string"), errParseValidation),
+			},
+		},
+		"EmptySchema": {
+			reason: "Should handle empty schema",
+			args: args{
+				given: &v2alpha1.CustomResourceValidation{
+					OpenAPIV3Schema: runtime.RawExtension{
+						Raw: []byte(`{}`),
+					},
+				},
+			},
+			want: want{
+				validation: &extv1.CustomResourceValidation{
+					OpenAPIV3Schema: &extv1.JSONSchemaProps{},
+				},
+			},
+		},
+		"SchemaWithArrayType": {
+			reason: "Should handle schema with array types",
+			args: args{
+				given: &v2alpha1.CustomResourceValidation{
+					OpenAPIV3Schema: runtime.RawExtension{
+						Raw: []byte(`{
+							"type": "object",
+							"properties": {
+								"spec": {
+									"type": "object",
+									"properties": {
+										"items": {
+											"type": "array",
+											"items": {"type": "string"}
+										}
+									}
+								}
+							}
+						}`),
+					},
+				},
+			},
+			want: want{
+				validation: &extv1.CustomResourceValidation{
+					OpenAPIV3Schema: &extv1.JSONSchemaProps{
+						Type: "object",
+						Properties: map[string]extv1.JSONSchemaProps{
+							"spec": {
+								Type: "object",
+								Properties: map[string]extv1.JSONSchemaProps{
+									"items": {
+										Type: "array",
+										Items: &extv1.JSONSchemaPropsOrArray{
+											Schema: &extv1.JSONSchemaProps{
+												Type: "string",
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		"SchemaWithEnum": {
+			reason: "Should handle schema with enum values",
+			args: args{
+				given: &v2alpha1.CustomResourceValidation{
+					OpenAPIV3Schema: runtime.RawExtension{
+						Raw: []byte(`{
+							"type": "object",
+							"properties": {
+								"spec": {
+									"type": "object",
+									"properties": {
+										"size": {
+											"type": "string",
+											"enum": ["small", "medium", "large"]
+										}
+									}
+								}
+							}
+						}`),
+					},
+				},
+			},
+			want: want{
+				validation: &extv1.CustomResourceValidation{
+					OpenAPIV3Schema: &extv1.JSONSchemaProps{
+						Type: "object",
+						Properties: map[string]extv1.JSONSchemaProps{
+							"spec": {
+								Type: "object",
+								Properties: map[string]extv1.JSONSchemaProps{
+									"size": {
+										Type: "string",
+										Enum: []extv1.JSON{
+											{Raw: []byte(`"small"`)},
+											{Raw: []byte(`"medium"`)},
+											{Raw: []byte(`"large"`)},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			got, err := toCustomResourceValidation(tc.args.given)
+			if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
+				t.Errorf("\n%s\ntoCustomResourceValidation(...): -want error, +got error:\n%s", tc.reason, diff)
+			}
+			if diff := cmp.Diff(tc.want.validation, got); diff != "" {
+				t.Errorf("\n%s\ntoCustomResourceValidation(...): -want, +got:\n%s", tc.reason, diff)
+			}
+		})
+	}
+}
diff --git a/test/e2e/apiextensions_mrds_test.go b/test/e2e/apiextensions_mrds_test.go
new file mode 100644
index 000000000..33da81a64
--- /dev/null
+++ b/test/e2e/apiextensions_mrds_test.go
@@ -0,0 +1,85 @@
+/*
+Copyright 2025 The Crossplane Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package e2e
+
+import (
+	"testing"
+	"time"
+
+	"sigs.k8s.io/e2e-framework/pkg/features"
+
+	"github.com/crossplane/crossplane/apis/apiextensions/v2alpha1"
+	"github.com/crossplane/crossplane/test/e2e/config"
+	"github.com/crossplane/crossplane/test/e2e/funcs"
+)
+
+func TestMRDValidation(t *testing.T) {
+	manifests := "test/e2e/manifests/apiextensions/mrd/validation"
+
+	cases := features.Table{
+		{
+			// A valid MRD should be created.
+			Name:        "ValidNewMRDIsAccepted",
+			Description: "A valid MRD should be created.",
+			Assessment: funcs.AllOf(
+				funcs.ApplyResources(FieldManager, manifests, "mrd-valid.yaml"),
+				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "mrd-valid.yaml"),
+				funcs.ResourcesHaveConditionWithin(1*time.Minute, manifests, "mrd-valid.yaml", v2alpha1.InactiveManaged()),
+			),
+		},
+		{
+			// An update to a valid MRD should be accepted.
+			Name:        "ValidUpdatedMRDIsAccepted",
+			Description: "A valid update to an MRD should be accepted.",
+			Assessment: funcs.AllOf(
+				funcs.ApplyResources(FieldManager, manifests, "mrd-valid-updated.yaml"),
+				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "mrd-valid-updated.yaml"),
+				funcs.ResourcesHaveConditionWithin(1*time.Minute, manifests, "mrd-valid-updated.yaml", v2alpha1.EstablishedManaged()),
+			),
+		},
+		{
+			// An update to an invalid MRD should be rejected.
+			Name:        "InvalidMRDUpdateIsRejected",
+			Description: "An invalid update to an MRD should be rejected.",
+			Assessment:  funcs.ResourcesFailToApply(FieldManager, manifests, "mrd-valid-updated-invalid.yaml"),
+		},
+		{
+			// An update to immutable MRD fields should be rejected.
+			Name:        "ImmutableMRDFieldUpdateIsRejected",
+			Description: "An update to immutable MRD field should be rejected.",
+			Assessment:  funcs.ResourcesFailToApply(FieldManager, manifests, "mrd-immutable-updated.yaml"),
+		},
+		{
+			// An invalid MRD should be rejected.
+			Name:        "InvalidMRDIsRejected",
+			Description: "An invalid MRD should be rejected.",
+			Assessment:  funcs.ResourcesFailToApply(FieldManager, manifests, "mrd-invalid.yaml"),
+		},
+	}
+	environment.Test(t,
+		cases.Build(t.Name()).
+			WithLabel(LabelStage, LabelStageAlpha).
+			WithLabel(LabelArea, LabelAreaAPIExtensions).
+			WithLabel(LabelSize, LabelSizeSmall).
+			WithLabel(config.LabelTestSuite, config.TestSuiteDefault).
+			WithTeardown("DeleteValidMRD", funcs.AllOf(
+				funcs.DeleteResources(manifests, "*-valid.yaml"),
+				funcs.ResourcesDeletedWithin(30*time.Second, manifests, "*-valid.yaml"),
+			)).
+			Feature(),
+	)
+}
diff --git a/test/e2e/manifests/apiextensions/mrd/validation/mrd-immutable-updated.yaml b/test/e2e/manifests/apiextensions/mrd/validation/mrd-immutable-updated.yaml
new file mode 100644
index 000000000..b5bcb4a34
--- /dev/null
+++ b/test/e2e/manifests/apiextensions/mrd/validation/mrd-immutable-updated.yaml
@@ -0,0 +1,29 @@
+apiVersion: apiextensions.crossplane.io/v2alpha1
+kind: ManagedResourceDefinition
+metadata:
+  name: xnopresources.nop.mrd.example.org
+spec:
+  state: Inactive
+  scope: Namespaced
+  group: nope.mrd.example.org # <-- invalid, field is immutable
+  names:
+    kind: XNopResource
+    plural: xnopresources
+    singular: xnopresource
+  versions:
+    - name: v1alpha1
+      served: true
+      storage: true
+      subresources:
+        status: { }
+      schema:
+        openAPIV3Schema:
+          type: object
+          properties:
+            spec:
+              type: object
+              properties:
+                coolField:
+                  type: string
+              required:
+                - coolField
diff --git a/test/e2e/manifests/apiextensions/mrd/validation/mrd-invalid.yaml b/test/e2e/manifests/apiextensions/mrd/validation/mrd-invalid.yaml
new file mode 100644
index 000000000..6118888f2
--- /dev/null
+++ b/test/e2e/manifests/apiextensions/mrd/validation/mrd-invalid.yaml
@@ -0,0 +1,29 @@
+apiVersion: apiextensions.crossplane.io/v2alpha1
+kind: ManagedResourceDefinition
+metadata:
+  name: xnopresources.nop.mrd.example.org
+spec:
+  state: Inactive
+  scope: Namespaced
+  group: nop.mrd.example.org
+  names:
+    kind: XNopResource
+    plural: Nopresourcestwo # <-- invalid, should be lowercase
+    singular: xnopresource
+  versions:
+    - name: v1alpha1
+      served: true
+      storage: true
+      subresources:
+        status: { }
+      schema:
+        openAPIV3Schema:
+          type: object
+          properties:
+            spec:
+              type: object
+              properties:
+                coolField:
+                  type: string
+              required:
+                - coolField
diff --git a/test/e2e/manifests/apiextensions/mrd/validation/mrd-valid-updated-invalid.yaml b/test/e2e/manifests/apiextensions/mrd/validation/mrd-valid-updated-invalid.yaml
new file mode 100644
index 000000000..a2b808da6
--- /dev/null
+++ b/test/e2e/manifests/apiextensions/mrd/validation/mrd-valid-updated-invalid.yaml
@@ -0,0 +1,29 @@
+apiVersion: apiextensions.crossplane.io/v2alpha1
+kind: ManagedResourceDefinition
+metadata:
+  name: xnopresources.nop.mrd.example.org
+spec:
+  state: Inactive
+  scope: Namespaced
+  group: nop.mrd.example.org
+  names:
+    kind: XNopResource
+    plural: Nopresources # <-- invalid, should be lowercase and immutable
+    singular: xnopresource
+  versions:
+    - name: v1alpha1
+      served: true
+      storage: true
+      subresources:
+        status: { }
+      schema:
+        openAPIV3Schema:
+          type: object
+          properties:
+            spec:
+              type: object
+              properties:
+                coolField:
+                  type: string
+              required:
+                - coolField
diff --git a/test/e2e/manifests/apiextensions/mrd/validation/mrd-valid-updated.yaml b/test/e2e/manifests/apiextensions/mrd/validation/mrd-valid-updated.yaml
new file mode 100644
index 000000000..799bb4581
--- /dev/null
+++ b/test/e2e/manifests/apiextensions/mrd/validation/mrd-valid-updated.yaml
@@ -0,0 +1,29 @@
+apiVersion: apiextensions.crossplane.io/v2alpha1
+kind: ManagedResourceDefinition
+metadata:
+  name: xnopresources.nop.mrd.example.org
+spec:
+  state: Active
+  scope: Namespaced
+  group: nop.mrd.example.org
+  names:
+    kind: XNopResource
+    plural: xnopresources
+    singular: xnopresource
+  versions:
+    - name: v1alpha1
+      served: true
+      storage: true
+      subresources:
+        status: { }
+      schema:
+        openAPIV3Schema:
+          type: object
+          properties:
+            spec:
+              type: object
+              properties:
+                coolField:
+                  type: string
+              required:
+                - coolField
diff --git a/test/e2e/manifests/apiextensions/mrd/validation/mrd-valid.yaml b/test/e2e/manifests/apiextensions/mrd/validation/mrd-valid.yaml
new file mode 100644
index 000000000..c3b5ec3b6
--- /dev/null
+++ b/test/e2e/manifests/apiextensions/mrd/validation/mrd-valid.yaml
@@ -0,0 +1,29 @@
+apiVersion: apiextensions.crossplane.io/v2alpha1
+kind: ManagedResourceDefinition
+metadata:
+  name: xnopresources.nop.mrd.example.org
+spec:
+  state: Inactive
+  scope: Namespaced
+  group: nop.mrd.example.org
+  names:
+    kind: XNopResource
+    plural: xnopresources
+    singular: xnopresource
+  versions:
+    - name: v1alpha1
+      served: true
+      storage: true
+      subresources:
+        status: { }
+      schema:
+        openAPIV3Schema:
+          type: object
+          properties:
+            spec:
+              type: object
+              properties:
+                coolField:
+                  type: string
+              required:
+                - coolField