Repository: crossplane/crossplane
Pull request #6321 (2025-03-08 00:25:51)

[v2] Support reconciling Namespaced and Cluster scoped XRs
<!--
Thank you for helping to improve Crossplane! Please read the contribution docs
(linked below) if this is your first Crossplane pull request.
-->

### Description of your changes

<!--
Briefly describe what this pull request does, and how it is covered by tests.
Be proactive - direct your reviewers' attention to anything that needs special
consideration.

We love pull requests that fix an open issue. If yours does, use the below line
to indicate which issue it fixes, for example "Fixes #500".
-->

Part of the prototype of https://github.com/crossplane/crossplane/pull/6255.

This updates the existing XR reconciler to support modern v2 style Namespaced and Cluster scoped XRs, as well as v1 style LegacyCluster XRs.

Three key changes enable this support:

1. The `definition` controller uses the `internal/xcrd` package to create CRDs with slightly different schemas depending on an XRD's `spec.scope`. The differences are whether the XR uses `spec.crossplane`, and whether refs may include a namespace. Some of this work was started in https://github.com/crossplane/crossplane/pull/6318.
2. The `composite` controller sets a new `Legacy` flag on `*composite.Unstructured` type it uses to reconcile XRs. The new flag mostly toggles `spec.crossplane` support off.
3. A handful of places in the `composite` controller now check whether the XR they're reconciling has a namespace, and act accordingly. e.g. Creating the XR's connection secret in its namespace.

I have: <!--You MUST either [x] check or [ ] ~strike through~ every item.-->

- [x] Read and followed Crossplane's [contribution process].
- [x] Run `earthly +reviewable` to ensure this PR is ready for review.
- [x] Added or updated unit tests.
- [x] Added or updated e2e tests.
- [ ] ~Linked a PR or a [docs tracking issue] to [document this change].~
- [ ] ~Added `backport release-x.y` labels to auto-backport this PR.~
- [ ] ~Followed the [API promotion workflow] if this PR introduces, removes, or promotes an API.~

Need help with this checklist? See the [cheat sheet].

[contribution process]: https://github.com/crossplane/crossplane/tree/main/contributing
[docs tracking issue]: https://github.com/crossplane/docs/issues/new
[document this change]: https://docs.crossplane.io/contribute/contribute
[cheat sheet]: https://github.com/crossplane/crossplane/tree/main/contributing#checklist-cheat-sheet
[API promotion workflow]: https://github.com/crossplane/crossplane/blob/main/contributing/guide-api-promotion.md


Patch:
diff --git a/cmd/crank/render/render.go b/cmd/crank/render/render.go
index 3ce27b49d..e91b451fa 100644
--- a/cmd/crank/render/render.go
+++ b/cmd/crank/render/render.go
@@ -389,7 +389,7 @@ func Render(ctx context.Context, log logging.Logger, in Inputs) (Outputs, error)
 // crossplane.io/claim-namespace, and crossplane.io/claim-name annotations.
 //
 // https://github.com/crossplane/crossplane/blob/0965f0/internal/controller/apiextensions/composite/composition_render.go#L117
-func SetComposedResourceMetadata(cd resource.Object, xr xresource.Composite, name string) error {
+func SetComposedResourceMetadata(cd resource.Object, xr xresource.LegacyComposite, name string) error {
 	cd.SetGenerateName(xr.GetName() + "-")
 	meta.AddAnnotations(cd, map[string]string{AnnotationKeyCompositionResourceName: name})
 	meta.AddLabels(cd, map[string]string{AnnotationKeyCompositeName: xr.GetName()})
diff --git a/internal/controller/apiextensions/claim/reconciler.go b/internal/controller/apiextensions/claim/reconciler.go
index f333bcfcf..3de5ee985 100644
--- a/internal/controller/apiextensions/claim/reconciler.go
+++ b/internal/controller/apiextensions/claim/reconciler.go
@@ -345,7 +345,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco
 		return reconcile.Result{}, errors.Wrap(r.client.Status().Update(ctx, cm), errUpdateClaimStatus)
 	}
 
-	xr := composite.New(composite.WithGroupVersionKind(r.gvkXR))
+	xr := composite.New(composite.WithGroupVersionKind(r.gvkXR), composite.WithSchema(composite.SchemaLegacy))
 	if ref := cm.GetResourceReference(); ref != nil {
 		record = record.WithAnnotations("composite-name", cm.GetResourceReference().Name)
 		log = log.WithValues("composite-name", cm.GetResourceReference().Name)
diff --git a/internal/controller/apiextensions/claim/syncer_csa.go b/internal/controller/apiextensions/claim/syncer_csa.go
index 9b9740be6..aa3a424a0 100644
--- a/internal/controller/apiextensions/claim/syncer_csa.go
+++ b/internal/controller/apiextensions/claim/syncer_csa.go
@@ -30,6 +30,7 @@ import (
 	"github.com/crossplane/crossplane-runtime/pkg/meta"
 	"github.com/crossplane/crossplane-runtime/pkg/resource"
 
+	v1 "github.com/crossplane/crossplane/apis/apiextensions/v1"
 	"github.com/crossplane/crossplane/internal/names"
 	"github.com/crossplane/crossplane/internal/xcrd"
 	"github.com/crossplane/crossplane/internal/xresource/unstructured/claim"
@@ -171,7 +172,7 @@ func (s *ClientSideCompositeSyncer) Sync(ctx context.Context, cm *claim.Unstruct
 		// XR status fields overwrite non-empty claim fields.
 		withMergeOptions(mergo.WithOverride),
 		// Don't sync XR machinery (i.e. status conditions, connection details).
-		withSrcFilter(xcrd.GetPropFields(xcrd.LegacyCompositeResourceStatusProps())...)); err != nil {
+		withSrcFilter(xcrd.GetPropFields(xcrd.CompositeResourceStatusProps(v1.CompositeResourceScopeLegacyCluster))...)); err != nil {
 		return errors.Wrap(err, errMergeClaimStatus)
 	}
 
@@ -194,7 +195,7 @@ func (s *ClientSideCompositeSyncer) Sync(ctx context.Context, cm *claim.Unstruct
 	// 2. Deleting any well-known fields that we want to propagate.
 	// 3. Filtering OUT the remaining map keys from the XR's spec so that we end
 	//    up adding only the well-known fields to the claim's spec.
-	wellKnownXRFields := xcrd.LegacyCompositeResourceSpecProps(nil)
+	wellKnownXRFields := xcrd.CompositeResourceSpecProps(v1.CompositeResourceScopeLegacyCluster, nil)
 	for _, field := range xcrd.PropagateSpecProps {
 		delete(wellKnownXRFields, field)
 	}
diff --git a/internal/controller/apiextensions/claim/syncer_ssa.go b/internal/controller/apiextensions/claim/syncer_ssa.go
index e1de9e687..966aa8f63 100644
--- a/internal/controller/apiextensions/claim/syncer_ssa.go
+++ b/internal/controller/apiextensions/claim/syncer_ssa.go
@@ -29,6 +29,7 @@ import (
 	"github.com/crossplane/crossplane-runtime/pkg/meta"
 	"github.com/crossplane/crossplane-runtime/pkg/resource"
 
+	v1 "github.com/crossplane/crossplane/apis/apiextensions/v1"
 	"github.com/crossplane/crossplane/internal/names"
 	"github.com/crossplane/crossplane/internal/xcrd"
 	"github.com/crossplane/crossplane/internal/xresource/unstructured/claim"
@@ -166,7 +167,7 @@ func (s *ServerSideCompositeSyncer) Sync(ctx context.Context, cm *claim.Unstruct
 	// Create an empty XR patch object. We'll use this object to ensure we only
 	// SSA our desired state, not the state we previously read from the API
 	// server.
-	xrPatch := composite.New(composite.WithGroupVersionKind(xr.GroupVersionKind()))
+	xrPatch := composite.New(composite.WithGroupVersionKind(xr.GroupVersionKind()), composite.WithSchema(composite.SchemaLegacy))
 
 	// If the claim references an XR, make sure we're going to apply that XR. We
 	// do this instead of using the supplied XR's name just in case the XR
@@ -319,7 +320,7 @@ func (s *ServerSideCompositeSyncer) Sync(ctx context.Context, cm *claim.Unstruct
 	pub := cm.GetConnectionDetailsLastPublishedTime()
 
 	// Update the claim's user-defined status fields to match the XRs.
-	cm.Object["status"] = withoutKeys(xrStatus, xcrd.GetPropFields(xcrd.LegacyCompositeResourceStatusProps())...)
+	cm.Object["status"] = withoutKeys(xrStatus, xcrd.GetPropFields(xcrd.CompositeResourceStatusProps(v1.CompositeResourceScopeLegacyCluster))...)
 
 	if cmcs.Conditions != nil {
 		cm.SetConditions(cmcs.Conditions...)
diff --git a/internal/controller/apiextensions/composite/composition_functions.go b/internal/controller/apiextensions/composite/composition_functions.go
index 56b049c9e..d3d3aa387 100644
--- a/internal/controller/apiextensions/composite/composition_functions.go
+++ b/internal/controller/apiextensions/composite/composition_functions.go
@@ -57,7 +57,7 @@ const (
 	errGetExistingCDs           = "cannot get existing composed resources"
 	errBuildObserved            = "cannot build observed state for RunFunctionRequest"
 	errGarbageCollectCDs        = "cannot garbage collect composed resources that are no longer desired"
-	errApplyXRRefs              = "cannot update composite resource spec.resourceRefs"
+	errApplyXRRefs              = "cannot update composed resource references"
 	errApplyXRStatus            = "cannot apply composite resource status"
 	errAnonymousCD              = "encountered composed resource without required \"" + AnnotationKeyCompositionResourceName + "\" annotation"
 	errUnmarshalDesiredXRStatus = "cannot unmarshal desired composite resource status from RunFunctionResponse"
@@ -461,9 +461,8 @@ func (c *FunctionComposer) Compose(ctx context.Context, xr *composite.Unstructur
 	// randomly generated names and hit an error applying the second one we need
 	// to know that the first one (that _was_ created) exists next time we
 	// reconcile the XR.
-	refs := composite.New()
-	refs.SetAPIVersion(xr.GetAPIVersion())
-	refs.SetKind(xr.GetKind())
+	refs := composite.New(composite.WithSchema(xr.Schema), composite.WithGroupVersionKind(xr.GroupVersionKind()))
+	refs.SetNamespace(xr.GetNamespace())
 	refs.SetName(xr.GetName())
 	UpdateResourceRefs(refs, desired)
 
@@ -478,13 +477,14 @@ func (c *FunctionComposer) Compose(ctx context.Context, xr *composite.Unstructur
 		return CompositionResult{}, errors.Wrap(err, errApplyXRRefs)
 	}
 
-	// TODO: Remove this call to Upgrade once no supported version of
-	// Crossplane have native P&T available. We only need to upgrade field managers if the
-	// native PTComposer might have applied the composed resources before, using the
-	// default client-side apply field manager "crossplane",
-	// but now migrated to use Composition functions, which uses server-side apply instead.
-	// Without this managedFields upgrade, the composed resources ends up having shared ownership
-	// of fields and field removals won't sync properly.
+	// TODO: Remove this call to Upgrade once no supported version of Crossplane
+	// have native P&T available. We only need to upgrade field managers if the
+	// native PTComposer might have applied the composed resources before, using
+	// the default client-side apply field manager "crossplane", but now
+	// migrated to use Composition functions, which uses server-side apply
+	// instead. Without this managedFields upgrade, the composed resources ends
+	// up having shared ownership of fields and field removals won't sync
+	// properly.
 	for _, cd := range observed {
 		if err := c.composite.ManagedFieldsUpgrader.Upgrade(ctx, cd.Resource); err != nil {
 			return CompositionResult{}, errors.Wrap(err, "cannot upgrade composed resource's managed fields from client-side to server-side apply")
@@ -542,6 +542,7 @@ func (c *FunctionComposer) Compose(ctx context.Context, xr *composite.Unstructur
 	// GVK and name so that our client knows what resource to patch.
 	v := xr.GetAPIVersion()
 	k := xr.GetKind()
+	ns := xr.GetNamespace()
 	n := xr.GetName()
 	u := xr.GetUID()
 	if err := FromStruct(xr, d.GetComposite().GetResource()); err != nil {
@@ -549,6 +550,7 @@ func (c *FunctionComposer) Compose(ctx context.Context, xr *composite.Unstructur
 	}
 	xr.SetAPIVersion(v)
 	xr.SetKind(k)
+	xr.SetNamespace(ns)
 	xr.SetName(n)
 	xr.SetUID(u)
 
@@ -623,8 +625,16 @@ func (g *ExistingComposedResourceObserver) ObserveComposedResources(ctx context.
 			continue
 		}
 
-		r := composed.New(composed.FromReference(ref))
+		// Cluster scoped XRs can compose resources either at the cluster scope,
+		// or in arbitrary namespaces. They can optionally include a namespace
+		// in their composed resource references. Namespaced XRs must compose
+		// resources in their own namespace.
 		nn := types.NamespacedName{Namespace: ref.Namespace, Name: ref.Name}
+		if xr.GetNamespace() != "" {
+			nn.Namespace = xr.GetNamespace()
+		}
+
+		r := composed.New(composed.FromReference(ref))
 		err := g.cached.Get(ctx, nn, r)
 		if kerrors.IsNotFound(err) {
 			// We believe we created this resource, but it is not in the cache yet?  Try again without the cache.
@@ -787,10 +797,19 @@ func (d *DeletingComposedResourceGarbageCollector) GarbageCollectComposedResourc
 
 // UpdateResourceRefs updates the supplied state to ensure the XR references all
 // composed resources that exist or are pending creation.
-func UpdateResourceRefs(xr resource.ComposedResourcesReferencer, desired ComposedResourceStates) {
+func UpdateResourceRefs(xr xresource.Composite, desired ComposedResourceStates) {
+	namespaced := xr.GetNamespace() != ""
 	refs := make([]corev1.ObjectReference, 0, len(desired))
 	for _, dr := range desired {
 		ref := meta.ReferenceTo(dr.Resource, dr.Resource.GetObjectKind().GroupVersionKind())
+
+		// If the XR is namespaced it can only compose resources in its own
+		// namespace. Its OpenAPI schema won't allow including a namespace in
+		// its resourceRefs.
+		if namespaced {
+			ref.Namespace = ""
+		}
+
 		refs = append(refs, *ref)
 	}
 
diff --git a/internal/controller/apiextensions/composite/composition_render.go b/internal/controller/apiextensions/composite/composition_render.go
index b78a27301..f45de7d60 100644
--- a/internal/controller/apiextensions/composite/composition_render.go
+++ b/internal/controller/apiextensions/composite/composition_render.go
@@ -83,14 +83,23 @@ func RenderComposedResourceMetadata(cd, xr resource.Object, n ResourceName) erro
 		return errors.Errorf(errFmtNamePrefixLabel, xcrd.LabelKeyNamePrefixForComposed)
 	}
 
-	//  We also set generate name in case we
-	// haven't yet named this composed resource.
+	// We also set generate name in case we haven't yet named this composed
+	// resource.
 	cd.SetGenerateName(xr.GetLabels()[xcrd.LabelKeyNamePrefixForComposed] + "-")
 
+	// If the XR is namespaced it can only create composed resources in its own
+	// namespace. Cluster scoped XRs can compose cluster scoped resources, or
+	// resources in any namespace.
+	if xr.GetNamespace() != "" {
+		cd.SetNamespace(xr.GetNamespace())
+	}
+
 	if n != "" {
 		SetCompositionResourceName(cd, n)
 	}
 
+	// TODO(negz): What happens if there is no claim? Will this set empty
+	// claim name/namespace labels?
 	meta.AddLabels(cd, map[string]string{
 		xcrd.LabelKeyNamePrefixForComposed: xr.GetLabels()[xcrd.LabelKeyNamePrefixForComposed],
 		xcrd.LabelKeyClaimName:             xr.GetLabels()[xcrd.LabelKeyClaimName],
diff --git a/internal/controller/apiextensions/composite/connection.go b/internal/controller/apiextensions/composite/connection.go
index 48e76365e..1de19e415 100644
--- a/internal/controller/apiextensions/composite/connection.go
+++ b/internal/controller/apiextensions/composite/connection.go
@@ -1,5 +1,5 @@
 /*
-Copyright 2022 The Crossplane Authors.
+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.
@@ -79,12 +79,18 @@ func NewSecretConnectionDetailsFetcher(c client.Client) *SecretConnectionDetails
 func (cdf *SecretConnectionDetailsFetcher) FetchConnection(ctx context.Context, o xresource.ConnectionSecretOwner) (managed.ConnectionDetails, error) {
 	sref := o.GetWriteConnectionSecretToReference()
 	if sref == nil {
-		// secret but has not yet. We presume this isn't an issue and that we'll
-		// propagate any connection details during a future iteration.
 		return nil, nil
 	}
+
+	// Namespaced resources always write connection secrets to their own
+	// namespace. Cluster scoped resources should have a namespace in the ref.
+	ns := o.GetNamespace()
+	if ns == "" {
+		ns = sref.Namespace
+	}
+
 	s := &corev1.Secret{}
-	nn := types.NamespacedName{Namespace: sref.Namespace, Name: sref.Name}
+	nn := types.NamespacedName{Namespace: ns, Name: sref.Name}
 	if err := cdf.client.Get(ctx, nn, s); client.IgnoreNotFound(err) != nil {
 		return nil, errors.Wrap(err, errGetSecret)
 	}
diff --git a/internal/controller/apiextensions/composite/reconciler.go b/internal/controller/apiextensions/composite/reconciler.go
index f3b096801..1f92313a7 100644
--- a/internal/controller/apiextensions/composite/reconciler.go
+++ b/internal/controller/apiextensions/composite/reconciler.go
@@ -375,6 +375,14 @@ func WithWatchStarter(controllerName string, h handler.EventHandler, w WatchStar
 	}
 }
 
+// WithCompositeSchema specifies whether the Reconciler should reconcile a
+// modern or a legacy type of composite resource.
+func WithCompositeSchema(s composite.Schema) ReconcilerOption {
+	return func(r *Reconciler) {
+		r.schema = s
+	}
+}
+
 type revision struct {
 	CompositionRevisionFetcher
 }
@@ -454,7 +462,9 @@ func NewReconciler(cached client.Client, of schema.GroupVersionKind, opts ...Rec
 // A Reconciler reconciles composite resources.
 type Reconciler struct {
 	client client.Client
+
 	gvk    schema.GroupVersionKind
+	schema composite.Schema
 
 	revision  revision
 	composite compositeResource
@@ -480,7 +490,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco
 	ctx, cancel := context.WithTimeout(ctx, timeout)
 	defer cancel()
 
-	xr := composite.New(composite.WithGroupVersionKind(r.gvk))
+	xr := composite.New(composite.WithGroupVersionKind(r.gvk), composite.WithSchema(r.schema))
 	if err := r.client.Get(ctx, req.NamespacedName, xr); err != nil {
 		log.Debug(errGet, "error", err)
 		return reconcile.Result{}, errors.Wrap(resource.IgnoreNotFound(err), errGet)
@@ -540,6 +550,9 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco
 
 	orig := xr.GetCompositionReference()
 	if err := r.composite.SelectComposition(ctx, xr); err != nil {
+		if kerrors.IsConflict(err) {
+			return reconcile.Result{Requeue: true}, nil
+		}
 		err = errors.Wrap(err, errSelectComp)
 		r.record.Event(xr, event.Warning(reasonResolve, err))
 		xr.SetConditions(xpv1.ReconcileError(err))
diff --git a/internal/controller/apiextensions/composite/watch/watch.go b/internal/controller/apiextensions/composite/watch/watch.go
index 1f501a368..dfc68a448 100644
--- a/internal/controller/apiextensions/composite/watch/watch.go
+++ b/internal/controller/apiextensions/composite/watch/watch.go
@@ -47,7 +47,8 @@ type ControllerEngine interface {
 // still reference.
 type GarbageCollector struct {
 	controllerName string
-	xrGVK          schema.GroupVersionKind
+	gvk            schema.GroupVersionKind
+	schema         composite.Schema
 
 	engine ControllerEngine
 
@@ -64,11 +65,19 @@ func WithLogger(l logging.Logger) GarbageCollectorOption {
 	}
 }
 
+// WithCompositeSchema configures whether to garbage collect a modern or a
+// legacy composite resource.
+func WithCompositeSchema(s composite.Schema) GarbageCollectorOption {
+	return func(gc *GarbageCollector) {
+		gc.schema = s
+	}
+}
+
 // NewGarbageCollector creates a new watch garbage collector for a controller.
 func NewGarbageCollector(name string, of schema.GroupVersionKind, ce ControllerEngine, o ...GarbageCollectorOption) *GarbageCollector {
 	gc := &GarbageCollector{
 		controllerName: name,
-		xrGVK:          of,
+		gvk:            of,
 		engine:         ce,
 		log:            logging.NewNopLogger(),
 	}
@@ -104,8 +113,8 @@ func (gc *GarbageCollector) GarbageCollectWatches(ctx context.Context, interval
 func (gc *GarbageCollector) GarbageCollectWatchesNow(ctx context.Context) error {
 	// List all XRs of the type we're interested in.
 	l := &kunstructured.UnstructuredList{}
-	l.SetAPIVersion(gc.xrGVK.GroupVersion().String())
-	l.SetKind(gc.xrGVK.Kind + "List")
+	l.SetAPIVersion(gc.gvk.GroupVersion().String())
+	l.SetKind(gc.gvk.Kind + "List")
 	if err := gc.engine.GetCached().List(ctx, l); err != nil {
 		return errors.Wrap(err, "cannot list composite resources")
 	}
@@ -113,7 +122,7 @@ func (gc *GarbageCollector) GarbageCollectWatchesNow(ctx context.Context) error
 	// Build the set of GVKs they still reference.
 	used := make(map[engine.WatchID]bool)
 	for _, u := range l.Items {
-		xr := &composite.Unstructured{Unstructured: u}
+		xr := &composite.Unstructured{Unstructured: u, Schema: gc.schema}
 		for _, ref := range xr.GetResourceReferences() {
 			used[engine.WatchID{Type: engine.WatchTypeComposedResource, GVK: schema.FromAPIVersionAndKind(ref.APIVersion, ref.Kind)}] = true
 		}
diff --git a/internal/controller/apiextensions/definition/handlers.go b/internal/controller/apiextensions/definition/handlers.go
index 15bf3bfdc..903e98091 100644
--- a/internal/controller/apiextensions/definition/handlers.go
+++ b/internal/controller/apiextensions/definition/handlers.go
@@ -37,19 +37,26 @@ import (
 
 // EnqueueForCompositionRevision enqueues reconciles for all XRs that will use a
 // newly created CompositionRevision.
-func EnqueueForCompositionRevision(of schema.GroupVersionKind, c client.Reader, log logging.Logger) handler.Funcs {
+func EnqueueForCompositionRevision(of schema.GroupVersionKind, s composite.Schema, c client.Reader, log logging.Logger) handler.Funcs {
 	return handler.Funcs{
 		CreateFunc: func(ctx context.Context, e kevent.CreateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) {
 			rev, ok := e.Object.(*v1.CompositionRevision)
 			if !ok {
-				// should not happen
 				return
 			}
 
-			// TODO(negz): Check whether the revision's compositeTypeRef matches
-			// the supplied CompositeKind. If it doesn't, we can return early.
+			if rev.Spec.CompositeTypeRef.APIVersion != of.GroupVersion().String() {
+				return
+			}
+			if rev.Spec.CompositeTypeRef.Kind != of.Kind {
+				return
+			}
+
+			compName := rev.Labels[v1.LabelCompositionName]
+			if compName == "" {
+				return
+			}
 
-			// get all XRs
 			xrs := kunstructured.UnstructuredList{}
 			xrs.SetGroupVersionKind(of)
 			xrs.SetKind(of.Kind + "List")
@@ -60,14 +67,8 @@ func EnqueueForCompositionRevision(of schema.GroupVersionKind, c client.Reader,
 				return
 			}
 
-			// enqueue all those that reference the Composition of this revision
-			compName := rev.Labels[v1.LabelCompositionName]
-			// TODO(negz): Check this before we get all XRs.
-			if compName == "" {
-				return
-			}
 			for _, u := range xrs.Items {
-				xr := composite.Unstructured{Unstructured: u}
+				xr := composite.Unstructured{Unstructured: u, Schema: s}
 
 				// only automatic
 				if pol := xr.GetCompositionUpdatePolicy(); pol != nil && *pol == xpv1.UpdateManual {
diff --git a/internal/controller/apiextensions/definition/indexes.go b/internal/controller/apiextensions/definition/indexes.go
index 13debf45f..65209392f 100644
--- a/internal/controller/apiextensions/definition/indexes.go
+++ b/internal/controller/apiextensions/definition/indexes.go
@@ -31,22 +31,23 @@ const (
 	compositeResourcesRefsIndex = "compositeResourcesRefs"
 )
 
-var _ client.IndexerFunc = IndexCompositeResourcesRefs
-
-// IndexCompositeResourcesRefs assumes the passed object is a composite. It
-// returns keys for every composed resource referenced in the composite.
-func IndexCompositeResourcesRefs(o client.Object) []string {
-	u, ok := o.(*kunstructured.Unstructured)
-	if !ok {
-		return nil // should never happen
+// IndexCompositeResourcesRefs returns an IndexerFunc that assumes the passed
+// object is a composite. It returns keys for every composed resource referenced
+// in the composite.
+func IndexCompositeResourcesRefs(s composite.Schema) client.IndexerFunc {
+	return func(o client.Object) []string {
+		u, ok := o.(*kunstructured.Unstructured)
+		if !ok {
+			return nil // should never happen
+		}
+		xr := composite.Unstructured{Unstructured: *u, Schema: s}
+		refs := xr.GetResourceReferences()
+		keys := make([]string, 0, len(refs))
+		for _, ref := range refs {
+			keys = append(keys, refKey(ref.Namespace, ref.Name, ref.Kind, ref.APIVersion))
+		}
+		return keys
 	}
-	xr := composite.Unstructured{Unstructured: *u}
-	refs := xr.GetResourceReferences()
-	keys := make([]string, 0, len(refs))
-	for _, ref := range refs {
-		keys = append(keys, refKey(ref.Namespace, ref.Name, ref.Kind, ref.APIVersion))
-	}
-	return keys
 }
 
 func refKey(ns, name, kind, apiVersion string) string {
diff --git a/internal/controller/apiextensions/definition/reconciler.go b/internal/controller/apiextensions/definition/reconciler.go
index 4f2fd1790..50a5b6b86 100644
--- a/internal/controller/apiextensions/definition/reconciler.go
+++ b/internal/controller/apiextensions/definition/reconciler.go
@@ -30,6 +30,7 @@ import (
 	kunstructured "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/client-go/util/workqueue"
+	"k8s.io/utils/ptr"
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/builder"
 	"sigs.k8s.io/controller-runtime/pkg/client"
@@ -51,6 +52,7 @@ import (
 	"github.com/crossplane/crossplane/internal/engine"
 	"github.com/crossplane/crossplane/internal/features"
 	"github.com/crossplane/crossplane/internal/xcrd"
+	ucomposite "github.com/crossplane/crossplane/internal/xresource/unstructured/composite"
 )
 
 const (
@@ -469,10 +471,51 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco
 		return reconcile.Result{Requeue: false}, errors.Wrap(r.client.Status().Update(ctx, d), errUpdateStatus)
 	}
 
-	ro := r.CompositeReconcilerOptions(ctx, d)
-	ck := d.GetCompositeGroupVersionKind()
+	runner := composite.NewFetchingFunctionRunner(r.options.FunctionRunner, composite.NewExistingExtraResourcesFetcher(r.engine.GetCached()))
+	fetcher := composite.NewSecretConnectionDetailsFetcher(r.engine.GetCached())
+	fc := composite.NewFunctionComposer(r.engine.GetCached(), r.engine.GetUncached(), runner,
+		composite.WithComposedResourceObserver(composite.NewExistingComposedResourceObserver(r.engine.GetCached(), r.engine.GetUncached(), fetcher)),
+		composite.WithCompositeConnectionDetailsFetcher(fetcher),
+	)
+
+	// All XRs have modern schema unless their XRD's scope is LegacyCluster.
+	schema := ucomposite.SchemaModern
+	if ptr.Deref(d.Spec.Scope, v1.CompositeResourceScopeLegacyCluster) == v1.CompositeResourceScopeLegacyCluster {
+		schema = ucomposite.SchemaLegacy
+	}
+
+	ro := []composite.ReconcilerOption{
+		composite.WithCompositeSchema(schema),
+		composite.WithConnectionPublishers(composite.NewAPIFilteredSecretPublisher(r.engine.GetCached(), d.GetConnectionSecretKeys())),
+		composite.WithCompositionSelector(composite.NewCompositionSelectorChain(
+			composite.NewEnforcedCompositionSelector(*d, r.record),
+			composite.NewAPIDefaultCompositionSelector(r.engine.GetCached(), *meta.ReferenceTo(d, v1.CompositeResourceDefinitionGroupVersionKind), r.record),
+			composite.NewAPILabelSelectorResolver(r.engine.GetCached()),
+		)),
+		composite.WithLogger(r.log.WithValues("controller", composite.ControllerName(d.GetName()))),
+		composite.WithRecorder(r.record.WithAnnotations("controller", composite.ControllerName(d.GetName()))),
+		composite.WithPollInterval(r.options.PollInterval),
+		composite.WithComposer(fc),
+	}
+
+	// If realtime compositions are enabled we pass the ControllerEngine to the
+	// XR reconciler so that it can start watches for composed resources.
+	if r.options.Features.Enabled(features.EnableAlphaRealtimeCompositions) {
+		gvk := d.GetCompositeGroupVersionKind()
+		u := &kunstructured.Unstructured{}
+		u.SetAPIVersion(gvk.GroupVersion().String())
+		u.SetKind(gvk.Kind)
 
-	cr := composite.NewReconciler(r.engine.GetCached(), ck, ro...)
+		// Add an index to the controller engine's client.
+		if err := r.engine.GetFieldIndexer().IndexField(ctx, u, compositeResourcesRefsIndex, IndexCompositeResourcesRefs(schema)); err != nil {
+			r.log.Debug(errAddIndex, "error", err)
+		}
+
+		h := EnqueueCompositeResources(d.GetCompositeGroupVersionKind(), r.engine.GetCached(), r.log)
+		ro = append(ro, composite.WithWatchStarter(composite.ControllerName(d.GetName()), h, r.engine))
+	}
+
+	cr := composite.NewReconciler(r.engine.GetCached(), d.GetCompositeGroupVersionKind(), ro...)
 	ko := r.options.ForControllerRuntime()
 
 	// Most controllers use this type of rate limiter to backoff requeues from 1
@@ -484,7 +527,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco
 	ko.RateLimiter = workqueue.NewTypedItemExponentialFailureRateLimiter[reconcile.Request](1*time.Second, 30*time.Second)
 	ko.Reconciler = ratelimiter.NewReconciler(composite.ControllerName(d.GetName()), errors.WithSilentRequeueOnConflict(cr), r.options.GlobalRateLimiter)
 
-	xrGVK := d.GetCompositeGroupVersionKind()
+	gvk := d.GetCompositeGroupVersionKind()
 	name := composite.ControllerName(d.GetName())
 
 	// TODO(negz): Update CompositeReconcilerOptions to produce
@@ -495,7 +538,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco
 		// If realtime composition is enabled we'll start watches dynamically,
 		// so we want to garbage collect watches for composed resource kinds
 		// that aren't used anymore.
-		gc := watch.NewGarbageCollector(name, xrGVK, r.engine, watch.WithLogger(log))
+		gc := watch.NewGarbageCollector(name, gvk, r.engine, watch.WithCompositeSchema(schema), watch.WithLogger(log))
 		co = append(co, engine.WithWatchGarbageCollector(gc))
 	}
 
@@ -510,9 +553,9 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco
 	// controller-runtime doesn't support watching types that satisfy the
 	// runtime.Unstructured interface - only *unstructured.Unstructured.
 	xr := &kunstructured.Unstructured{}
-	xr.SetGroupVersionKind(xrGVK)
+	xr.SetGroupVersionKind(gvk)
 
-	crh := EnqueueForCompositionRevision(xrGVK, r.engine.GetCached(), log)
+	crh := EnqueueForCompositionRevision(gvk, schema, r.engine.GetCached(), log)
 	if err := r.engine.StartWatches(name,
 		engine.WatchFor(xr, engine.WatchTypeCompositeResource, &handler.EnqueueRequestForObject{}),
 		engine.WatchFor(&v1.CompositionRevision{}, engine.WatchTypeCompositionRevision, crh),
@@ -529,55 +572,3 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco
 	d.Status.SetConditions(v1.WatchingComposite())
 	return reconcile.Result{Requeue: false}, errors.Wrap(r.client.Status().Update(ctx, d), errUpdateStatus)
 }
-
-// CompositeReconcilerOptions builds the options for a composite resource
-// reconciler. The options vary based on the supplied feature flags.
-func (r *Reconciler) CompositeReconcilerOptions(ctx context.Context, d *v1.CompositeResourceDefinition) []composite.ReconcilerOption {
-	// The default set of reconciler options when no feature flags are enabled.
-	o := []composite.ReconcilerOption{
-		composite.WithConnectionPublishers(composite.NewAPIFilteredSecretPublisher(r.engine.GetCached(), d.GetConnectionSecretKeys())),
-		composite.WithCompositionSelector(composite.NewCompositionSelectorChain(
-			composite.NewEnforcedCompositionSelector(*d, r.record),
-			composite.NewAPIDefaultCompositionSelector(r.engine.GetCached(), *meta.ReferenceTo(d, v1.CompositeResourceDefinitionGroupVersionKind), r.record),
-			composite.NewAPILabelSelectorResolver(r.engine.GetCached()),
-		)),
-		composite.WithLogger(r.log.WithValues("controller", composite.ControllerName(d.GetName()))),
-		composite.WithRecorder(r.record.WithAnnotations("controller", composite.ControllerName(d.GetName()))),
-		composite.WithPollInterval(r.options.PollInterval),
-	}
-
-	// Wrap the PackagedFunctionRunner setup in main with support for loading
-	// extra resources to satisfy function requirements.
-	runner := composite.NewFetchingFunctionRunner(r.options.FunctionRunner, composite.NewExistingExtraResourcesFetcher(r.engine.GetCached()))
-
-	fetcher := composite.NewSecretConnectionDetailsFetcher(r.engine.GetCached())
-
-	// This composer is used for mode: Pipeline Compositions.
-	fc := composite.NewFunctionComposer(r.engine.GetCached(), r.engine.GetUncached(), runner,
-		composite.WithComposedResourceObserver(composite.NewExistingComposedResourceObserver(r.engine.GetCached(), r.engine.GetUncached(), fetcher)),
-		composite.WithCompositeConnectionDetailsFetcher(fetcher),
-	)
-
-	// We use two different Composer implementations. One supports P&T (aka
-	// 'Resources mode') and the other Functions (aka 'Pipeline mode').
-	o = append(o, composite.WithComposer(fc))
-
-	// If realtime compositions are enabled we pass the ControllerEngine to the
-	// XR reconciler so that it can start watches for composed resources.
-	if r.options.Features.Enabled(features.EnableAlphaRealtimeCompositions) {
-		gvk := d.GetCompositeGroupVersionKind()
-		u := &kunstructured.Unstructured{}
-		u.SetAPIVersion(gvk.GroupVersion().String())
-		u.SetKind(gvk.Kind)
-
-		// Add an index to the controller engine's client.
-		if err := r.engine.GetFieldIndexer().IndexField(ctx, u, compositeResourcesRefsIndex, IndexCompositeResourcesRefs); err != nil {
-			r.log.Debug(errAddIndex, "error", err)
-		}
-
-		h := EnqueueCompositeResources(d.GetCompositeGroupVersionKind(), r.engine.GetCached(), r.log)
-		o = append(o, composite.WithWatchStarter(composite.ControllerName(d.GetName()), h, r.engine))
-	}
-
-	return o
-}
diff --git a/internal/controller/apiextensions/offered/watch.go b/internal/controller/apiextensions/offered/watch.go
index e51498bd1..42189fda4 100644
--- a/internal/controller/apiextensions/offered/watch.go
+++ b/internal/controller/apiextensions/offered/watch.go
@@ -99,7 +99,7 @@ func addClaim(obj runtime.Object, queue adder) {
 	if !ok || u == nil {
 		return
 	}
-	cp := &composite.Unstructured{Unstructured: *u}
+	cp := &composite.Unstructured{Unstructured: *u, Schema: composite.SchemaLegacy}
 	if ref := cp.GetClaimReference(); ref != nil {
 		queue.Add(reconcile.Request{NamespacedName: types.NamespacedName{Namespace: ref.Namespace, Name: ref.Name}})
 	}
diff --git a/internal/xcrd/crd.go b/internal/xcrd/crd.go
index 783f64ca7..9337d8d4b 100644
--- a/internal/xcrd/crd.go
+++ b/internal/xcrd/crd.go
@@ -89,18 +89,12 @@ func ForCompositeResource(xrd *v1.CompositeResourceDefinition) (*extv1.CustomRes
 		if err != nil {
 			return nil, errors.Wrapf(err, errFmtGenCrd, "Composite Resource", xrd.Name)
 		}
-		crdv.AdditionalPrinterColumns = append(crdv.AdditionalPrinterColumns, CompositeResourcePrinterColumns()...)
-		props := CompositeResourceSpecProps(xrd.Spec.DefaultCompositionUpdatePolicy)
-		if scope == v1.CompositeResourceScopeLegacyCluster {
-			props = LegacyCompositeResourceSpecProps(xrd.Spec.DefaultCompositionUpdatePolicy)
-		}
+		crdv.AdditionalPrinterColumns = append(crdv.AdditionalPrinterColumns, CompositeResourcePrinterColumns(scope)...)
+		props := CompositeResourceSpecProps(scope, xrd.Spec.DefaultCompositionUpdatePolicy)
 		for k, v := range props {
 			crdv.Schema.OpenAPIV3Schema.Properties["spec"].Properties[k] = v
 		}
-		props = CompositeResourceStatusProps()
-		if scope == v1.CompositeResourceScopeLegacyCluster {
-			props = LegacyCompositeResourceStatusProps()
-		}
+		props = CompositeResourceStatusProps(scope)
 		for k, v := range props {
 			crdv.Schema.OpenAPIV3Schema.Properties["status"].Properties[k] = v
 		}
@@ -150,7 +144,9 @@ func ForCompositeResourceClaim(xrd *v1.CompositeResourceDefinition) (*extv1.Cust
 		for k, v := range props {
 			crdv.Schema.OpenAPIV3Schema.Properties["spec"].Properties[k] = v
 		}
-		props = LegacyCompositeResourceStatusProps()
+		// TODO(negz): This means claims will have status.claimConditionTypes.
+		// I think that's a bug - only XRs should have that field.
+		props = CompositeResourceStatusProps(v1.CompositeResourceScopeLegacyCluster)
 		for k, v := range props {
 			crdv.Schema.OpenAPIV3Schema.Properties["status"].Properties[k] = v
 		}
diff --git a/internal/xcrd/schemas.go b/internal/xcrd/schemas.go
index ded722f71..422a1cb7d 100644
--- a/internal/xcrd/schemas.go
+++ b/internal/xcrd/schemas.go
@@ -22,7 +22,9 @@ import (
 	extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	"k8s.io/utils/ptr"
 
-	v1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+
+	v1 "github.com/crossplane/crossplane/apis/apiextensions/v1"
 )
 
 // Label keys.
@@ -73,21 +75,8 @@ func BaseProps() *extv1.JSONSchemaProps {
 
 // CompositeResourceSpecProps is a partial OpenAPIV3Schema for the spec fields
 // that Crossplane expects to be present for all defined composite resources.
-func CompositeResourceSpecProps(defaultPol *v1.UpdatePolicy) map[string]extv1.JSONSchemaProps {
-	return map[string]extv1.JSONSchemaProps{
-		"crossplane": {
-			Type:        "object",
-			Description: "Configures how Crossplane will reconcile this composite resource",
-			Properties:  LegacyCompositeResourceSpecProps(defaultPol),
-		},
-	}
-}
-
-// LegacyCompositeResourceSpecProps is a partial OpenAPIV3Schema for the spec
-// fields that Crossplane expects to be present for all defined composite
-// resources. It's used for Crossplane v1 style ClusterLegacy XRs.
-func LegacyCompositeResourceSpecProps(defaultPol *v1.UpdatePolicy) map[string]extv1.JSONSchemaProps {
-	return map[string]extv1.JSONSchemaProps{
+func CompositeResourceSpecProps(s v1.CompositeResourceScope, defaultPol *xpv1.UpdatePolicy) map[string]extv1.JSONSchemaProps {
+	props := map[string]extv1.JSONSchemaProps{
 		"compositionRef": {
 			Type:     "object",
 			Required: []string{"name"},
@@ -141,16 +130,6 @@ func LegacyCompositeResourceSpecProps(defaultPol *v1.UpdatePolicy) map[string]ex
 				return &extv1.JSON{Raw: []byte(fmt.Sprintf("\"%s\"", *defaultPol))}
 			}(),
 		},
-		"claimRef": {
-			Type:     "object",
-			Required: []string{"apiVersion", "kind", "namespace", "name"},
-			Properties: map[string]extv1.JSONSchemaProps{
-				"apiVersion": {Type: "string"},
-				"kind":       {Type: "string"},
-				"namespace":  {Type: "string"},
-				"name":       {Type: "string"},
-			},
-		},
 		"resourceRefs": {
 			Type: "array",
 			Items: &extv1.JSONSchemaPropsOrArray{
@@ -159,6 +138,7 @@ func LegacyCompositeResourceSpecProps(defaultPol *v1.UpdatePolicy) map[string]ex
 					Properties: map[string]extv1.JSONSchemaProps{
 						"apiVersion": {Type: "string"},
 						"name":       {Type: "string"},
+						"namespace":  {Type: "string"},
 						"kind":       {Type: "string"},
 					},
 					Required: []string{"apiVersion", "kind"},
@@ -176,12 +156,67 @@ func LegacyCompositeResourceSpecProps(defaultPol *v1.UpdatePolicy) map[string]ex
 			},
 		},
 	}
+
+	// Namespaced XRs don't get to reference secrets or composed resources in
+	// other namespaces.
+	if s == v1.CompositeResourceScopeNamespaced {
+		props["resourceRefs"] = extv1.JSONSchemaProps{
+			Type: "array",
+			Items: &extv1.JSONSchemaPropsOrArray{
+				Schema: &extv1.JSONSchemaProps{
+					Type: "object",
+					Properties: map[string]extv1.JSONSchemaProps{
+						"apiVersion": {Type: "string"},
+						"name":       {Type: "string"},
+						"kind":       {Type: "string"},
+					},
+					Required: []string{"apiVersion", "kind"},
+				},
+			},
+			// Controllers should replace the entire resourceRefs array.
+			XListType: ptr.To("atomic"),
+		}
+
+		props["writeConnectionSecretToRef"] = extv1.JSONSchemaProps{
+			Type:     "object",
+			Required: []string{"name"},
+			Properties: map[string]extv1.JSONSchemaProps{
+				"name": {Type: "string"},
+			},
+		}
+	}
+
+	// Legacy XRs have their Crossplane machinery fields directly under spec.
+	// They also support referencing a claim.
+	if s == v1.CompositeResourceScopeLegacyCluster {
+		props["claimRef"] = extv1.JSONSchemaProps{
+			Type:     "object",
+			Required: []string{"apiVersion", "kind", "namespace", "name"},
+			Properties: map[string]extv1.JSONSchemaProps{
+				"apiVersion": {Type: "string"},
+				"kind":       {Type: "string"},
+				"namespace":  {Type: "string"},
+				"name":       {Type: "string"},
+			},
+		}
+
+		return props
+	}
+
+	// Modern XRs nest their Crossplane machinery fields under spec.crossplane.
+	return map[string]extv1.JSONSchemaProps{
+		"crossplane": {
+			Type:        "object",
+			Description: "Configures how Crossplane will reconcile this composite resource",
+			Properties:  props,
+		},
+	}
 }
 
 // CompositeResourceClaimSpecProps is a partial OpenAPIV3Schema for the spec
 // fields that Crossplane expects to be present for all published infrastructure
 // resources.
-func CompositeResourceClaimSpecProps(defaultPol *v1.CompositeDeletePolicy) map[string]extv1.JSONSchemaProps {
+func CompositeResourceClaimSpecProps(defaultPol *xpv1.CompositeDeletePolicy) map[string]extv1.JSONSchemaProps {
 	return map[string]extv1.JSONSchemaProps{
 		"compositionRef": {
 			Type:     "object",
@@ -264,8 +299,8 @@ func CompositeResourceClaimSpecProps(defaultPol *v1.CompositeDeletePolicy) map[s
 
 // CompositeResourceStatusProps is a partial OpenAPIV3Schema for the status
 // fields that Crossplane expects to be present for all composite resources.
-func CompositeResourceStatusProps() map[string]extv1.JSONSchemaProps {
-	return map[string]extv1.JSONSchemaProps{
+func CompositeResourceStatusProps(s v1.CompositeResourceScope) map[string]extv1.JSONSchemaProps {
+	props := map[string]extv1.JSONSchemaProps{
 		"conditions": {
 			Description: "Conditions of the resource.",
 			Type:        "array",
@@ -287,7 +322,12 @@ func CompositeResourceStatusProps() map[string]extv1.JSONSchemaProps {
 				},
 			},
 		},
-		"crossplane": {
+	}
+
+	switch s {
+	case v1.CompositeResourceScopeNamespaced, v1.CompositeResourceScopeCluster:
+		// Modern XRs use status.crossplane, and don't support claims.
+		props["crossplane"] = extv1.JSONSchemaProps{
 			Type:        "object",
 			Description: "Indicates how Crossplane is reconciling this composite resource",
 			Properties: map[string]extv1.JSONSchemaProps{
@@ -298,43 +338,16 @@ func CompositeResourceStatusProps() map[string]extv1.JSONSchemaProps {
 					},
 				},
 			},
-		},
-	}
-}
-
-// LegacyCompositeResourceStatusProps is a partial OpenAPIV3Schema for the
-// status fields that Crossplane expects to be present for all composite
-// resources and claims. It's used for v1 style ClusterLegacy XRs and claims.
-func LegacyCompositeResourceStatusProps() map[string]extv1.JSONSchemaProps {
-	return map[string]extv1.JSONSchemaProps{
-		"conditions": {
-			Description: "Conditions of the resource.",
-			Type:        "array",
-			XListMapKeys: []string{
-				"type",
-			},
-			XListType: ptr.To("map"),
-			Items: &extv1.JSONSchemaPropsOrArray{
-				Schema: &extv1.JSONSchemaProps{
-					Type:     "object",
-					Required: []string{"lastTransitionTime", "reason", "status", "type"},
-					Properties: map[string]extv1.JSONSchemaProps{
-						"lastTransitionTime": {Type: "string", Format: "date-time"},
-						"message":            {Type: "string"},
-						"reason":             {Type: "string"},
-						"status":             {Type: "string"},
-						"type":               {Type: "string"},
-					},
-				},
-			},
-		},
-		"connectionDetails": {
+		}
+	case v1.CompositeResourceScopeLegacyCluster:
+		// Legacy XRs don't use status.crossplane, and support claims.
+		props["connectionDetails"] = extv1.JSONSchemaProps{
 			Type: "object",
 			Properties: map[string]extv1.JSONSchemaProps{
 				"lastPublishedTime": {Type: "string", Format: "date-time"},
 			},
-		},
-		"claimConditionTypes": {
+		}
+		props["claimConditionTypes"] = extv1.JSONSchemaProps{
 			Type:      "array",
 			XListType: ptr.To("set"),
 			Items: &extv1.JSONSchemaPropsOrArray{
@@ -342,14 +355,16 @@ func LegacyCompositeResourceStatusProps() map[string]extv1.JSONSchemaProps {
 					Type: "string",
 				},
 			},
-		},
+		}
 	}
+
+	return props
 }
 
 // CompositeResourcePrinterColumns returns the set of default printer columns
 // that should exist in all generated composite resource CRDs.
-func CompositeResourcePrinterColumns() []extv1.CustomResourceColumnDefinition {
-	return []extv1.CustomResourceColumnDefinition{
+func CompositeResourcePrinterColumns(s v1.CompositeResourceScope) []extv1.CustomResourceColumnDefinition {
+	cols := []extv1.CustomResourceColumnDefinition{
 		{
 			Name:     "SYNCED",
 			Type:     "string",
@@ -363,12 +378,12 @@ func CompositeResourcePrinterColumns() []extv1.CustomResourceColumnDefinition {
 		{
 			Name:     "COMPOSITION",
 			Type:     "string",
-			JSONPath: ".spec.compositionRef.name",
+			JSONPath: ".spec.crossplane.compositionRef.name",
 		},
 		{
 			Name:     "COMPOSITIONREVISION",
 			Type:     "string",
-			JSONPath: ".spec.compositionRevisionRef.name",
+			JSONPath: ".spec.crossplane.compositionRevisionRef.name",
 			Priority: 1,
 		},
 		{
@@ -377,6 +392,19 @@ func CompositeResourcePrinterColumns() []extv1.CustomResourceColumnDefinition {
 			JSONPath: ".metadata.creationTimestamp",
 		},
 	}
+
+	if s == v1.CompositeResourceScopeLegacyCluster {
+		for i := range cols {
+			if cols[i].Name == "COMPOSITION" {
+				cols[i].JSONPath = ".spec.compositionRef.name"
+			}
+			if cols[i].Name == "COMPOSITIONREVISION" {
+				cols[i].JSONPath = ".spec.compositionRevisionRef.name"
+			}
+		}
+	}
+
+	return cols
 }
 
 // CompositeResourceClaimPrinterColumns returns the set of default printer
diff --git a/internal/xresource/connection.go b/internal/xresource/connection.go
index e96901a2c..6e0353221 100644
--- a/internal/xresource/connection.go
+++ b/internal/xresource/connection.go
@@ -47,7 +47,8 @@ type ConnectionSecretOwner interface {
 }
 
 // LocalConnectionSecretFor creates a connection secret in the namespace of the
-// supplied LocalConnectionSecretOwner, assumed to be of the supplied kind.
+// supplied LocalConnectionSecretOwner, assumed to be of the supplied kind. It
+// should be used for resource types (like claims) that are always namespaced.
 func LocalConnectionSecretFor(o LocalConnectionSecretOwner, kind schema.GroupVersionKind) *corev1.Secret {
 	return &corev1.Secret{
 		ObjectMeta: metav1.ObjectMeta{
@@ -61,11 +62,19 @@ func LocalConnectionSecretFor(o LocalConnectionSecretOwner, kind schema.GroupVer
 }
 
 // ConnectionSecretFor creates a connection for the supplied
-// ConnectionSecretOwner, assumed to be of the supplied kind.
+// ConnectionSecretOwner, assumed to be of the supplied kind. It should be used
+// for resource types (like XRs) that may be namespaced or cluster scoped.
 func ConnectionSecretFor(o ConnectionSecretOwner, kind schema.GroupVersionKind) *corev1.Secret {
+	// Namespaced resources always write connection secrets to their own
+	// namespace. Cluster scoped resources should have a namespace in the ref.
+	ns := o.GetNamespace()
+	if ns == "" {
+		ns = o.GetWriteConnectionSecretToReference().Namespace
+	}
+
 	return &corev1.Secret{
 		ObjectMeta: metav1.ObjectMeta{
-			Namespace:       o.GetWriteConnectionSecretToReference().Namespace,
+			Namespace:       ns,
 			Name:            o.GetWriteConnectionSecretToReference().Name,
 			OwnerReferences: []metav1.OwnerReference{meta.AsController(meta.TypedReferenceTo(o, kind))},
 		},
diff --git a/internal/xresource/interfaces.go b/internal/xresource/interfaces.go
index 09a004e6f..6131c9ae4 100644
--- a/internal/xresource/interfaces.go
+++ b/internal/xresource/interfaces.go
@@ -108,12 +108,17 @@ type Composite interface { //nolint:interfacebloat // This interface has to be b
 	CompositionRevisionReferencer
 	CompositionRevisionSelector
 	ComposedResourcesReferencer
-	ClaimReferencer
 
 	resource.Conditioned
 	ConnectionDetailsPublishedTimer
 }
 
+// A LegacyComposite resource is a composite resource that can be claimed.
+type LegacyComposite interface {
+	Composite
+	ClaimReferencer
+}
+
 // Composed resources can be a composed into a Composite resource.
 type Composed interface {
 	resource.Object
diff --git a/internal/xresource/unstructured/composite/composite.go b/internal/xresource/unstructured/composite/composite.go
index 06bcbf25c..e47a95d34 100644
--- a/internal/xresource/unstructured/composite/composite.go
+++ b/internal/xresource/unstructured/composite/composite.go
@@ -22,6 +22,7 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/utils/ptr"
 
 	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
 	"github.com/crossplane/crossplane-runtime/pkg/errors"
@@ -30,28 +31,49 @@ import (
 	"github.com/crossplane/crossplane/internal/xresource/unstructured/reference"
 )
 
+// Schema specifies the schema version of a composite resource's Crossplane
+// machinery fields.
+type Schema int
+
+const (
+	// SchemaModern indicates a modern Namespaced or Cluster scope composite
+	// resource. Modern composite resources nest all Crossplane machinery fields
+	// under spec.crossplane and status.crossplane, and can't be claimed.
+	SchemaModern Schema = iota
+
+	// SchemaLegacy indicates a LegacyCluster scope composite resource. Legacy
+	// composite resources don't nest Crossplane machinery fields - they're set
+	// directly under spec and status. Legacy composite resources can be claimed.
+	SchemaLegacy
+)
+
 // An Option modifies an unstructured composite resource.
 type Option func(*Unstructured)
 
-// WithGroupVersionKind sets the GroupVersionKind of the unstructured composite
-// resource.
+// WithGroupVersionKind sets the GroupVersionKind of the composite resource.
 func WithGroupVersionKind(gvk schema.GroupVersionKind) Option {
 	return func(c *Unstructured) {
 		c.SetGroupVersionKind(gvk)
 	}
 }
 
-// WithConditions returns an Option that sets the supplied conditions on an
-// unstructured composite resource.
+// WithConditions sets the supplied conditions on the composite resource.
 func WithConditions(c ...xpv1.Condition) Option {
 	return func(cr *Unstructured) {
 		cr.SetConditions(c...)
 	}
 }
 
-// New returns a new unstructured composed resource.
+// WithSchema sets the schema of the composite resource.
+func WithSchema(s Schema) Option {
+	return func(c *Unstructured) {
+		c.Schema = s
+	}
+}
+
+// New returns a new unstructured composite resource.
 func New(opts ...Option) *Unstructured {
-	c := &Unstructured{unstructured.Unstructured{Object: make(map[string]any)}}
+	c := &Unstructured{Unstructured: unstructured.Unstructured{Object: make(map[string]any)}}
 	for _, f := range opts {
 		f(c)
 	}
@@ -61,9 +83,11 @@ func New(opts ...Option) *Unstructured {
 // +k8s:deepcopy-gen=true
 // +kubebuilder:object:root=true
 
-// An Unstructured composed resource.
+// An Unstructured composite resource.
 type Unstructured struct {
 	unstructured.Unstructured
+
+	Schema Schema
 }
 
 // GetUnstructured returns the underlying *unstructured.Unstructured.
@@ -71,52 +95,87 @@ func (c *Unstructured) GetUnstructured() *unstructured.Unstructured {
 	return &c.Unstructured
 }
 
-// GetCompositionSelector of this Composite resource.
+// GetCompositionSelector of this composite resource.
 func (c *Unstructured) GetCompositionSelector() *metav1.LabelSelector {
+	path := "spec.crossplane.compositionSelector"
+	if c.Schema == SchemaLegacy {
+		path = "spec.compositionSelector"
+	}
+
 	out := &metav1.LabelSelector{}
-	if err := fieldpath.Pave(c.Object).GetValueInto("spec.compositionSelector", out); err != nil {
+	if err := fieldpath.Pave(c.Object).GetValueInto(path, out); err != nil {
 		return nil
 	}
 	return out
 }
 
-// SetCompositionSelector of this Composite resource.
+// SetCompositionSelector of this composite resource.
 func (c *Unstructured) SetCompositionSelector(sel *metav1.LabelSelector) {
-	_ = fieldpath.Pave(c.Object).SetValue("spec.compositionSelector", sel)
+	path := "spec.crossplane.compositionSelector"
+	if c.Schema == SchemaLegacy {
+		path = "spec.compositionSelector"
+	}
+
+	_ = fieldpath.Pave(c.Object).SetValue(path, sel)
 }
 
-// GetCompositionReference of this Composite resource.
+// GetCompositionReference of this composite resource.
 func (c *Unstructured) GetCompositionReference() *corev1.ObjectReference {
+	path := "spec.crossplane.compositionRef"
+	if c.Schema == SchemaLegacy {
+		path = "spec.compositionRef"
+	}
+
 	out := &corev1.ObjectReference{}
-	if err := fieldpath.Pave(c.Object).GetValueInto("spec.compositionRef", out); err != nil {
+	if err := fieldpath.Pave(c.Object).GetValueInto(path, out); err != nil {
 		return nil
 	}
 	return out
 }
 
-// SetCompositionReference of this Composite resource.
+// SetCompositionReference of this composite resource.
 func (c *Unstructured) SetCompositionReference(ref *corev1.ObjectReference) {
-	_ = fieldpath.Pave(c.Object).SetValue("spec.compositionRef", ref)
+	path := "spec.crossplane.compositionRef"
+	if c.Schema == SchemaLegacy {
+		path = "spec.compositionRef"
+	}
+
+	_ = fieldpath.Pave(c.Object).SetValue(path, ref)
 }
 
-// GetCompositionRevisionReference of this Composite resource.
+// GetCompositionRevisionReference of this composite resource.
 func (c *Unstructured) GetCompositionRevisionReference() *corev1.LocalObjectReference {
+	path := "spec.crossplane.compositionRevisionRef"
+	if c.Schema == SchemaLegacy {
+		path = "spec.compositionRevisionRef"
+	}
+
 	out := &corev1.LocalObjectReference{}
-	if err := fieldpath.Pave(c.Object).GetValueInto("spec.compositionRevisionRef", out); err != nil {
+	if err := fieldpath.Pave(c.Object).GetValueInto(path, out); err != nil {
 		return nil
 	}
 	return out
 }
 
-// SetCompositionRevisionReference of this Composite resource.
+// SetCompositionRevisionReference of this composite resource.
 func (c *Unstructured) SetCompositionRevisionReference(ref *corev1.LocalObjectReference) {
-	_ = fieldpath.Pave(c.Object).SetValue("spec.compositionRevisionRef", ref)
+	path := "spec.crossplane.compositionRevisionRef"
+	if c.Schema == SchemaLegacy {
+		path = "spec.compositionRevisionRef"
+	}
+
+	_ = fieldpath.Pave(c.Object).SetValue(path, ref)
 }
 
 // GetCompositionRevisionSelector of this resource claim.
 func (c *Unstructured) GetCompositionRevisionSelector() *metav1.LabelSelector {
+	path := "spec.crossplane.compositionRevisionSelector"
+	if c.Schema == SchemaLegacy {
+		path = "spec.compositionRevisionSelector"
+	}
+
 	out := &metav1.LabelSelector{}
-	if err := fieldpath.Pave(c.Object).GetValueInto("spec.compositionRevisionSelector", out); err != nil {
+	if err := fieldpath.Pave(c.Object).GetValueInto(path, out); err != nil {
 		return nil
 	}
 	return out
@@ -124,17 +183,32 @@ func (c *Unstructured) GetCompositionRevisionSelector() *metav1.LabelSelector {
 
 // SetCompositionRevisionSelector of this resource claim.
 func (c *Unstructured) SetCompositionRevisionSelector(sel *metav1.LabelSelector) {
-	_ = fieldpath.Pave(c.Object).SetValue("spec.compositionRevisionSelector", sel)
+	path := "spec.crossplane.compositionRevisionSelector"
+	if c.Schema == SchemaLegacy {
+		path = "spec.compositionRevisionSelector"
+	}
+
+	_ = fieldpath.Pave(c.Object).SetValue(path, sel)
 }
 
-// SetCompositionUpdatePolicy of this Composite resource.
+// SetCompositionUpdatePolicy of this composite resource.
 func (c *Unstructured) SetCompositionUpdatePolicy(p *xpv1.UpdatePolicy) {
-	_ = fieldpath.Pave(c.Object).SetValue("spec.compositionUpdatePolicy", p)
+	path := "spec.crossplane.compositionUpdatePolicy"
+	if c.Schema == SchemaLegacy {
+		path = "spec.compositionUpdatePolicy"
+	}
+
+	_ = fieldpath.Pave(c.Object).SetValue(path, p)
 }
 
-// GetCompositionUpdatePolicy of this Composite resource.
+// GetCompositionUpdatePolicy of this composite resource.
 func (c *Unstructured) GetCompositionUpdatePolicy() *xpv1.UpdatePolicy {
-	p, err := fieldpath.Pave(c.Object).GetString("spec.compositionUpdatePolicy")
+	path := "spec.crossplane.compositionUpdatePolicy"
+	if c.Schema == SchemaLegacy {
+		path = "spec.compositionUpdatePolicy"
+	}
+
+	p, err := fieldpath.Pave(c.Object).GetString(path)
 	if err != nil {
 		return nil
 	}
@@ -142,8 +216,13 @@ func (c *Unstructured) GetCompositionUpdatePolicy() *xpv1.UpdatePolicy {
 	return &out
 }
 
-// GetClaimReference of this Composite resource.
+// GetClaimReference of this composite resource.
 func (c *Unstructured) GetClaimReference() *reference.Claim {
+	// Only legacy XRs support claims.
+	if c.Schema != SchemaLegacy {
+		return nil
+	}
+
 	out := &reference.Claim{}
 	if err := fieldpath.Pave(c.Object).GetValueInto("spec.claimRef", out); err != nil {
 		return nil
@@ -151,20 +230,35 @@ func (c *Unstructured) GetClaimReference() *reference.Claim {
 	return out
 }
 
-// SetClaimReference of this Composite resource.
+// SetClaimReference of this composite resource.
 func (c *Unstructured) SetClaimReference(ref *reference.Claim) {
+	// Only legacy XRs support claims.
+	if c.Schema != SchemaLegacy {
+		return
+	}
+
 	_ = fieldpath.Pave(c.Object).SetValue("spec.claimRef", ref)
 }
 
-// GetResourceReferences of this Composite resource.
+// GetResourceReferences of this composite resource.
 func (c *Unstructured) GetResourceReferences() []corev1.ObjectReference {
+	path := "spec.crossplane.resourceRefs"
+	if c.Schema == SchemaLegacy {
+		path = "spec.resourceRefs"
+	}
+
 	out := &[]corev1.ObjectReference{}
-	_ = fieldpath.Pave(c.Object).GetValueInto("spec.resourceRefs", out)
+	_ = fieldpath.Pave(c.Object).GetValueInto(path, out)
 	return *out
 }
 
-// SetResourceReferences of this Composite resource.
+// SetResourceReferences of this composite resource.
 func (c *Unstructured) SetResourceReferences(refs []corev1.ObjectReference) {
+	path := "spec.crossplane.resourceRefs"
+	if c.Schema == SchemaLegacy {
+		path = "spec.resourceRefs"
+	}
+
 	empty := corev1.ObjectReference{}
 	filtered := make([]corev1.ObjectReference, 0, len(refs))
 	for _, ref := range refs {
@@ -175,33 +269,53 @@ func (c *Unstructured) SetResourceReferences(refs []corev1.ObjectReference) {
 		}
 		filtered = append(filtered, ref)
 	}
-	_ = fieldpath.Pave(c.Object).SetValue("spec.resourceRefs", filtered)
+	_ = fieldpath.Pave(c.Object).SetValue(path, filtered)
 }
 
 // GetReference returns reference to this composite.
 func (c *Unstructured) GetReference() *reference.Composite {
-	return &reference.Composite{
+	ref := &reference.Composite{
 		APIVersion: c.GetAPIVersion(),
 		Kind:       c.GetKind(),
 		Name:       c.GetName(),
 	}
+
+	if c.GetNamespace() != "" {
+		ref.Namespace = ptr.To(c.GetNamespace())
+	}
+
+	return ref
 }
 
-// GetWriteConnectionSecretToReference of this Composite resource.
+// TODO(negz): Ideally we'd use LocalSecretReference for namespaced XRs. As is
+// we'll return a SecretReference with an empty namespace if the XR doesn't
+// actually have a spec.crossplane.writeConnectionSecretToRef.namespace field.
+
+// GetWriteConnectionSecretToReference of this composite resource.
 func (c *Unstructured) GetWriteConnectionSecretToReference() *xpv1.SecretReference {
+	path := "spec.crossplane.writeConnectionSecretToRef"
+	if c.Schema == SchemaLegacy {
+		path = "spec.writeConnectionSecretToRef"
+	}
+
 	out := &xpv1.SecretReference{}
-	if err := fieldpath.Pave(c.Object).GetValueInto("spec.writeConnectionSecretToRef", out); err != nil {
+	if err := fieldpath.Pave(c.Object).GetValueInto(path, out); err != nil {
 		return nil
 	}
 	return out
 }
 
-// SetWriteConnectionSecretToReference of this Composite resource.
+// SetWriteConnectionSecretToReference of this composite resource.
 func (c *Unstructured) SetWriteConnectionSecretToReference(ref *xpv1.SecretReference) {
-	_ = fieldpath.Pave(c.Object).SetValue("spec.writeConnectionSecretToRef", ref)
+	path := "spec.crossplane.writeConnectionSecretToRef"
+	if c.Schema == SchemaLegacy {
+		path = "spec.writeConnectionSecretToRef"
+	}
+
+	_ = fieldpath.Pave(c.Object).SetValue(path, ref)
 }
 
-// GetCondition of this Composite resource.
+// GetCondition of this composite resource.
 func (c *Unstructured) GetCondition(ct xpv1.ConditionType) xpv1.Condition {
 	conditioned := xpv1.ConditionedStatus{}
 	// The path is directly `status` because conditions are inline.
@@ -211,7 +325,7 @@ func (c *Unstructured) GetCondition(ct xpv1.ConditionType) xpv1.Condition {
 	return conditioned.GetCondition(ct)
 }
 
-// SetConditions of this Composite resource.
+// SetConditions of this composite resource.
 func (c *Unstructured) SetConditions(conditions ...xpv1.Condition) {
 	conditioned := xpv1.ConditionedStatus{}
 	// The path is directly `status` because conditions are inline.
@@ -220,7 +334,7 @@ func (c *Unstructured) SetConditions(conditions ...xpv1.Condition) {
 	_ = fieldpath.Pave(c.Object).SetValue("status.conditions", conditioned.Conditions)
 }
 
-// GetConditions of this Composite resource.
+// GetConditions of this composite resource.
 func (c *Unstructured) GetConditions() []xpv1.Condition {
 	conditioned := xpv1.ConditionedStatus{}
 	// The path is directly `status` because conditions are inline.
@@ -228,18 +342,28 @@ func (c *Unstructured) GetConditions() []xpv1.Condition {
 	return conditioned.Conditions
 }
 
-// GetConnectionDetailsLastPublishedTime of this Composite resource.
+// GetConnectionDetailsLastPublishedTime of this composite resource.
 func (c *Unstructured) GetConnectionDetailsLastPublishedTime() *metav1.Time {
+	path := "status.crossplane.connectionDetails.lastPublishedTime"
+	if c.Schema == SchemaLegacy {
+		path = "status.connectionDetails.lastPublishedTime"
+	}
+
 	out := &metav1.Time{}
-	if err := fieldpath.Pave(c.Object).GetValueInto("status.connectionDetails.lastPublishedTime", out); err != nil {
+	if err := fieldpath.Pave(c.Object).GetValueInto(path, out); err != nil {
 		return nil
 	}
 	return out
 }
 
-// SetConnectionDetailsLastPublishedTime of this Composite resource.
+// SetConnectionDetailsLastPublishedTime of this composite resource.
 func (c *Unstructured) SetConnectionDetailsLastPublishedTime(t *metav1.Time) {
-	_ = fieldpath.Pave(c.Object).SetValue("status.connectionDetails.lastPublishedTime", t)
+	path := "status.crossplane.connectionDetails.lastPublishedTime"
+	if c.Schema == SchemaLegacy {
+		path = "status.connectionDetails.lastPublishedTime"
+	}
+
+	_ = fieldpath.Pave(c.Object).SetValue(path, t)
 }
 
 // SetObservedGeneration of this composite resource claim.
@@ -257,9 +381,13 @@ func (c *Unstructured) GetObservedGeneration() int64 {
 	return status.GetObservedGeneration()
 }
 
-// SetClaimConditionTypes of this Composite resource. You cannot set system
+// SetClaimConditionTypes of this composite resource. You cannot set system
 // condition types such as Ready, Synced or Healthy as claim conditions.
 func (c *Unstructured) SetClaimConditionTypes(in ...xpv1.ConditionType) error {
+	// Only legacy XRs support claims.
+	if c.Schema != SchemaLegacy {
+		return nil
+	}
 	ts := c.GetClaimConditionTypes()
 	m := make(map[xpv1.ConditionType]bool, len(ts))
 	for _, t := range ts {
@@ -280,8 +408,12 @@ func (c *Unstructured) SetClaimConditionTypes(in ...xpv1.ConditionType) error {
 	return nil
 }
 
-// GetClaimConditionTypes of this Composite resource.
+// GetClaimConditionTypes of this composite resource.
 func (c *Unstructured) GetClaimConditionTypes() []xpv1.ConditionType {
+	// Only legacy XRs support claims.
+	if c.Schema != SchemaLegacy {
+		return nil
+	}
 	cs := []xpv1.ConditionType{}
 	_ = fieldpath.Pave(c.Object).GetValueInto("status.claimConditionTypes", &cs)
 	return cs
diff --git a/internal/xresource/unstructured/reference/reference.go b/internal/xresource/unstructured/reference/reference.go
index afe1fa29a..8d62102af 100644
--- a/internal/xresource/unstructured/reference/reference.go
+++ b/internal/xresource/unstructured/reference/reference.go
@@ -46,6 +46,9 @@ type Composite struct {
 
 	// Name of the referenced composite.
 	Name string `json:"name"`
+
+	// Namespace of the referenced composite.
+	Namespace *string `json:"namespace,omitempty"`
 }
 
 // GroupVersionKind returns the GroupVersionKind of the claim reference.


Test patch:
diff --git a/cmd/crank/beta/validate/validate_test.go b/cmd/crank/beta/validate/validate_test.go
index d8235fa26..e08fbbcdd 100644
--- a/cmd/crank/beta/validate/validate_test.go
+++ b/cmd/crank/beta/validate/validate_test.go
@@ -440,6 +440,7 @@ func TestConvertToCRDs(t *testing.T) {
 																	Properties: map[string]extv1.JSONSchemaProps{
 																		"apiVersion": {Type: "string"},
 																		"name":       {Type: "string"},
+																		"namespace":  {Type: "string"},
 																		"kind":       {Type: "string"},
 																	},
 																	Required: []string{"apiVersion", "kind"},
@@ -746,6 +747,7 @@ func TestConvertToCRDs(t *testing.T) {
 																	Properties: map[string]extv1.JSONSchemaProps{
 																		"apiVersion": {Type: "string"},
 																		"name":       {Type: "string"},
+																		"namespace":  {Type: "string"},
 																		"kind":       {Type: "string"},
 																	},
 																	Required: []string{"apiVersion", "kind"},
diff --git a/internal/controller/apiextensions/claim/syncer_csa_test.go b/internal/controller/apiextensions/claim/syncer_csa_test.go
index bf75ecdc2..b35fa4f18 100644
--- a/internal/controller/apiextensions/claim/syncer_csa_test.go
+++ b/internal/controller/apiextensions/claim/syncer_csa_test.go
@@ -414,7 +414,7 @@ func TestClientSideSync(t *testing.T) {
 type CompositeModifier func(xr *composite.Unstructured)
 
 func NewComposite(m ...CompositeModifier) *composite.Unstructured {
-	xr := composite.New(composite.WithGroupVersionKind(schema.GroupVersionKind{}))
+	xr := composite.New(composite.WithGroupVersionKind(schema.GroupVersionKind{}), composite.WithSchema(composite.SchemaLegacy))
 	for _, fn := range m {
 		fn(xr)
 	}
diff --git a/internal/controller/apiextensions/composite/composition_functions_test.go b/internal/controller/apiextensions/composite/composition_functions_test.go
index 90bcd91e3..0481b06e3 100644
--- a/internal/controller/apiextensions/composite/composition_functions_test.go
+++ b/internal/controller/apiextensions/composite/composition_functions_test.go
@@ -39,7 +39,6 @@ import (
 	"github.com/crossplane/crossplane-runtime/pkg/event"
 	"github.com/crossplane/crossplane-runtime/pkg/meta"
 	"github.com/crossplane/crossplane-runtime/pkg/reconciler/managed"
-	"github.com/crossplane/crossplane-runtime/pkg/resource"
 	"github.com/crossplane/crossplane-runtime/pkg/test"
 
 	fnv1 "github.com/crossplane/crossplane/apis/apiextensions/fn/proto/v1"
@@ -1524,12 +1523,12 @@ func TestGarbageCollectComposedResources(t *testing.T) {
 
 func TestUpdateResourceRefs(t *testing.T) {
 	type args struct {
-		xr  resource.ComposedResourcesReferencer
+		xr  xresource.Composite
 		drs ComposedResourceStates
 	}
 
 	type want struct {
-		xr resource.ComposedResourcesReferencer
+		xr xresource.Composite
 	}
 
 	cases := map[string]struct {
@@ -1537,28 +1536,31 @@ func TestUpdateResourceRefs(t *testing.T) {
 		args   args
 		want   want
 	}{
-		"Success": {
-			reason: "We should return a consistently ordered set of references.",
+		"ClusterScopedXR": {
+			reason: "We should return a consistently ordered set of references (including namespaces where applicable) suitable for a cluster scoped XR.",
 			args: args{
 				xr: &xfake.Composite{},
 				drs: ComposedResourceStates{
 					"never-created-c": ComposedResourceState{
 						Resource: &xfake.Composed{
 							ObjectMeta: metav1.ObjectMeta{
-								Name: "never-created-c-42",
+								Namespace: "c",
+								Name:      "never-created-c-42",
 							},
 						},
 					},
 					"never-created-b": ComposedResourceState{
 						Resource: &xfake.Composed{
 							ObjectMeta: metav1.ObjectMeta{
-								Name: "never-created-b-42",
+								Namespace: "b",
+								Name:      "never-created-b-42",
 							},
 						},
 					},
 					"never-created-a": ComposedResourceState{
 						Resource: &xfake.Composed{
 							ObjectMeta: metav1.ObjectMeta{
+								// No namespace - cluster scoped.
 								Name: "never-created-a-42",
 							},
 						},
@@ -1569,6 +1571,61 @@ func TestUpdateResourceRefs(t *testing.T) {
 				xr: &xfake.Composite{
 					ComposedResourcesReferencer: xfake.ComposedResourcesReferencer{
 						Refs: []corev1.ObjectReference{
+							{Name: "never-created-a-42"},
+							{Namespace: "b", Name: "never-created-b-42"},
+							{Namespace: "c", Name: "never-created-c-42"},
+						},
+					},
+				},
+			},
+		},
+		"NamespacedXR": {
+			reason: "We should return a consistently ordered set of references (without namespaces) suitable for a namespaced XR.",
+			args: args{
+				xr: &xfake.Composite{
+					ObjectMeta: metav1.ObjectMeta{
+						// We take the presence of a namespace to mean the XR
+						// is namespaced.
+						Namespace: "default",
+					},
+				},
+				drs: ComposedResourceStates{
+					"never-created-c": ComposedResourceState{
+						Resource: &xfake.Composed{
+							ObjectMeta: metav1.ObjectMeta{
+								Namespace: "default",
+								Name:      "never-created-c-42",
+							},
+						},
+					},
+					"never-created-b": ComposedResourceState{
+						Resource: &xfake.Composed{
+							ObjectMeta: metav1.ObjectMeta{
+								Namespace: "default",
+								Name:      "never-created-b-42",
+							},
+						},
+					},
+					"never-created-a": ComposedResourceState{
+						Resource: &xfake.Composed{
+							ObjectMeta: metav1.ObjectMeta{
+								Namespace: "default",
+								Name:      "never-created-a-42",
+							},
+						},
+					},
+				},
+			},
+			want: want{
+				xr: &xfake.Composite{
+					ObjectMeta: metav1.ObjectMeta{
+						Namespace: "default",
+					},
+					ComposedResourcesReferencer: xfake.ComposedResourcesReferencer{
+						Refs: []corev1.ObjectReference{
+							// The refs shouldn't have namespaces. They're
+							// assumed to be to resource in the same namespace
+							// as the XR.
 							{Name: "never-created-a-42"},
 							{Name: "never-created-b-42"},
 							{Name: "never-created-c-42"},
diff --git a/internal/controller/apiextensions/composite/composition_render_test.go b/internal/controller/apiextensions/composite/composition_render_test.go
index 4e1db488b..96546ed93 100644
--- a/internal/controller/apiextensions/composite/composition_render_test.go
+++ b/internal/controller/apiextensions/composite/composition_render_test.go
@@ -280,8 +280,9 @@ func TestRenderComposedResourceMetadata(t *testing.T) {
 			args: args{
 				xr: &xfake.Composite{
 					ObjectMeta: metav1.ObjectMeta{
-						Name: "cool-xr",
-						UID:  "somewhat-random",
+						Namespace: "ns",
+						Name:      "cool-xr",
+						UID:       "somewhat-random",
 						Labels: map[string]string{
 							xcrd.LabelKeyNamePrefixForComposed: "prefix",
 							xcrd.LabelKeyClaimName:             "name",
@@ -294,6 +295,7 @@ func TestRenderComposedResourceMetadata(t *testing.T) {
 			want: want{
 				cd: &fake.Composed{
 					ObjectMeta: metav1.ObjectMeta{
+						Namespace:    "ns",
 						GenerateName: "prefix-",
 						OwnerReferences: []metav1.OwnerReference{{
 							Controller:         ptr.To(true),
diff --git a/internal/controller/apiextensions/composite/connection_test.go b/internal/controller/apiextensions/composite/connection_test.go
new file mode 100644
index 000000000..e1609ee92
--- /dev/null
+++ b/internal/controller/apiextensions/composite/connection_test.go
@@ -0,0 +1,168 @@
+/*
+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 composite
+
+import (
+	"context"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	corev1 "k8s.io/api/core/v1"
+	kerrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+	"github.com/crossplane/crossplane-runtime/pkg/errors"
+	"github.com/crossplane/crossplane-runtime/pkg/reconciler/managed"
+	"github.com/crossplane/crossplane-runtime/pkg/resource"
+	"github.com/crossplane/crossplane-runtime/pkg/resource/fake"
+	"github.com/crossplane/crossplane-runtime/pkg/test"
+)
+
+var _ ConnectionDetailsFetcher = &SecretConnectionDetailsFetcher{}
+
+func TestSecretConnectionDetailsFetcher(t *testing.T) {
+	errBoom := errors.New("boom")
+	sref := &xpv1.SecretReference{Name: "foo", Namespace: "bar"}
+	s := &corev1.Secret{
+		Data: map[string][]byte{
+			"foo": []byte("a"),
+			"bar": []byte("b"),
+		},
+	}
+
+	type params struct {
+		kube client.Client
+	}
+	type args struct {
+		ctx context.Context
+		o   resource.ConnectionSecretOwner
+	}
+	type want struct {
+		conn managed.ConnectionDetails
+		err  error
+	}
+	cases := map[string]struct {
+		reason string
+		params params
+		args   args
+		want   want
+	}{
+		"DoesNotPublish": {
+			reason: "Should not fail if composed resource doesn't publish a connection secret",
+			args: args{
+				o: &fake.Composed{},
+			},
+		},
+		"SecretNotPublishedYet": {
+			reason: "Should not fail if composed resource has yet to publish the secret",
+			params: params{
+				kube: &test.MockClient{MockGet: test.NewMockGetFn(kerrors.NewNotFound(schema.GroupResource{}, ""))},
+			},
+			args: args{
+				o: &fake.Composed{
+					ConnectionSecretWriterTo: fake.ConnectionSecretWriterTo{Ref: sref},
+				},
+			},
+			want: want{
+				conn: nil,
+			},
+		},
+		"SecretGetFailed": {
+			reason: "Should fail if secret retrieval results in some error other than NotFound",
+			params: params{
+				kube: &test.MockClient{MockGet: test.NewMockGetFn(errBoom)},
+			},
+			args: args{
+				o: &fake.Composed{
+					ConnectionSecretWriterTo: fake.ConnectionSecretWriterTo{Ref: sref},
+				},
+			},
+			want: want{
+				err: errors.Wrap(errBoom, errGetSecret),
+			},
+		},
+		"ClusterScopedOwner": {
+			reason: "Should fetch all connection details from a connection secret owned by a cluster scoped resource - i.e. one with an empty namespace, and a populated secret ref namespace.",
+			params: params{
+				kube: &test.MockClient{MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error {
+					if sobj, ok := obj.(*corev1.Secret); ok {
+						if key.Name == sref.Name && key.Namespace == sref.Namespace {
+							s.DeepCopyInto(sobj)
+							return nil
+						}
+					}
+					t.Errorf("wrong secret is queried")
+					return errBoom
+				}},
+			},
+			args: args{
+				o: &fake.Composed{
+					ConnectionSecretWriterTo: fake.ConnectionSecretWriterTo{Ref: sref},
+				},
+			},
+			want: want{
+				conn: managed.ConnectionDetails{
+					"foo": s.Data["foo"],
+					"bar": s.Data["bar"],
+				},
+			},
+		},
+		"NamespacedOwner": {
+			reason: "Should fetch all connection details from a connection secret owned by a namespaced resource - i.e. one with a populated namespace, and an empty secret ref namespace.",
+			params: params{
+				kube: &test.MockClient{MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error {
+					if sobj, ok := obj.(*corev1.Secret); ok {
+						if key.Name == sref.Name && key.Namespace == "baz" {
+							s.DeepCopyInto(sobj)
+							return nil
+						}
+					}
+					t.Errorf("wrong secret is queried")
+					return errBoom
+				}},
+			},
+			args: args{
+				o: &fake.Composed{
+					ObjectMeta:               metav1.ObjectMeta{Namespace: "baz"},
+					ConnectionSecretWriterTo: fake.ConnectionSecretWriterTo{Ref: sref},
+				},
+			},
+			want: want{
+				conn: managed.ConnectionDetails{
+					"foo": s.Data["foo"],
+					"bar": s.Data["bar"],
+				},
+			},
+		},
+	}
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			c := &SecretConnectionDetailsFetcher{client: tc.params.kube}
+			conn, err := c.FetchConnection(tc.args.ctx, tc.args.o)
+			if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
+				t.Errorf("\n%s\nFetchConnection(...): -want, +got:\n%s", tc.reason, diff)
+			}
+			if diff := cmp.Diff(tc.want.conn, conn, cmpopts.EquateEmpty()); diff != "" {
+				t.Errorf("\n%s\nFetchFetchConnection(...): -want, +got:\n%s", tc.reason, diff)
+			}
+		})
+	}
+}
diff --git a/internal/controller/apiextensions/composite/reconciler_test.go b/internal/controller/apiextensions/composite/reconciler_test.go
index 5f591de9b..3b46b5487 100644
--- a/internal/controller/apiextensions/composite/reconciler_test.go
+++ b/internal/controller/apiextensions/composite/reconciler_test.go
@@ -97,10 +97,10 @@ func TestReconcile(t *testing.T) {
 			reason: "We should return any error encountered while unpublishing connection details.",
 			args: args{
 				c: &test.MockClient{
-					MockGet: WithComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockGet: WithComposite(t, NewComposite(func(cr *composite.Unstructured) {
 						cr.SetDeletionTimestamp(&now)
 					})),
-					MockStatusUpdate: WantComposite(t, NewComposite(func(want xresource.Composite) {
+					MockStatusUpdate: WantComposite(t, NewComposite(func(want *composite.Unstructured) {
 						want.SetDeletionTimestamp(&now)
 						want.SetConditions(xpv1.Deleting(), xpv1.ReconcileError(errors.Wrap(errBoom, errUnpublish)))
 					})),
@@ -122,10 +122,10 @@ func TestReconcile(t *testing.T) {
 			reason: "We should return any error encountered while removing finalizer.",
 			args: args{
 				c: &test.MockClient{
-					MockGet: WithComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockGet: WithComposite(t, NewComposite(func(cr *composite.Unstructured) {
 						cr.SetDeletionTimestamp(&now)
 					})),
-					MockStatusUpdate: WantComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockStatusUpdate: WantComposite(t, NewComposite(func(cr *composite.Unstructured) {
 						cr.SetDeletionTimestamp(&now)
 						cr.SetConditions(xpv1.Deleting(), xpv1.ReconcileError(errors.Wrap(errBoom, errRemoveFinalizer)))
 					})),
@@ -151,10 +151,10 @@ func TestReconcile(t *testing.T) {
 			reason: "We should return no error when deleted successfully.",
 			args: args{
 				c: &test.MockClient{
-					MockGet: WithComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockGet: WithComposite(t, NewComposite(func(cr *composite.Unstructured) {
 						cr.SetDeletionTimestamp(&now)
 					})),
-					MockStatusUpdate: WantComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockStatusUpdate: WantComposite(t, NewComposite(func(cr *composite.Unstructured) {
 						cr.SetDeletionTimestamp(&now)
 						cr.SetConditions(xpv1.Deleting(), xpv1.ReconcileSuccess())
 					})),
@@ -181,7 +181,7 @@ func TestReconcile(t *testing.T) {
 			args: args{
 				c: &test.MockClient{
 					MockGet: test.NewMockGetFn(nil),
-					MockStatusUpdate: WantComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockStatusUpdate: WantComposite(t, NewComposite(func(cr *composite.Unstructured) {
 						cr.SetConditions(xpv1.ReconcileError(errors.Wrap(errBoom, errAddFinalizer)))
 					})),
 				},
@@ -202,7 +202,7 @@ func TestReconcile(t *testing.T) {
 			args: args{
 				c: &test.MockClient{
 					MockGet: test.NewMockGetFn(nil),
-					MockStatusUpdate: WantComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockStatusUpdate: WantComposite(t, NewComposite(func(cr *composite.Unstructured) {
 						cr.SetConditions(xpv1.ReconcileError(errors.Wrap(errBoom, errSelectComp)))
 					})),
 				},
@@ -222,7 +222,7 @@ func TestReconcile(t *testing.T) {
 			args: args{
 				c: &test.MockClient{
 					MockGet: test.NewMockGetFn(nil),
-					MockStatusUpdate: WantComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockStatusUpdate: WantComposite(t, NewComposite(func(cr *composite.Unstructured) {
 						cr.SetCompositionReference(&corev1.ObjectReference{})
 						cr.SetConditions(xpv1.ReconcileError(errors.Wrap(errBoom, errFetchComp)))
 					})),
@@ -247,7 +247,7 @@ func TestReconcile(t *testing.T) {
 			args: args{
 				c: &test.MockClient{
 					MockGet: test.NewMockGetFn(nil),
-					MockStatusUpdate: WantComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockStatusUpdate: WantComposite(t, NewComposite(func(cr *composite.Unstructured) {
 						cr.SetCompositionReference(&corev1.ObjectReference{})
 						cr.SetConditions(xpv1.ReconcileError(errors.Wrap(errBoom, errConfigure)))
 					})),
@@ -275,7 +275,7 @@ func TestReconcile(t *testing.T) {
 			args: args{
 				c: &test.MockClient{
 					MockGet: test.NewMockGetFn(nil),
-					MockStatusUpdate: WantComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockStatusUpdate: WantComposite(t, NewComposite(func(cr *composite.Unstructured) {
 						cr.SetCompositionReference(&corev1.ObjectReference{})
 						cr.SetConditions(xpv1.ReconcileError(errors.Wrap(errBoom, errCompose)))
 					})),
@@ -306,7 +306,7 @@ func TestReconcile(t *testing.T) {
 			args: args{
 				c: &test.MockClient{
 					MockGet: test.NewMockGetFn(nil),
-					MockStatusUpdate: WantComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockStatusUpdate: WantComposite(t, NewComposite(func(cr *composite.Unstructured) {
 						cr.SetCompositionReference(&corev1.ObjectReference{})
 						cr.SetConditions(xpv1.ReconcileError(errors.Wrap(errBoom, errPublish)))
 					})),
@@ -342,7 +342,7 @@ func TestReconcile(t *testing.T) {
 			args: args{
 				c: &test.MockClient{
 					MockGet: test.NewMockGetFn(nil),
-					MockStatusUpdate: WantComposite(t, NewComposite(func(xr xresource.Composite) {
+					MockStatusUpdate: WantComposite(t, NewComposite(func(xr *composite.Unstructured) {
 						xr.SetCompositionReference(&corev1.ObjectReference{})
 						xr.SetConditions(xpv1.ReconcileSuccess(), xpv1.Available())
 					})),
@@ -385,7 +385,7 @@ func TestReconcile(t *testing.T) {
 			args: args{
 				c: &test.MockClient{
 					MockGet: test.NewMockGetFn(nil),
-					MockStatusUpdate: WantComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockStatusUpdate: WantComposite(t, NewComposite(func(cr *composite.Unstructured) {
 						cr.SetCompositionReference(&corev1.ObjectReference{})
 						cr.SetConditions(xpv1.ReconcileSuccess(), xpv1.Creating().WithMessage("Unready resources: cat, cow, elephant, and 1 more"))
 					})),
@@ -446,13 +446,13 @@ func TestReconcile(t *testing.T) {
 			reason: "We should requeue after our poll interval if all of our composed resources are ready.",
 			args: args{
 				c: &test.MockClient{
-					MockGet: WithComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockGet: WithComposite(t, NewComposite(func(cr *composite.Unstructured) {
 						cr.SetResourceReferences([]corev1.ObjectReference{{
 							APIVersion: "example.org/v1",
 							Kind:       "ComposedResource",
 						}})
 					})),
-					MockStatusUpdate: WantComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockStatusUpdate: WantComposite(t, NewComposite(func(cr *composite.Unstructured) {
 						cr.SetCompositionReference(&corev1.ObjectReference{})
 						cr.SetResourceReferences([]corev1.ObjectReference{{
 							APIVersion: "example.org/v1",
@@ -509,10 +509,10 @@ func TestReconcile(t *testing.T) {
 			reason: `If a composite resource has the pause annotation with value "true", there should be no further requeue requests.`,
 			args: args{
 				c: &test.MockClient{
-					MockGet: WithComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockGet: WithComposite(t, NewComposite(func(cr *composite.Unstructured) {
 						cr.SetAnnotations(map[string]string{meta.AnnotationKeyReconciliationPaused: "true"})
 					})),
-					MockStatusUpdate: WantComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockStatusUpdate: WantComposite(t, NewComposite(func(cr *composite.Unstructured) {
 						cr.SetAnnotations(map[string]string{meta.AnnotationKeyReconciliationPaused: "true"})
 						cr.SetConditions(xpv1.ReconcilePaused().WithMessage(reconcilePausedMsg))
 					})),
@@ -526,7 +526,7 @@ func TestReconcile(t *testing.T) {
 			reason: `If a composite resource has the pause annotation with value "true" and the status update due to reconciliation being paused fails, error should be reported causing an exponentially backed-off requeue.`,
 			args: args{
 				c: &test.MockClient{
-					MockGet: WithComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockGet: WithComposite(t, NewComposite(func(cr *composite.Unstructured) {
 						cr.SetAnnotations(map[string]string{meta.AnnotationKeyReconciliationPaused: "true"})
 					})),
 					MockStatusUpdate: test.NewMockSubResourceUpdateFn(errBoom),
@@ -540,11 +540,11 @@ func TestReconcile(t *testing.T) {
 			reason: `If a composite resource has the pause annotation with some value other than "true" and the Synced=False/ReconcilePaused status condition, reconciliation should resume with requeueing.`,
 			args: args{
 				c: &test.MockClient{
-					MockGet: WithComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockGet: WithComposite(t, NewComposite(func(cr *composite.Unstructured) {
 						cr.SetAnnotations(map[string]string{meta.AnnotationKeyReconciliationPaused: ""})
 						cr.SetConditions(xpv1.ReconcilePaused())
 					})),
-					MockStatusUpdate: WantComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockStatusUpdate: WantComposite(t, NewComposite(func(cr *composite.Unstructured) {
 						cr.SetAnnotations(map[string]string{meta.AnnotationKeyReconciliationPaused: ""})
 						cr.SetConditions(xpv1.ReconcileSuccess(), xpv1.Available())
 						cr.SetConnectionDetailsLastPublishedTime(&now)
@@ -581,12 +581,12 @@ func TestReconcile(t *testing.T) {
 			reason: `If a composite resource has the pause annotation removed and the Synced=False/ReconcilePaused status condition, reconciliation should resume with requeueing.`,
 			args: args{
 				c: &test.MockClient{
-					MockGet: WithComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockGet: WithComposite(t, NewComposite(func(cr *composite.Unstructured) {
 						// no annotation atm
 						// (but reconciliations were already paused)
 						cr.SetConditions(xpv1.ReconcilePaused())
 					})),
-					MockStatusUpdate: WantComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockStatusUpdate: WantComposite(t, NewComposite(func(cr *composite.Unstructured) {
 						cr.SetConditions(xpv1.ReconcileSuccess(), xpv1.Available())
 						cr.SetConnectionDetailsLastPublishedTime(&now)
 						cr.SetCompositionReference(&corev1.ObjectReference{})
@@ -634,7 +634,8 @@ func TestReconcile(t *testing.T) {
 						}
 						return nil
 					}),
-					MockStatusUpdate: WantComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockStatusUpdate: WantComposite(t, NewComposite(func(cr *composite.Unstructured) {
+						cr.Schema = composite.SchemaLegacy
 						cr.SetCompositionReference(&corev1.ObjectReference{})
 						cr.SetConditions(
 							xpv1.Condition{
@@ -654,11 +655,13 @@ func TestReconcile(t *testing.T) {
 							xpv1.ReconcileSuccess(),
 							xpv1.Available(),
 						)
-						cr.(*composite.Unstructured).SetClaimConditionTypes("DatabaseReady")
+						cr.SetClaimConditionTypes("DatabaseReady")
 						cr.SetClaimReference(&reference.Claim{})
 					})),
 				},
 				opts: []ReconcilerOption{
+					// Only legacy XRs support claims.
+					WithCompositeSchema(composite.SchemaLegacy),
 					WithRecorder(newTestRecorder(
 						eventArgs{
 							Kind: compositeKind,
@@ -789,9 +792,9 @@ func TestReconcile(t *testing.T) {
 						}
 						return nil
 					}),
-					MockStatusUpdate: WantComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockStatusUpdate: WantComposite(t, NewComposite(func(cr *composite.Unstructured) {
+						cr.Schema = composite.SchemaLegacy
 						cr.SetCompositionReference(&corev1.ObjectReference{})
-
 						cr.SetConditions(
 							xpv1.Condition{
 								Type:    "DatabaseReady",
@@ -818,7 +821,7 @@ func TestReconcile(t *testing.T) {
 							},
 						)
 
-						cr.(*composite.Unstructured).SetClaimConditionTypes(
+						cr.SetClaimConditionTypes(
 							"DatabaseReady",
 							"BucketReady",
 						)
@@ -826,6 +829,8 @@ func TestReconcile(t *testing.T) {
 					})),
 				},
 				opts: []ReconcilerOption{
+					// Only legacy XRs support claims.
+					WithCompositeSchema(composite.SchemaLegacy),
 					WithRecorder(newTestRecorder(
 						eventArgs{
 							Kind: compositeKind,
@@ -988,7 +993,8 @@ func TestReconcile(t *testing.T) {
 						}
 						return nil
 					}),
-					MockStatusUpdate: WantComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockStatusUpdate: WantComposite(t, NewComposite(func(cr *composite.Unstructured) {
+						cr.Schema = composite.SchemaLegacy
 						cr.SetCompositionReference(&corev1.ObjectReference{})
 						cr.SetConditions(
 							// The database condition should exist even though it was not seen
@@ -1020,7 +1026,7 @@ func TestReconcile(t *testing.T) {
 							xpv1.ReconcileSuccess(),
 							xpv1.Available(),
 						)
-						cr.(*composite.Unstructured).SetClaimConditionTypes(
+						cr.SetClaimConditionTypes(
 							// The database claim condition should exist even though it was
 							// not seen during this reconcile.
 							"DatabaseReady",
@@ -1030,6 +1036,8 @@ func TestReconcile(t *testing.T) {
 					})),
 				},
 				opts: []ReconcilerOption{
+					// Only legacy XRs support claims.
+					WithCompositeSchema(composite.SchemaLegacy),
 					WithRecorder(newTestRecorder(
 						eventArgs{
 							Kind: compositeKind,
@@ -1107,7 +1115,8 @@ func TestReconcile(t *testing.T) {
 						}
 						return nil
 					}),
-					MockStatusUpdate: WantComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockStatusUpdate: WantComposite(t, NewComposite(func(cr *composite.Unstructured) {
+						cr.Schema = composite.SchemaLegacy
 						cr.SetCompositionReference(&corev1.ObjectReference{})
 						cr.SetConditions(
 							xpv1.ReconcileSuccess(),
@@ -1117,6 +1126,8 @@ func TestReconcile(t *testing.T) {
 					})),
 				},
 				opts: []ReconcilerOption{
+					// Only legacy XRs support claims.
+					WithCompositeSchema(composite.SchemaLegacy),
 					WithRecorder(newTestRecorder(
 						eventArgs{
 							Kind: compositeKind,
@@ -1172,13 +1183,16 @@ func TestReconcile(t *testing.T) {
 						}
 						return nil
 					}),
-					MockStatusUpdate: WantComposite(t, NewComposite(func(cr xresource.Composite) {
+					MockStatusUpdate: WantComposite(t, NewComposite(func(cr *composite.Unstructured) {
+						cr.Schema = composite.SchemaLegacy
 						cr.SetCompositionReference(&corev1.ObjectReference{})
 						cr.SetConditions(xpv1.ReconcileSuccess(), xpv1.Available())
 						cr.SetClaimReference(&reference.Claim{})
 					})),
 				},
 				opts: []ReconcilerOption{
+					// Only legacy XRs support claims.
+					WithCompositeSchema(composite.SchemaLegacy),
 					WithRecorder(newTestRecorder(
 						eventArgs{
 							Kind: compositeKind,
@@ -1257,7 +1271,7 @@ func TestReconcile(t *testing.T) {
 	}
 }
 
-type CompositeModifier func(cr xresource.Composite)
+type CompositeModifier func(cr *composite.Unstructured)
 
 func NewComposite(m ...CompositeModifier) *composite.Unstructured {
 	cr := composite.New(composite.WithGroupVersionKind(schema.GroupVersionKind{}))
diff --git a/internal/controller/apiextensions/definition/handlers_test.go b/internal/controller/apiextensions/definition/handlers_test.go
index 328061602..c503c95fb 100644
--- a/internal/controller/apiextensions/definition/handlers_test.go
+++ b/internal/controller/apiextensions/definition/handlers_test.go
@@ -26,6 +26,7 @@ import (
 func TestEnqueueForCompositionRevisionFunc(t *testing.T) {
 	type args struct {
 		of     schema.GroupVersionKind
+		schema composite.Schema
 		reader client.Reader
 		event  kevent.CreateEvent
 	}
@@ -88,6 +89,9 @@ func TestEnqueueForCompositionRevisionFunc(t *testing.T) {
 								v1.LabelCompositionName: "dachshund",
 							},
 						},
+						Spec: v1.CompositionRevisionSpec{
+							CompositeTypeRef: v1.TypeReferenceTo(dog),
+						},
 					},
 				},
 			},
@@ -124,6 +128,9 @@ func TestEnqueueForCompositionRevisionFunc(t *testing.T) {
 								v1.LabelCompositionName: "dachshund",
 							},
 						},
+						Spec: v1.CompositionRevisionSpec{
+							CompositeTypeRef: v1.TypeReferenceTo(dog),
+						},
 					},
 				},
 			},
@@ -202,6 +209,9 @@ func TestEnqueueForCompositionRevisionFunc(t *testing.T) {
 								v1.LabelCompositionName: "dachshund",
 							},
 						},
+						Spec: v1.CompositionRevisionSpec{
+							CompositeTypeRef: v1.TypeReferenceTo(dog),
+						},
 					},
 				},
 			},
@@ -215,7 +225,7 @@ func TestEnqueueForCompositionRevisionFunc(t *testing.T) {
 	}
 	for name, tc := range tests {
 		t.Run(name, func(t *testing.T) {
-			fns := EnqueueForCompositionRevision(tc.args.of, tc.args.reader, logging.NewNopLogger())
+			fns := EnqueueForCompositionRevision(tc.args.of, tc.args.schema, tc.args.reader, logging.NewNopLogger())
 			q := rateLimitingQueueMock{}
 			fns.Create(context.TODO(), tc.args.event, &q)
 
diff --git a/internal/controller/apiextensions/definition/indexes_test.go b/internal/controller/apiextensions/definition/indexes_test.go
index 40fc1315e..6464d993d 100644
--- a/internal/controller/apiextensions/definition/indexes_test.go
+++ b/internal/controller/apiextensions/definition/indexes_test.go
@@ -23,6 +23,8 @@ import (
 	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/crossplane/crossplane/internal/xresource/unstructured/composite"
 )
 
 func TestIndexCompositeResourcesRefs(t *testing.T) {
@@ -30,44 +32,60 @@ func TestIndexCompositeResourcesRefs(t *testing.T) {
 		object client.Object
 	}
 	tests := map[string]struct {
-		args args
-		want []string
+		schema composite.Schema
+		args   args
+		want   []string
 	}{
-		"Nil":             {args: args{object: nil}, want: nil},
-		"NotUnstructured": {args: args{object: &corev1.Pod{}}, want: nil},
-		"NoRefs": {args: args{object: &unstructured.Unstructured{
-			Object: map[string]interface{}{
-				"spec": map[string]interface{}{},
-			},
-		}}, want: []string{}},
-		"References": {args: args{object: &unstructured.Unstructured{
-			Object: map[string]interface{}{
-				"spec": map[string]interface{}{
-					"resourceRefs": []interface{}{
-						map[string]interface{}{
-							"apiVersion": "nop.crossplane.io/v1alpha1",
-							"kind":       "NopResource",
-							"name":       "mr",
-						},
-						map[string]interface{}{
-							"apiVersion": "nop.example.org/v1alpha1",
-							"kind":       "NopResource",
-							"name":       "xr",
-						},
-						map[string]interface{}{
-							"apiVersion": "v1",
-							"kind":       "ConfigMap",
-							"name":       "cm",
-							"namespace":  "ns",
+		"Nil": {
+			args: args{object: nil},
+			want: nil,
+		},
+		"NotUnstructured": {
+			args: args{object: &corev1.Pod{}},
+			want: nil,
+		},
+		"NoRefs": {
+			args: args{object: &unstructured.Unstructured{
+				Object: map[string]interface{}{
+					"spec": map[string]interface{}{},
+				},
+			}},
+			want: []string{},
+		},
+		"Legacy": {
+			schema: composite.SchemaLegacy,
+			args: args{
+				object: &unstructured.Unstructured{
+					Object: map[string]interface{}{
+						"spec": map[string]interface{}{
+							"resourceRefs": []interface{}{
+								map[string]interface{}{
+									"apiVersion": "nop.crossplane.io/v1alpha1",
+									"kind":       "NopResource",
+									"name":       "mr",
+								},
+								map[string]interface{}{
+									"apiVersion": "nop.example.org/v1alpha1",
+									"kind":       "NopResource",
+									"name":       "xr",
+								},
+								map[string]interface{}{
+									"apiVersion": "v1",
+									"kind":       "ConfigMap",
+									"name":       "cm",
+									"namespace":  "ns",
+								},
+							},
 						},
 					},
 				},
 			},
-		}}, want: []string{"mr..NopResource.nop.crossplane.io/v1alpha1", "xr..NopResource.nop.example.org/v1alpha1", "cm.ns.ConfigMap.v1"}},
+			want: []string{"mr..NopResource.nop.crossplane.io/v1alpha1", "xr..NopResource.nop.example.org/v1alpha1", "cm.ns.ConfigMap.v1"},
+		},
 	}
 	for name, tc := range tests {
 		t.Run(name, func(t *testing.T) {
-			got := IndexCompositeResourcesRefs(tc.args.object)
+			got := IndexCompositeResourcesRefs(tc.schema)(tc.args.object)
 			if diff := cmp.Diff(tc.want, got); diff != "" {
 				t.Errorf("\n%s\nIndexCompositeResourcesRefs(...): -want, +got:\n%s", name, diff)
 			}
diff --git a/internal/xcrd/crd_test.go b/internal/xcrd/crd_test.go
index e6b0b4216..f31a6a9ba 100644
--- a/internal/xcrd/crd_test.go
+++ b/internal/xcrd/crd_test.go
@@ -269,12 +269,12 @@ func TestForCompositeResource(t *testing.T) {
 								{
 									Name:     "COMPOSITION",
 									Type:     "string",
-									JSONPath: ".spec.compositionRef.name",
+									JSONPath: ".spec.crossplane.compositionRef.name",
 								},
 								{
 									Name:     "COMPOSITIONREVISION",
 									Type:     "string",
-									JSONPath: ".spec.compositionRevisionRef.name",
+									JSONPath: ".spec.crossplane.compositionRevisionRef.name",
 									Priority: 1,
 								},
 								{
@@ -372,16 +372,6 @@ func TestForCompositeResource(t *testing.T) {
 																{Raw: []byte(`"Manual"`)},
 															},
 														},
-														"claimRef": {
-															Type:     "object",
-															Required: []string{"apiVersion", "kind", "namespace", "name"},
-															Properties: map[string]extv1.JSONSchemaProps{
-																"apiVersion": {Type: "string"},
-																"kind":       {Type: "string"},
-																"namespace":  {Type: "string"},
-																"name":       {Type: "string"},
-															},
-														},
 														"resourceRefs": {
 															Type: "array",
 															Items: &extv1.JSONSchemaPropsOrArray{
@@ -399,10 +389,9 @@ func TestForCompositeResource(t *testing.T) {
 														},
 														"writeConnectionSecretToRef": {
 															Type:     "object",
-															Required: []string{"name", "namespace"},
+															Required: []string{"name"},
 															Properties: map[string]extv1.JSONSchemaProps{
-																"name":      {Type: "string"},
-																"namespace": {Type: "string"},
+																"name": {Type: "string"},
 															},
 														},
 													},
@@ -644,6 +633,7 @@ func TestForCompositeResource(t *testing.T) {
 															Properties: map[string]extv1.JSONSchemaProps{
 																"apiVersion": {Type: "string"},
 																"name":       {Type: "string"},
+																"namespace":  {Type: "string"},
 																"kind":       {Type: "string"},
 															},
 															Required: []string{"apiVersion", "kind"},
@@ -924,6 +914,7 @@ func TestForCompositeResource(t *testing.T) {
 															Properties: map[string]extv1.JSONSchemaProps{
 																"apiVersion": {Type: "string"},
 																"name":       {Type: "string"},
+																"namespace":  {Type: "string"},
 																"kind":       {Type: "string"},
 															},
 															Required: []string{"apiVersion", "kind"},
@@ -1167,6 +1158,7 @@ func TestForCompositeResource(t *testing.T) {
 															Properties: map[string]extv1.JSONSchemaProps{
 																"apiVersion": {Type: "string"},
 																"name":       {Type: "string"},
+																"namespace":  {Type: "string"},
 																"kind":       {Type: "string"},
 															},
 															Required: []string{"apiVersion", "kind"},
@@ -1400,6 +1392,7 @@ func TestForCompositeResource(t *testing.T) {
 															Properties: map[string]extv1.JSONSchemaProps{
 																"apiVersion": {Type: "string"},
 																"name":       {Type: "string"},
+																"namespace":  {Type: "string"},
 																"kind":       {Type: "string"},
 															},
 															Required: []string{"apiVersion", "kind"},
@@ -1656,6 +1649,7 @@ func TestForCompositeResource(t *testing.T) {
 															Properties: map[string]extv1.JSONSchemaProps{
 																"apiVersion": {Type: "string"},
 																"name":       {Type: "string"},
+																"namespace":  {Type: "string"},
 																"kind":       {Type: "string"},
 															},
 															Required: []string{"apiVersion", "kind"},
@@ -1923,6 +1917,7 @@ func TestForCompositeResource(t *testing.T) {
 															Properties: map[string]extv1.JSONSchemaProps{
 																"apiVersion": {Type: "string"},
 																"name":       {Type: "string"},
+																"namespace":  {Type: "string"},
 																"kind":       {Type: "string"},
 															},
 															Required: []string{"apiVersion", "kind"},
@@ -2016,6 +2011,9 @@ func TestForCompositeResource(t *testing.T) {
 
 	for name, tc := range cases {
 		t.Run(name, func(t *testing.T) {
+			// TODO(negz): This is surprising - refactor it. We should always
+			// pass the xrd as an argument, not default it here. Same with the
+			// version.
 			var xrd *v1.CompositeResourceDefinition
 			if tc.args.xrd != nil {
 				xrd = tc.args.xrd
diff --git a/internal/xresource/unstructured/composite/composite_test.go b/internal/xresource/unstructured/composite/composite_test.go
index ed01e92d6..ed709f56a 100644
--- a/internal/xresource/unstructured/composite/composite_test.go
+++ b/internal/xresource/unstructured/composite/composite_test.go
@@ -97,7 +97,7 @@ func TestConditions(t *testing.T) {
 		},
 		"WeirdStatus": {
 			reason: "It should not be possible to set a condition when status is not an object.",
-			u: &Unstructured{unstructured.Unstructured{Object: map[string]any{
+			u: &Unstructured{Unstructured: unstructured.Unstructured{Object: map[string]any{
 				"status": "wat",
 			}}},
 			set:     []xpv1.Condition{xpv1.Available()},
@@ -107,7 +107,7 @@ func TestConditions(t *testing.T) {
 		},
 		"WeirdStatusConditions": {
 			reason: "Conditions should be overwritten if they are not an object.",
-			u: &Unstructured{unstructured.Unstructured{Object: map[string]any{
+			u: &Unstructured{Unstructured: unstructured.Unstructured{Object: map[string]any{
 				"status": map[string]any{
 					"conditions": "wat",
 				},
@@ -146,7 +146,7 @@ func TestClaimConditionTypes(t *testing.T) {
 	}{
 		"CannotSetSystemConditionTypes": {
 			reason: "Claim conditions API should fail to set conditions if a system condition is detected.",
-			u:      New(),
+			u:      New(WithSchema(SchemaLegacy)),
 			set: []xpv1.ConditionType{
 				xpv1.ConditionType("DatabaseReady"),
 				xpv1.ConditionType("NetworkReady"),
@@ -158,37 +158,43 @@ func TestClaimConditionTypes(t *testing.T) {
 		},
 		"SetSingleCustomConditionType": {
 			reason: "Claim condition API should work with a single custom condition type.",
-			u:      New(),
+			u:      New(WithSchema(SchemaLegacy)),
 			set:    []xpv1.ConditionType{xpv1.ConditionType("DatabaseReady")},
 			want:   []xpv1.ConditionType{xpv1.ConditionType("DatabaseReady")},
 		},
 		"SetMultipleCustomConditionTypes": {
 			reason: "Claim condition API should work with multiple custom condition types.",
-			u:      New(),
+			u:      New(WithSchema(SchemaLegacy)),
 			set:    []xpv1.ConditionType{xpv1.ConditionType("DatabaseReady"), xpv1.ConditionType("NetworkReady")},
 			want:   []xpv1.ConditionType{xpv1.ConditionType("DatabaseReady"), xpv1.ConditionType("NetworkReady")},
 		},
 		"SetMultipleOfTheSameCustomConditionTypes": {
 			reason: "Claim condition API not add more than one of the same condition.",
-			u:      New(),
+			u:      New(WithSchema(SchemaLegacy)),
 			set:    []xpv1.ConditionType{xpv1.ConditionType("DatabaseReady"), xpv1.ConditionType("DatabaseReady")},
 			want:   []xpv1.ConditionType{xpv1.ConditionType("DatabaseReady")},
 		},
 		"WeirdStatus": {
 			reason: "It should not be possible to set a condition when status is not an object.",
-			u: &Unstructured{unstructured.Unstructured{Object: map[string]any{
-				"status": "wat",
-			}}},
+			u: &Unstructured{
+				Unstructured: unstructured.Unstructured{Object: map[string]any{
+					"status": "wat",
+				}},
+				Schema: SchemaLegacy,
+			},
 			set:  []xpv1.ConditionType{xpv1.ConditionType("DatabaseReady")},
 			want: []xpv1.ConditionType{},
 		},
 		"WeirdStatusClaimConditionTypes": {
 			reason: "Claim conditions should be overwritten if they are not an object.",
-			u: &Unstructured{unstructured.Unstructured{Object: map[string]any{
-				"status": map[string]any{
-					"claimConditionTypes": "wat",
-				},
-			}}},
+			u: &Unstructured{
+				Unstructured: unstructured.Unstructured{Object: map[string]any{
+					"status": map[string]any{
+						"claimConditionTypes": "wat",
+					},
+				}},
+				Schema: SchemaLegacy,
+			},
 			set:  []xpv1.ConditionType{xpv1.ConditionType("DatabaseReady")},
 			want: []xpv1.ConditionType{xpv1.ConditionType("DatabaseReady")},
 		},
@@ -342,7 +348,7 @@ func TestClaimReference(t *testing.T) {
 		want *reference.Claim
 	}{
 		"NewRef": {
-			u:    New(),
+			u:    New(WithSchema(SchemaLegacy)),
 			set:  ref,
 			want: ref,
 		},
diff --git a/test/e2e/apiextensions_compositions_legacy_test.go b/test/e2e/apiextensions_compositions_legacy_test.go
new file mode 100644
index 000000000..f0c54bf9e
--- /dev/null
+++ b/test/e2e/apiextensions_compositions_legacy_test.go
@@ -0,0 +1,221 @@
+/*
+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"
+	"sigs.k8s.io/e2e-framework/third_party/helm"
+
+	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+
+	apiextensionsv1 "github.com/crossplane/crossplane/apis/apiextensions/v1"
+	pkgv1 "github.com/crossplane/crossplane/apis/pkg/v1"
+	"github.com/crossplane/crossplane/test/e2e/config"
+	"github.com/crossplane/crossplane/test/e2e/funcs"
+)
+
+func TestLegacyComposition(t *testing.T) {
+	manifests := "test/e2e/manifests/apiextensions/composition/legacy"
+	environment.Test(t,
+		features.NewWithDescription(t.Name(), "Tests the correct functioning of composition functions ensuring that the composed resources are created, conditions are met, fields are patched, and resources are properly cleaned up when deleted.").
+			WithLabel(LabelArea, LabelAreaAPIExtensions).
+			WithLabel(LabelSize, LabelSizeSmall).
+			WithLabel(config.LabelTestSuite, config.TestSuiteDefault).
+			WithSetup("CreatePrerequisites", funcs.AllOf(
+				funcs.ApplyResources(FieldManager, manifests, "setup/*.yaml"),
+				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "setup/*.yaml"),
+				funcs.ResourcesHaveConditionWithin(1*time.Minute, manifests, "setup/definition.yaml", apiextensionsv1.WatchingComposite()),
+				funcs.ResourcesHaveConditionWithin(2*time.Minute, manifests, "setup/provider.yaml", pkgv1.Healthy(), pkgv1.Active()),
+			)).
+			Assess("CreateClaim", funcs.AllOf(
+				funcs.ApplyResources(FieldManager, manifests, "claim.yaml"),
+				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "claim.yaml"),
+			)).
+			Assess("ClaimIsReady",
+				funcs.ResourcesHaveConditionWithin(5*time.Minute, manifests, "claim.yaml", xpv1.Available())).
+			Assess("ClaimHasPatchedField",
+				funcs.ResourcesHaveFieldValueWithin(5*time.Minute, manifests, "claim.yaml", "status.coolerField", "I'M COOLER!"),
+			).
+			WithTeardown("DeleteClaim", funcs.AllOf(
+				funcs.DeleteResources(manifests, "claim.yaml"),
+				funcs.ResourcesDeletedWithin(2*time.Minute, manifests, "claim.yaml"),
+			)).
+			WithTeardown("DeletePrerequisites", funcs.ResourcesDeletedAfterListedAreGone(3*time.Minute, manifests, "setup/*.yaml", nopList)).
+			Feature(),
+	)
+}
+
+func TestLegacyPropagateFieldsRemovalToXR(t *testing.T) {
+	manifests := "test/e2e/manifests/apiextensions/composition/legacy-propagate-field-removals"
+	environment.Test(t,
+		features.NewWithDescription(t.Name(), "Tests that field removals in a claim are correctly propagated to the associated composite resource (XR), ensuring that updates and deletions are properly synchronized, and that the status from the XR is accurately reflected back to the claim.").
+			WithLabel(LabelStage, LabelStageBeta).
+			WithLabel(LabelArea, LabelAreaAPIExtensions).
+			WithLabel(LabelSize, LabelSizeSmall).
+			WithLabel(LabelModifyCrossplaneInstallation, LabelModifyCrossplaneInstallationTrue).
+			WithLabel(config.LabelTestSuite, config.TestSuiteDefault).
+			WithSetup("PrerequisitesAreCreated", funcs.AllOf(
+				funcs.ApplyResources(FieldManager, manifests, "setup/*.yaml"),
+				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "setup/*.yaml"),
+				funcs.ResourcesHaveConditionWithin(1*time.Minute, manifests, "setup/definition.yaml", apiextensionsv1.WatchingComposite()),
+			)).
+			Assess("CreateClaim", funcs.AllOf(
+				funcs.ApplyClaim(FieldManager, manifests, "claim.yaml"),
+				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "claim.yaml"),
+				funcs.ResourcesHaveConditionWithin(5*time.Minute, manifests, "claim.yaml", xpv1.Available()),
+			)).
+			Assess("UpdateClaim", funcs.ApplyClaim(FieldManager, manifests, "claim-update.yaml")).
+			Assess("FieldsRemovalPropagatedToXR", funcs.AllOf(
+				// Updates and deletes are propagated claim -> XR.
+				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.labels[foo]", "1"),
+				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.labels[bar]", funcs.NotFound),
+				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.labels[foo2]", "3"),
+				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.annotations[test/foo]", "1"),
+				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.annotations[test/bar]", funcs.NotFound),
+				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.annotations[test/foo2]", "4"),
+				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "spec.numbers[0]", "one"),
+				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "spec.numbers[1]", "five"),
+				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "spec.numbers[2]", funcs.NotFound),
+				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "spec.parameters.tags[tag]", "v1"),
+				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "spec.parameters.tags[newtag]", funcs.NotFound),
+				funcs.ClaimUnderTestMustNotChangeWithin(1*time.Minute),
+				funcs.CompositeUnderTestMustNotChangeWithin(1*time.Minute),
+			)).
+			WithTeardown("DeleteClaim", funcs.AllOf(
+				funcs.DeleteResources(manifests, "claim.yaml"),
+				funcs.ResourcesDeletedWithin(2*time.Minute, manifests, "claim.yaml"),
+			)).
+			WithTeardown("DeletePrerequisites", funcs.ResourcesDeletedAfterListedAreGone(3*time.Minute, manifests, "setup/*.yaml", nopList)).
+			Feature(),
+	)
+}
+
+func TestLegacyPropagateFieldsRemovalToXRAfterUpgrade(t *testing.T) {
+	manifests := "test/e2e/manifests/apiextensions/composition/legacy-propagate-field-removals"
+	environment.Test(t,
+		features.NewWithDescription(t.Name(), "Tests that field removals in a composite resource (XR) are correctly propagated after upgrading the field managers from CSA to SSA, verifying that the upgrade process does not interfere with the synchronization of fields between the claim and the XR.").
+			WithLabel(LabelStage, LabelStageBeta).
+			WithLabel(LabelArea, LabelAreaAPIExtensions).
+			WithLabel(LabelSize, LabelSizeSmall).
+			WithLabel(LabelModifyCrossplaneInstallation, LabelModifyCrossplaneInstallationTrue).
+			WithLabel(config.LabelTestSuite, config.TestSuiteDefault).
+			// SSA claims are enabled by default, so we need to explicitly
+			// disable them first before we create anything.
+			WithSetup("DisableSSAClaims", funcs.AllOf(
+				funcs.AsFeaturesFunc(environment.HelmUpgradeCrossplaneToBase(helm.WithArgs("--set args={--debug,--enable-ssa-claims=false}"))), // Disable our feature flag.
+				funcs.ArgExistsWithin(1*time.Minute, "--enable-ssa-claims=false", namespace, "crossplane"),
+				funcs.ReadyToTestWithin(1*time.Minute, namespace),
+				funcs.DeploymentPodIsRunningMustNotChangeWithin(10*time.Second, namespace, "crossplane"),
+			)).
+			WithSetup("PrerequisitesAreCreated", funcs.AllOf(
+				funcs.ApplyResources(FieldManager, manifests, "setup/*.yaml"),
+				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "setup/*.yaml"),
+				funcs.ResourcesHaveConditionWithin(1*time.Minute, manifests, "setup/definition.yaml", apiextensionsv1.WatchingComposite()),
+			)).
+			Assess("CreateClaim", funcs.AllOf(
+				funcs.ApplyClaim(FieldManager, manifests, "claim.yaml"),
+				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "claim.yaml"),
+				funcs.ResourcesHaveConditionWithin(5*time.Minute, manifests, "claim.yaml", xpv1.Available()),
+			)).
+			// Note that unlike TestPropagateFieldsRemovalToXR above, here we
+			// enable SSA _after_ creating the claim. Our goal is to test that
+			// we can still remove fields from the XR when we've upgraded the
+			// field managers from CSA to SSA. If we didn't upgrade successfully
+			// would end up sharing ownership with the old CSA field manager.
+			Assess("EnableSSAClaims", funcs.AllOf(
+				funcs.AsFeaturesFunc(environment.HelmUpgradeCrossplaneToBase()),
+				funcs.ArgNotExistsWithin(1*time.Minute, "--enable-ssa-claims=false", namespace, "crossplane"),
+				funcs.ReadyToTestWithin(1*time.Minute, namespace),
+				funcs.DeploymentPodIsRunningMustNotChangeWithin(10*time.Second, namespace, "crossplane"),
+			)).
+			Assess("UpdateClaim", funcs.AllOf(
+				funcs.ApplyClaim(FieldManager, manifests, "claim-update.yaml"),
+				funcs.ClaimUnderTestMustNotChangeWithin(1*time.Minute),
+			)).
+			Assess("FieldsRemovalPropagatedToXR", funcs.AllOf(
+				// Updates and deletes are propagated claim -> XR.
+				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.labels[foo]", "1"),
+				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.labels[bar]", funcs.NotFound),
+				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.labels[foo2]", "3"),
+				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.annotations[test/foo]", "1"),
+				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.annotations[test/bar]", funcs.NotFound),
+				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.annotations[test/foo2]", "4"),
+				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "spec.numbers[0]", "one"),
+				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "spec.numbers[1]", "five"),
+				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "spec.numbers[2]", funcs.NotFound),
+				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "spec.parameters.tags[tag]", "v1"),
+				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "spec.parameters.tags[newtag]", funcs.NotFound),
+				funcs.ClaimUnderTestMustNotChangeWithin(1*time.Minute),
+				funcs.CompositeUnderTestMustNotChangeWithin(1*time.Minute),
+			)).
+			WithTeardown("DeleteClaim", funcs.AllOf(
+				funcs.DeleteResources(manifests, "claim.yaml"),
+				funcs.ResourcesDeletedWithin(2*time.Minute, manifests, "claim.yaml"),
+			)).
+			WithTeardown("DeletePrerequisites", funcs.ResourcesDeletedAfterListedAreGone(3*time.Minute, manifests, "setup/*.yaml", nopList)).
+			Feature(),
+	)
+}
+
+func TestLegacyBindToExistingXR(t *testing.T) {
+	manifests := "test/e2e/manifests/apiextensions/composition/legacy-bind-existing-xr"
+	environment.Test(t,
+		features.NewWithDescription(t.Name(), "Tests that a new claim can successfully bind to an existing composite resource (XR), ensuring that the XR’s fields are updated according to the claim’s specifications and that the XR is correctly managed when the claim is deleted.").
+			WithLabel(LabelArea, LabelAreaAPIExtensions).
+			WithLabel(LabelSize, LabelSizeSmall).
+			WithLabel(config.LabelTestSuite, config.TestSuiteDefault).
+			WithSetup("PrerequisitesAreCreated", funcs.AllOf(
+				funcs.ApplyResources(FieldManager, manifests, "setup/*.yaml"),
+				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "setup/*.yaml"),
+				funcs.ResourcesHaveConditionWithin(1*time.Minute, manifests, "setup/definition.yaml", apiextensionsv1.WatchingComposite()),
+			)).
+			// Create an XR we'll later bind to.
+			Assess("CreateXR", funcs.AllOf(
+				funcs.ApplyClaim(FieldManager, manifests, "xr.yaml"),
+				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "xr.yaml"),
+				funcs.ResourcesHaveConditionWithin(5*time.Minute, manifests, "xr.yaml", xpv1.Available()),
+			)).
+			// Make sure our fields are set to the XR's values.
+			Assess("XRFieldHasOriginalValues", funcs.AllOf(
+				funcs.ResourcesHaveFieldValueWithin(1*time.Minute, manifests, "xr.yaml", "spec.coolField", "Set by XR"),
+			)).
+			// Create a claim that explicitly asks to bind to the above XR.
+			Assess("CreateClaim", funcs.AllOf(
+				funcs.ApplyClaim(FieldManager, manifests, "claim.yaml"),
+				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "claim.yaml"),
+				funcs.ResourcesHaveConditionWithin(5*time.Minute, manifests, "claim.yaml", xpv1.Available()),
+			)).
+			Assess("XRIsBoundToClaim", funcs.AllOf(
+				funcs.ResourcesHaveFieldValueWithin(1*time.Minute, manifests, "xr.yaml", "spec.claimRef.name", "bind-existing-xr"),
+			)).
+			Assess("XRFieldChangesToClaimValue", funcs.AllOf(
+				funcs.ResourcesHaveFieldValueWithin(1*time.Minute, manifests, "xr.yaml", "spec.coolField", "Set by claim"),
+			)).
+			WithTeardown("DeleteClaim", funcs.AllOf(
+				funcs.DeleteResources(manifests, "claim.yaml"),
+				funcs.ResourcesDeletedWithin(2*time.Minute, manifests, "claim.yaml"),
+
+				// Deleting the claim should delete the XR.
+				funcs.ResourcesDeletedWithin(2*time.Minute, manifests, "xr.yaml"),
+			)).
+			WithTeardown("DeletePrerequisites", funcs.ResourcesDeletedAfterListedAreGone(3*time.Minute, manifests, "setup/*.yaml", nopList)).
+			Feature(),
+	)
+}
diff --git a/test/e2e/apiextensions_compositions_realtime_test.go b/test/e2e/apiextensions_compositions_realtime_test.go
index a669dbd1c..55270587a 100644
--- a/test/e2e/apiextensions_compositions_realtime_test.go
+++ b/test/e2e/apiextensions_compositions_realtime_test.go
@@ -1,3 +1,19 @@
+/*
+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 (
@@ -101,3 +117,41 @@ func TestRealtimeCompositions(t *testing.T) {
 			Feature(),
 	)
 }
+
+func TestCompositionRealtimeRevisionSelection(t *testing.T) {
+	manifests := "test/e2e/manifests/apiextensions/composition/realtime-revision-selection"
+	environment.Test(t,
+		features.NewWithDescription(t.Name(), "Tests Crossplane's Composition functionality to react in realtime to changes in a Composition by selecting the new CompositionRevision and reconcile the XRs.").
+			WithLabel(LabelArea, LabelAreaAPIExtensions).
+			WithLabel(LabelSize, LabelSizeSmall).
+			WithLabel(config.LabelTestSuite, config.TestSuiteDefault).
+			WithSetup("PrerequisitesAreCreated", funcs.AllOf(
+				funcs.ApplyResources(FieldManager, manifests, "setup/*.yaml"),
+				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "setup/*.yaml"),
+				funcs.ResourcesHaveConditionWithin(1*time.Minute, manifests, "setup/definition.yaml", apiextensionsv1.WatchingComposite()),
+				funcs.ResourcesHaveConditionWithin(2*time.Minute, manifests, "setup/provider.yaml", pkgv1.Healthy(), pkgv1.Active()),
+			)).
+			Assess("CreateClaim", funcs.AllOf(
+				funcs.ApplyResources(FieldManager, manifests, "claim.yaml"),
+				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "claim.yaml"),
+			)).
+			Assess("ClaimIsReady",
+				funcs.ResourcesHaveConditionWithin(5*time.Minute, manifests, "claim.yaml", xpv1.Available()),
+			).
+			Assess("ClaimHasOriginalField",
+				funcs.ResourcesHaveFieldValueWithin(10*time.Second, manifests, "claim.yaml", "status.coolerField", "from-original-composition"),
+			).
+			Assess("UpdateComposition", funcs.AllOf(
+				funcs.ApplyResources(FieldManager, manifests, "composition-update.yaml"),
+			)).
+			Assess("ClaimHasUpdatedField",
+				funcs.ResourcesHaveFieldValueWithin(10*time.Second, manifests, "claim.yaml", "status.coolerField", "from-updated-composition"),
+			).
+			WithTeardown("DeleteClaim", funcs.AllOf(
+				funcs.DeleteResources(manifests, "claim.yaml"),
+				funcs.ResourcesDeletedWithin(2*time.Minute, manifests, "claim.yaml"),
+			)).
+			WithTeardown("DeletePrerequisites", funcs.ResourcesDeletedAfterListedAreGone(3*time.Minute, manifests, "setup/*.yaml", nopList)).
+			Feature(),
+	)
+}
diff --git a/test/e2e/apiextensions_compositions_test.go b/test/e2e/apiextensions_compositions_test.go
index dbeed0f23..0bbec0db6 100644
--- a/test/e2e/apiextensions_compositions_test.go
+++ b/test/e2e/apiextensions_compositions_test.go
@@ -21,13 +21,12 @@ import (
 	"time"
 
 	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"sigs.k8s.io/e2e-framework/pkg/features"
-	"sigs.k8s.io/e2e-framework/third_party/helm"
 
 	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
 
 	apiextensionsv1 "github.com/crossplane/crossplane/apis/apiextensions/v1"
-	pkgv1 "github.com/crossplane/crossplane/apis/pkg/v1"
 	"github.com/crossplane/crossplane/internal/xresource/unstructured/composed"
 	"github.com/crossplane/crossplane/test/e2e/config"
 	"github.com/crossplane/crossplane/test/e2e/funcs"
@@ -42,48 +41,43 @@ var nopList = composed.NewList(composed.FromReferenceToList(corev1.ObjectReferen
 	Kind:       "NopResource",
 }))
 
-func TestCompositionRealtimeRevisionSelection(t *testing.T) {
-	manifests := "test/e2e/manifests/apiextensions/composition/realtime-revision-selection"
+func TestBasicCompositionNamespaced(t *testing.T) {
+	manifests := "test/e2e/manifests/apiextensions/composition/basic-namespaced"
 	environment.Test(t,
-		features.NewWithDescription(t.Name(), "Tests Crossplane's Composition functionality to react in realtime to changes in a Composition by selecting the new CompositionRevision and reconcile the XRs.").
+		features.NewWithDescription(t.Name(), "Tests the correct functioning of a namespaced XR ensuring that the composed resources are created, conditions are met, fields are patched, and resources are properly cleaned up when deleted.").
 			WithLabel(LabelArea, LabelAreaAPIExtensions).
 			WithLabel(LabelSize, LabelSizeSmall).
 			WithLabel(config.LabelTestSuite, config.TestSuiteDefault).
-			WithSetup("PrerequisitesAreCreated", funcs.AllOf(
+			WithSetup("CreatePrerequisites", funcs.AllOf(
 				funcs.ApplyResources(FieldManager, manifests, "setup/*.yaml"),
 				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "setup/*.yaml"),
 				funcs.ResourcesHaveConditionWithin(1*time.Minute, manifests, "setup/definition.yaml", apiextensionsv1.WatchingComposite()),
-				funcs.ResourcesHaveConditionWithin(2*time.Minute, manifests, "setup/provider.yaml", pkgv1.Healthy(), pkgv1.Active()),
 			)).
-			Assess("CreateClaim", funcs.AllOf(
-				funcs.ApplyResources(FieldManager, manifests, "claim.yaml"),
-				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "claim.yaml"),
+			Assess("CreateXR", funcs.AllOf(
+				funcs.ApplyResources(FieldManager, manifests, "xr.yaml"),
+				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "xr.yaml"),
 			)).
-			Assess("ClaimIsReady",
-				funcs.ResourcesHaveConditionWithin(5*time.Minute, manifests, "claim.yaml", xpv1.Available()),
+			Assess("XRIsReady",
+				funcs.ResourcesHaveConditionWithin(5*time.Minute, manifests, "xr.yaml", xpv1.Available())).
+			Assess("XRHasStatusField",
+				funcs.ResourcesHaveFieldValueWithin(5*time.Minute, manifests, "xr.yaml", "status.coolerField", "I'M COOLER!"),
 			).
-			Assess("ClaimHasOriginalField",
-				funcs.ResourcesHaveFieldValueWithin(10*time.Second, manifests, "claim.yaml", "status.coolerField", "from-original-composition"),
+			Assess("ConnectionSecretCreated",
+				funcs.ResourceHasFieldValueWithin(30*time.Second, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "basic-secret-namespaced"}}, "data[super]", "c2VjcmV0Cg=="),
 			).
-			Assess("UpdateComposition", funcs.AllOf(
-				funcs.ApplyResources(FieldManager, manifests, "composition-update.yaml"),
-			)).
-			Assess("ClaimHasUpdatedField",
-				funcs.ResourcesHaveFieldValueWithin(10*time.Second, manifests, "claim.yaml", "status.coolerField", "from-updated-composition"),
-			).
-			WithTeardown("DeleteClaim", funcs.AllOf(
-				funcs.DeleteResources(manifests, "claim.yaml"),
-				funcs.ResourcesDeletedWithin(2*time.Minute, manifests, "claim.yaml"),
+			WithTeardown("DeleteXR", funcs.AllOf(
+				funcs.DeleteResources(manifests, "xr.yaml"),
+				funcs.ResourcesDeletedWithin(2*time.Minute, manifests, "xr.yaml"),
 			)).
 			WithTeardown("DeletePrerequisites", funcs.ResourcesDeletedAfterListedAreGone(3*time.Minute, manifests, "setup/*.yaml", nopList)).
 			Feature(),
 	)
 }
 
-func TestCompositionFunctions(t *testing.T) {
-	manifests := "test/e2e/manifests/apiextensions/composition/functions"
+func TestBasicCompositionCluster(t *testing.T) {
+	manifests := "test/e2e/manifests/apiextensions/composition/basic-cluster"
 	environment.Test(t,
-		features.NewWithDescription(t.Name(), "Tests the correct functioning of composition functions ensuring that the composed resources are created, conditions are met, fields are patched, and resources are properly cleaned up when deleted.").
+		features.NewWithDescription(t.Name(), "Tests the correct functioning of cluster-scoped XR ensuring that the composed resources are created, conditions are met, fields are patched, and resources are properly cleaned up when deleted.").
 			WithLabel(LabelArea, LabelAreaAPIExtensions).
 			WithLabel(LabelSize, LabelSizeSmall).
 			WithLabel(config.LabelTestSuite, config.TestSuiteDefault).
@@ -91,132 +85,22 @@ func TestCompositionFunctions(t *testing.T) {
 				funcs.ApplyResources(FieldManager, manifests, "setup/*.yaml"),
 				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "setup/*.yaml"),
 				funcs.ResourcesHaveConditionWithin(1*time.Minute, manifests, "setup/definition.yaml", apiextensionsv1.WatchingComposite()),
-				funcs.ResourcesHaveConditionWithin(2*time.Minute, manifests, "setup/provider.yaml", pkgv1.Healthy(), pkgv1.Active()),
 			)).
-			Assess("CreateClaim", funcs.AllOf(
-				funcs.ApplyResources(FieldManager, manifests, "claim.yaml"),
-				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "claim.yaml"),
+			Assess("CreateXR", funcs.AllOf(
+				funcs.ApplyResources(FieldManager, manifests, "xr.yaml"),
+				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "xr.yaml"),
 			)).
-			Assess("ClaimIsReady",
-				funcs.ResourcesHaveConditionWithin(5*time.Minute, manifests, "claim.yaml", xpv1.Available())).
-			Assess("ClaimHasPatchedField",
-				funcs.ResourcesHaveFieldValueWithin(5*time.Minute, manifests, "claim.yaml", "status.coolerField", "I'M COOLER!"),
+			Assess("XRIsReady",
+				funcs.ResourcesHaveConditionWithin(5*time.Minute, manifests, "xr.yaml", xpv1.Available())).
+			Assess("XRHasStatusField",
+				funcs.ResourcesHaveFieldValueWithin(5*time.Minute, manifests, "xr.yaml", "status.coolerField", "I'M COOLER!"),
 			).
-			WithTeardown("DeleteClaim", funcs.AllOf(
-				funcs.DeleteResources(manifests, "claim.yaml"),
-				funcs.ResourcesDeletedWithin(2*time.Minute, manifests, "claim.yaml"),
-			)).
-			WithTeardown("DeletePrerequisites", funcs.ResourcesDeletedAfterListedAreGone(3*time.Minute, manifests, "setup/*.yaml", nopList)).
-			Feature(),
-	)
-}
-
-func TestPropagateFieldsRemovalToXR(t *testing.T) {
-	manifests := "test/e2e/manifests/apiextensions/composition/propagate-field-removals"
-	environment.Test(t,
-		features.NewWithDescription(t.Name(), "Tests that field removals in a claim are correctly propagated to the associated composite resource (XR), ensuring that updates and deletions are properly synchronized, and that the status from the XR is accurately reflected back to the claim.").
-			WithLabel(LabelStage, LabelStageBeta).
-			WithLabel(LabelArea, LabelAreaAPIExtensions).
-			WithLabel(LabelSize, LabelSizeSmall).
-			WithLabel(LabelModifyCrossplaneInstallation, LabelModifyCrossplaneInstallationTrue).
-			WithLabel(config.LabelTestSuite, config.TestSuiteDefault).
-			WithSetup("PrerequisitesAreCreated", funcs.AllOf(
-				funcs.ApplyResources(FieldManager, manifests, "setup/*.yaml"),
-				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "setup/*.yaml"),
-				funcs.ResourcesHaveConditionWithin(1*time.Minute, manifests, "setup/definition.yaml", apiextensionsv1.WatchingComposite()),
-			)).
-			Assess("CreateClaim", funcs.AllOf(
-				funcs.ApplyClaim(FieldManager, manifests, "claim.yaml"),
-				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "claim.yaml"),
-				funcs.ResourcesHaveConditionWithin(5*time.Minute, manifests, "claim.yaml", xpv1.Available()),
-			)).
-			Assess("UpdateClaim", funcs.ApplyClaim(FieldManager, manifests, "claim-update.yaml")).
-			Assess("FieldsRemovalPropagatedToXR", funcs.AllOf(
-				// Updates and deletes are propagated claim -> XR.
-				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.labels[foo]", "1"),
-				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.labels[bar]", funcs.NotFound),
-				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.labels[foo2]", "3"),
-				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.annotations[test/foo]", "1"),
-				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.annotations[test/bar]", funcs.NotFound),
-				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.annotations[test/foo2]", "4"),
-				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "spec.numbers[0]", "one"),
-				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "spec.numbers[1]", "five"),
-				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "spec.numbers[2]", funcs.NotFound),
-				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "spec.parameters.tags[tag]", "v1"),
-				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "spec.parameters.tags[newtag]", funcs.NotFound),
-				funcs.ClaimUnderTestMustNotChangeWithin(1*time.Minute),
-				funcs.CompositeUnderTestMustNotChangeWithin(1*time.Minute),
-			)).
-			WithTeardown("DeleteClaim", funcs.AllOf(
-				funcs.DeleteResources(manifests, "claim.yaml"),
-				funcs.ResourcesDeletedWithin(2*time.Minute, manifests, "claim.yaml"),
-			)).
-			WithTeardown("DeletePrerequisites", funcs.ResourcesDeletedAfterListedAreGone(3*time.Minute, manifests, "setup/*.yaml", nopList)).
-			Feature(),
-	)
-}
-
-func TestPropagateFieldsRemovalToXRAfterUpgrade(t *testing.T) {
-	manifests := "test/e2e/manifests/apiextensions/composition/propagate-field-removals"
-	environment.Test(t,
-		features.NewWithDescription(t.Name(), "Tests that field removals in a composite resource (XR) are correctly propagated after upgrading the field managers from CSA to SSA, verifying that the upgrade process does not interfere with the synchronization of fields between the claim and the XR.").
-			WithLabel(LabelStage, LabelStageBeta).
-			WithLabel(LabelArea, LabelAreaAPIExtensions).
-			WithLabel(LabelSize, LabelSizeSmall).
-			WithLabel(LabelModifyCrossplaneInstallation, LabelModifyCrossplaneInstallationTrue).
-			WithLabel(config.LabelTestSuite, config.TestSuiteDefault).
-			// SSA claims are enabled by default, so we need to explicitly
-			// disable them first before we create anything.
-			WithSetup("DisableSSAClaims", funcs.AllOf(
-				funcs.AsFeaturesFunc(environment.HelmUpgradeCrossplaneToBase(helm.WithArgs("--set args={--debug,--enable-ssa-claims=false}"))), // Disable our feature flag.
-				funcs.ArgExistsWithin(1*time.Minute, "--enable-ssa-claims=false", namespace, "crossplane"),
-				funcs.ReadyToTestWithin(1*time.Minute, namespace),
-				funcs.DeploymentPodIsRunningMustNotChangeWithin(10*time.Second, namespace, "crossplane"),
-			)).
-			WithSetup("PrerequisitesAreCreated", funcs.AllOf(
-				funcs.ApplyResources(FieldManager, manifests, "setup/*.yaml"),
-				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "setup/*.yaml"),
-				funcs.ResourcesHaveConditionWithin(1*time.Minute, manifests, "setup/definition.yaml", apiextensionsv1.WatchingComposite()),
-			)).
-			Assess("CreateClaim", funcs.AllOf(
-				funcs.ApplyClaim(FieldManager, manifests, "claim.yaml"),
-				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "claim.yaml"),
-				funcs.ResourcesHaveConditionWithin(5*time.Minute, manifests, "claim.yaml", xpv1.Available()),
-			)).
-			// Note that unlike TestPropagateFieldsRemovalToXR above, here we
-			// enable SSA _after_ creating the claim. Our goal is to test that
-			// we can still remove fields from the XR when we've upgraded the
-			// field managers from CSA to SSA. If we didn't upgrade successfully
-			// would end up sharing ownership with the old CSA field manager.
-			Assess("EnableSSAClaims", funcs.AllOf(
-				funcs.AsFeaturesFunc(environment.HelmUpgradeCrossplaneToBase()),
-				funcs.ArgNotExistsWithin(1*time.Minute, "--enable-ssa-claims=false", namespace, "crossplane"),
-				funcs.ReadyToTestWithin(1*time.Minute, namespace),
-				funcs.DeploymentPodIsRunningMustNotChangeWithin(10*time.Second, namespace, "crossplane"),
-			)).
-			Assess("UpdateClaim", funcs.AllOf(
-				funcs.ApplyClaim(FieldManager, manifests, "claim-update.yaml"),
-				funcs.ClaimUnderTestMustNotChangeWithin(1*time.Minute),
-			)).
-			Assess("FieldsRemovalPropagatedToXR", funcs.AllOf(
-				// Updates and deletes are propagated claim -> XR.
-				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.labels[foo]", "1"),
-				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.labels[bar]", funcs.NotFound),
-				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.labels[foo2]", "3"),
-				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.annotations[test/foo]", "1"),
-				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.annotations[test/bar]", funcs.NotFound),
-				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "metadata.annotations[test/foo2]", "4"),
-				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "spec.numbers[0]", "one"),
-				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "spec.numbers[1]", "five"),
-				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "spec.numbers[2]", funcs.NotFound),
-				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "spec.parameters.tags[tag]", "v1"),
-				funcs.CompositeResourceHasFieldValueWithin(1*time.Minute, manifests, "claim.yaml", "spec.parameters.tags[newtag]", funcs.NotFound),
-				funcs.ClaimUnderTestMustNotChangeWithin(1*time.Minute),
-				funcs.CompositeUnderTestMustNotChangeWithin(1*time.Minute),
-			)).
-			WithTeardown("DeleteClaim", funcs.AllOf(
-				funcs.DeleteResources(manifests, "claim.yaml"),
-				funcs.ResourcesDeletedWithin(2*time.Minute, manifests, "claim.yaml"),
+			Assess("ConnectionSecretCreated",
+				funcs.ResourceHasFieldValueWithin(30*time.Second, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "basic-secret-cluster"}}, "data[super]", "c2VjcmV0Cg=="),
+			).
+			WithTeardown("DeleteXR", funcs.AllOf(
+				funcs.DeleteResources(manifests, "xr.yaml"),
+				funcs.ResourcesDeletedWithin(2*time.Minute, manifests, "xr.yaml"),
 			)).
 			WithTeardown("DeletePrerequisites", funcs.ResourcesDeletedAfterListedAreGone(3*time.Minute, manifests, "setup/*.yaml", nopList)).
 			Feature(),
@@ -275,52 +159,6 @@ func TestCompositionSelection(t *testing.T) {
 	)
 }
 
-func TestBindToExistingXR(t *testing.T) {
-	manifests := "test/e2e/manifests/apiextensions/composition/bind-existing-xr"
-	environment.Test(t,
-		features.NewWithDescription(t.Name(), "Tests that a new claim can successfully bind to an existing composite resource (XR), ensuring that the XR’s fields are updated according to the claim’s specifications and that the XR is correctly managed when the claim is deleted.").
-			WithLabel(LabelArea, LabelAreaAPIExtensions).
-			WithLabel(LabelSize, LabelSizeSmall).
-			WithLabel(config.LabelTestSuite, config.TestSuiteDefault).
-			WithSetup("PrerequisitesAreCreated", funcs.AllOf(
-				funcs.ApplyResources(FieldManager, manifests, "setup/*.yaml"),
-				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "setup/*.yaml"),
-				funcs.ResourcesHaveConditionWithin(1*time.Minute, manifests, "setup/definition.yaml", apiextensionsv1.WatchingComposite()),
-			)).
-			// Create an XR we'll later bind to.
-			Assess("CreateXR", funcs.AllOf(
-				funcs.ApplyClaim(FieldManager, manifests, "xr.yaml"),
-				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "xr.yaml"),
-				funcs.ResourcesHaveConditionWithin(5*time.Minute, manifests, "xr.yaml", xpv1.Available()),
-			)).
-			// Make sure our fields are set to the XR's values.
-			Assess("XRFieldHasOriginalValues", funcs.AllOf(
-				funcs.ResourcesHaveFieldValueWithin(1*time.Minute, manifests, "xr.yaml", "spec.coolField", "Set by XR"),
-			)).
-			// Create a claim that explicitly asks to bind to the above XR.
-			Assess("CreateClaim", funcs.AllOf(
-				funcs.ApplyClaim(FieldManager, manifests, "claim.yaml"),
-				funcs.ResourcesCreatedWithin(30*time.Second, manifests, "claim.yaml"),
-				funcs.ResourcesHaveConditionWithin(5*time.Minute, manifests, "claim.yaml", xpv1.Available()),
-			)).
-			Assess("XRIsBoundToClaim", funcs.AllOf(
-				funcs.ResourcesHaveFieldValueWithin(1*time.Minute, manifests, "xr.yaml", "spec.claimRef.name", "bind-existing-xr"),
-			)).
-			Assess("XRFieldChangesToClaimValue", funcs.AllOf(
-				funcs.ResourcesHaveFieldValueWithin(1*time.Minute, manifests, "xr.yaml", "spec.coolField", "Set by claim"),
-			)).
-			WithTeardown("DeleteClaim", funcs.AllOf(
-				funcs.DeleteResources(manifests, "claim.yaml"),
-				funcs.ResourcesDeletedWithin(2*time.Minute, manifests, "claim.yaml"),
-
-				// Deleting the claim should delete the XR.
-				funcs.ResourcesDeletedWithin(2*time.Minute, manifests, "xr.yaml"),
-			)).
-			WithTeardown("DeletePrerequisites", funcs.ResourcesDeletedAfterListedAreGone(3*time.Minute, manifests, "setup/*.yaml", nopList)).
-			Feature(),
-	)
-}
-
 func TestCompositionValidation(t *testing.T) {
 	manifests := "test/e2e/manifests/apiextensions/composition/validation"
 
diff --git a/test/e2e/apiextensions_xrds_test.go b/test/e2e/apiextensions_xrds_test.go
index 55c2e41c5..6ec7a7a58 100644
--- a/test/e2e/apiextensions_xrds_test.go
+++ b/test/e2e/apiextensions_xrds_test.go
@@ -1,3 +1,19 @@
+/*
+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 (
diff --git a/test/e2e/funcs/feature.go b/test/e2e/funcs/feature.go
index c23c93e33..5abfbe61d 100644
--- a/test/e2e/funcs/feature.go
+++ b/test/e2e/funcs/feature.go
@@ -901,9 +901,10 @@ func ComposedResourcesHaveFieldValueWithin(d time.Duration, dir, file, path stri
 			return ctx
 		}
 
-		xrRef := cm.GetResourceReference()
-		uxr := &composite.Unstructured{}
+		// We always find this XR from a claim, so it'll always be legacy.
+		uxr := &composite.Unstructured{Schema: composite.SchemaLegacy}
 
+		xrRef := cm.GetResourceReference()
 		uxr.SetGroupVersionKind(xrRef.GroupVersionKind())
 		if err := c.Client().Resources().Get(ctx, xrRef.Name, "", uxr); err != nil {
 			t.Errorf("cannot get composite %s: %v", xrRef.Name, err)
diff --git a/test/e2e/manifests/apiextensions/composition/basic-cluster/setup/composition.yaml b/test/e2e/manifests/apiextensions/composition/basic-cluster/setup/composition.yaml
new file mode 100644
index 000000000..90aa441ac
--- /dev/null
+++ b/test/e2e/manifests/apiextensions/composition/basic-cluster/setup/composition.yaml
@@ -0,0 +1,62 @@
+apiVersion: apiextensions.crossplane.io/v1
+kind: Composition
+metadata:
+  name: basic-composition-cluster
+spec:
+  compositeTypeRef:
+    apiVersion: example.org/v1alpha1
+    kind: ClusterTest
+  mode: Pipeline
+  pipeline:
+  - step: be-a-dummy
+    functionRef:
+      name: function-dummy-ext-basic
+    input:
+      apiVersion: dummy.fn.crossplane.io/v1beta1
+      kind: Response
+      # This is a YAML-serialized RunFunctionResponse. function-dummy will
+      # overlay the desired state on any that was passed into it.
+      response:
+        desired:
+          composite:
+            resource:
+              status:
+                coolerField: "I'M COOLER!"
+            connectionDetails:
+              super: c2VjcmV0Cg==  # The string 'secret'
+          resources:
+            configmap:
+              resource:
+                apiVersion: v1
+                kind: ConfigMap
+                metadata:
+                  namespace: default
+                data:
+                  coolData: "I'm cool!"
+              ready: READY_TRUE
+            crd:
+              resource:
+                apiVersion: apiextensions.k8s.io/v1
+                kind: CustomResourceDefinition
+                metadata:
+                  name: controllers.test.example.org
+                spec:
+                  group: test.example.org
+                  names:
+                    kind: Controller
+                    plural: controllers
+                  scope: Cluster
+                  versions:
+                  - name: v1alpha1
+                    served: true
+                    storage: true
+              ready: READY_TRUE
+        results:
+         - severity: SEVERITY_NORMAL
+           message: "I am doing a compose!"
+    credentials:
+    - name: important-secret
+      source: Secret
+      secretRef:
+        namespace: crossplane-system
+        name: super-secret
diff --git a/test/e2e/manifests/apiextensions/composition/basic-cluster/setup/definition.yaml b/test/e2e/manifests/apiextensions/composition/basic-cluster/setup/definition.yaml
new file mode 100644
index 000000000..9d9f1918f
--- /dev/null
+++ b/test/e2e/manifests/apiextensions/composition/basic-cluster/setup/definition.yaml
@@ -0,0 +1,32 @@
+apiVersion: apiextensions.crossplane.io/v2alpha1
+kind: CompositeResourceDefinition
+metadata:
+  name: clustertests.example.org
+spec:
+  scope: Cluster
+  group: example.org
+  names:
+    kind: ClusterTest
+    plural: clustertests
+  connectionSecretKeys:
+  - super
+  versions:
+  - name: v1alpha1
+    served: true
+    referenceable: true
+    schema:
+     openAPIV3Schema:
+       type: object
+       properties:
+        spec:
+          type: object
+          properties:
+            coolField:
+              type: string
+          required:
+          - coolField
+        status:
+          type: object
+          properties:
+            coolerField:
+              type: string
diff --git a/test/e2e/manifests/apiextensions/composition/basic-cluster/setup/functions.yaml b/test/e2e/manifests/apiextensions/composition/basic-cluster/setup/functions.yaml
new file mode 100644
index 000000000..f37255b68
--- /dev/null
+++ b/test/e2e/manifests/apiextensions/composition/basic-cluster/setup/functions.yaml
@@ -0,0 +1,14 @@
+---
+# We intentionally use v1beta1 here, to make sure it still works.
+apiVersion: pkg.crossplane.io/v1beta1
+kind: Function
+metadata:
+  name: function-dummy-ext-basic
+spec:
+  # NOTE(negz): This is currently manually pushed. See README.md at
+  # https://github.com/crossplane-contrib/function-dummy.
+  # NOTE(negz): This version of the function uses meta.pkg.crossplane.io/v1beta1
+  # and is built with an old SDK that only serves v1beta1 RPCs. This is
+  # intentional. We want to make sure Crossplane is backward compatible with
+  # older v1beta1 functions.
+  package: xpkg.upbound.io/crossplane-contrib/function-dummy:v0.2.1
diff --git a/test/e2e/manifests/apiextensions/composition/functions/setup/secret.yaml b/test/e2e/manifests/apiextensions/composition/basic-cluster/setup/secret.yaml
similarity index 100%
rename from test/e2e/manifests/apiextensions/composition/functions/setup/secret.yaml
rename to test/e2e/manifests/apiextensions/composition/basic-cluster/setup/secret.yaml
diff --git a/test/e2e/manifests/apiextensions/composition/basic-cluster/xr.yaml b/test/e2e/manifests/apiextensions/composition/basic-cluster/xr.yaml
new file mode 100644
index 000000000..f0f597aa0
--- /dev/null
+++ b/test/e2e/manifests/apiextensions/composition/basic-cluster/xr.yaml
@@ -0,0 +1,12 @@
+apiVersion: example.org/v1alpha1
+kind: ClusterTest
+metadata:
+  name: basic-xr-cluster
+spec:
+  coolField: "I'm cool!"
+  crossplane:
+    compositionRef:
+      name: basic-composition-cluster
+    writeConnectionSecretToRef:
+      name: basic-secret-cluster
+      namespace: default
diff --git a/test/e2e/manifests/apiextensions/composition/basic-namespaced/setup/composition.yaml b/test/e2e/manifests/apiextensions/composition/basic-namespaced/setup/composition.yaml
new file mode 100644
index 000000000..2f82f4fa7
--- /dev/null
+++ b/test/e2e/manifests/apiextensions/composition/basic-namespaced/setup/composition.yaml
@@ -0,0 +1,43 @@
+apiVersion: apiextensions.crossplane.io/v1
+kind: Composition
+metadata:
+  name: basic-composition-namespaced
+spec:
+  compositeTypeRef:
+    apiVersion: example.org/v1alpha1
+    kind: Test
+  mode: Pipeline
+  pipeline:
+  - step: be-a-dummy
+    functionRef:
+      name: function-dummy-ext-basic
+    input:
+      apiVersion: dummy.fn.crossplane.io/v1beta1
+      kind: Response
+      # This is a YAML-serialized RunFunctionResponse. function-dummy will
+      # overlay the desired state on any that was passed into it.
+      response:
+        desired:
+          composite:
+            resource:
+              status:
+                coolerField: "I'M COOLER!"
+            connectionDetails:
+              super: c2VjcmV0Cg==  # The string 'secret'
+          resources:
+            configmap:
+              resource:
+                apiVersion: v1
+                kind: ConfigMap
+                data:
+                  coolData: "I'm cool!"
+              ready: READY_TRUE
+        results:
+         - severity: SEVERITY_NORMAL
+           message: "I am doing a compose!"
+    credentials:
+    - name: important-secret
+      source: Secret
+      secretRef:
+        namespace: crossplane-system
+        name: super-secret
diff --git a/test/e2e/manifests/apiextensions/composition/basic-namespaced/setup/definition.yaml b/test/e2e/manifests/apiextensions/composition/basic-namespaced/setup/definition.yaml
new file mode 100644
index 000000000..ec59f8896
--- /dev/null
+++ b/test/e2e/manifests/apiextensions/composition/basic-namespaced/setup/definition.yaml
@@ -0,0 +1,32 @@
+apiVersion: apiextensions.crossplane.io/v2alpha1
+kind: CompositeResourceDefinition
+metadata:
+  name: tests.example.org
+spec:
+  scope: Namespaced
+  group: example.org
+  names:
+    kind: Test
+    plural: tests
+  connectionSecretKeys:
+  - super
+  versions:
+  - name: v1alpha1
+    served: true
+    referenceable: true
+    schema:
+     openAPIV3Schema:
+       type: object
+       properties:
+        spec:
+          type: object
+          properties:
+            coolField:
+              type: string
+          required:
+          - coolField
+        status:
+          type: object
+          properties:
+            coolerField:
+              type: string
diff --git a/test/e2e/manifests/apiextensions/composition/basic-namespaced/setup/functions.yaml b/test/e2e/manifests/apiextensions/composition/basic-namespaced/setup/functions.yaml
new file mode 100644
index 000000000..f37255b68
--- /dev/null
+++ b/test/e2e/manifests/apiextensions/composition/basic-namespaced/setup/functions.yaml
@@ -0,0 +1,14 @@
+---
+# We intentionally use v1beta1 here, to make sure it still works.
+apiVersion: pkg.crossplane.io/v1beta1
+kind: Function
+metadata:
+  name: function-dummy-ext-basic
+spec:
+  # NOTE(negz): This is currently manually pushed. See README.md at
+  # https://github.com/crossplane-contrib/function-dummy.
+  # NOTE(negz): This version of the function uses meta.pkg.crossplane.io/v1beta1
+  # and is built with an old SDK that only serves v1beta1 RPCs. This is
+  # intentional. We want to make sure Crossplane is backward compatible with
+  # older v1beta1 functions.
+  package: xpkg.upbound.io/crossplane-contrib/function-dummy:v0.2.1
diff --git a/test/e2e/manifests/apiextensions/composition/basic-namespaced/setup/secret.yaml b/test/e2e/manifests/apiextensions/composition/basic-namespaced/setup/secret.yaml
new file mode 100644
index 000000000..22e00c9ac
--- /dev/null
+++ b/test/e2e/manifests/apiextensions/composition/basic-namespaced/setup/secret.yaml
@@ -0,0 +1,8 @@
+apiVersion: v1
+kind: Secret
+metadata:
+  namespace: crossplane-system
+  name: super-secret
+# We don't actually use the data, we just want to make sure we can load the
+# secret.
+data: {}
\ No newline at end of file
diff --git a/test/e2e/manifests/apiextensions/composition/basic-namespaced/xr.yaml b/test/e2e/manifests/apiextensions/composition/basic-namespaced/xr.yaml
new file mode 100644
index 000000000..82dfa114b
--- /dev/null
+++ b/test/e2e/manifests/apiextensions/composition/basic-namespaced/xr.yaml
@@ -0,0 +1,12 @@
+apiVersion: example.org/v1alpha1
+kind: Test
+metadata:
+  namespace: default
+  name: basic-xr-namespaced
+spec:
+  coolField: "I'm cool!"
+  crossplane:
+    compositionRef:
+      name: basic-composition-namespaced
+    writeConnectionSecretToRef:
+      name: basic-secret-namespaced
diff --git a/test/e2e/manifests/apiextensions/composition/bind-existing-xr/claim.yaml b/test/e2e/manifests/apiextensions/composition/legacy-bind-existing-xr/claim.yaml
similarity index 100%
rename from test/e2e/manifests/apiextensions/composition/bind-existing-xr/claim.yaml
rename to test/e2e/manifests/apiextensions/composition/legacy-bind-existing-xr/claim.yaml
diff --git a/test/e2e/manifests/apiextensions/composition/bind-existing-xr/setup/composition.yaml b/test/e2e/manifests/apiextensions/composition/legacy-bind-existing-xr/setup/composition.yaml
similarity index 100%
rename from test/e2e/manifests/apiextensions/composition/bind-existing-xr/setup/composition.yaml
rename to test/e2e/manifests/apiextensions/composition/legacy-bind-existing-xr/setup/composition.yaml
diff --git a/test/e2e/manifests/apiextensions/composition/bind-existing-xr/setup/definition.yaml b/test/e2e/manifests/apiextensions/composition/legacy-bind-existing-xr/setup/definition.yaml
similarity index 100%
rename from test/e2e/manifests/apiextensions/composition/bind-existing-xr/setup/definition.yaml
rename to test/e2e/manifests/apiextensions/composition/legacy-bind-existing-xr/setup/definition.yaml
diff --git a/test/e2e/manifests/apiextensions/composition/bind-existing-xr/setup/functions.yaml b/test/e2e/manifests/apiextensions/composition/legacy-bind-existing-xr/setup/functions.yaml
similarity index 100%
rename from test/e2e/manifests/apiextensions/composition/bind-existing-xr/setup/functions.yaml
rename to test/e2e/manifests/apiextensions/composition/legacy-bind-existing-xr/setup/functions.yaml
diff --git a/test/e2e/manifests/apiextensions/composition/bind-existing-xr/xr.yaml b/test/e2e/manifests/apiextensions/composition/legacy-bind-existing-xr/xr.yaml
similarity index 100%
rename from test/e2e/manifests/apiextensions/composition/bind-existing-xr/xr.yaml
rename to test/e2e/manifests/apiextensions/composition/legacy-bind-existing-xr/xr.yaml
diff --git a/test/e2e/manifests/apiextensions/composition/propagate-field-removals/claim-update.yaml b/test/e2e/manifests/apiextensions/composition/legacy-propagate-field-removals/claim-update.yaml
similarity index 100%
rename from test/e2e/manifests/apiextensions/composition/propagate-field-removals/claim-update.yaml
rename to test/e2e/manifests/apiextensions/composition/legacy-propagate-field-removals/claim-update.yaml
diff --git a/test/e2e/manifests/apiextensions/composition/propagate-field-removals/claim.yaml b/test/e2e/manifests/apiextensions/composition/legacy-propagate-field-removals/claim.yaml
similarity index 100%
rename from test/e2e/manifests/apiextensions/composition/propagate-field-removals/claim.yaml
rename to test/e2e/manifests/apiextensions/composition/legacy-propagate-field-removals/claim.yaml
diff --git a/test/e2e/manifests/apiextensions/composition/propagate-field-removals/setup/composition.yaml b/test/e2e/manifests/apiextensions/composition/legacy-propagate-field-removals/setup/composition.yaml
similarity index 100%
rename from test/e2e/manifests/apiextensions/composition/propagate-field-removals/setup/composition.yaml
rename to test/e2e/manifests/apiextensions/composition/legacy-propagate-field-removals/setup/composition.yaml
diff --git a/test/e2e/manifests/apiextensions/composition/propagate-field-removals/setup/definition.yaml b/test/e2e/manifests/apiextensions/composition/legacy-propagate-field-removals/setup/definition.yaml
similarity index 100%
rename from test/e2e/manifests/apiextensions/composition/propagate-field-removals/setup/definition.yaml
rename to test/e2e/manifests/apiextensions/composition/legacy-propagate-field-removals/setup/definition.yaml
diff --git a/test/e2e/manifests/apiextensions/composition/propagate-field-removals/setup/functions.yaml b/test/e2e/manifests/apiextensions/composition/legacy-propagate-field-removals/setup/functions.yaml
similarity index 100%
rename from test/e2e/manifests/apiextensions/composition/propagate-field-removals/setup/functions.yaml
rename to test/e2e/manifests/apiextensions/composition/legacy-propagate-field-removals/setup/functions.yaml
diff --git a/test/e2e/manifests/apiextensions/composition/functions/claim.yaml b/test/e2e/manifests/apiextensions/composition/legacy/claim.yaml
similarity index 100%
rename from test/e2e/manifests/apiextensions/composition/functions/claim.yaml
rename to test/e2e/manifests/apiextensions/composition/legacy/claim.yaml
diff --git a/test/e2e/manifests/apiextensions/composition/functions/setup/composition-nested.yaml b/test/e2e/manifests/apiextensions/composition/legacy/setup/composition-nested.yaml
similarity index 100%
rename from test/e2e/manifests/apiextensions/composition/functions/setup/composition-nested.yaml
rename to test/e2e/manifests/apiextensions/composition/legacy/setup/composition-nested.yaml
diff --git a/test/e2e/manifests/apiextensions/composition/functions/setup/composition.yaml b/test/e2e/manifests/apiextensions/composition/legacy/setup/composition.yaml
similarity index 100%
rename from test/e2e/manifests/apiextensions/composition/functions/setup/composition.yaml
rename to test/e2e/manifests/apiextensions/composition/legacy/setup/composition.yaml
diff --git a/test/e2e/manifests/apiextensions/composition/functions/setup/definition.yaml b/test/e2e/manifests/apiextensions/composition/legacy/setup/definition.yaml
similarity index 100%
rename from test/e2e/manifests/apiextensions/composition/functions/setup/definition.yaml
rename to test/e2e/manifests/apiextensions/composition/legacy/setup/definition.yaml
diff --git a/test/e2e/manifests/apiextensions/composition/functions/setup/functions.yaml b/test/e2e/manifests/apiextensions/composition/legacy/setup/functions.yaml
similarity index 100%
rename from test/e2e/manifests/apiextensions/composition/functions/setup/functions.yaml
rename to test/e2e/manifests/apiextensions/composition/legacy/setup/functions.yaml
diff --git a/test/e2e/manifests/apiextensions/composition/functions/setup/provider.yaml b/test/e2e/manifests/apiextensions/composition/legacy/setup/provider.yaml
similarity index 100%
rename from test/e2e/manifests/apiextensions/composition/functions/setup/provider.yaml
rename to test/e2e/manifests/apiextensions/composition/legacy/setup/provider.yaml
diff --git a/test/e2e/manifests/apiextensions/composition/legacy/setup/secret.yaml b/test/e2e/manifests/apiextensions/composition/legacy/setup/secret.yaml
new file mode 100644
index 000000000..22e00c9ac
--- /dev/null
+++ b/test/e2e/manifests/apiextensions/composition/legacy/setup/secret.yaml
@@ -0,0 +1,8 @@
+apiVersion: v1
+kind: Secret
+metadata:
+  namespace: crossplane-system
+  name: super-secret
+# We don't actually use the data, we just want to make sure we can load the
+# secret.
+data: {}
\ No newline at end of file