Repository: GoogleContainerTools/kpt-config-sync
Pull request #1626: feat: unmanage orphaned objects during RSync deletion
Author: login: sdowell
Labels: name: lgtm
name: approved
name: size/XL
Created: 2025-04-02T17:29:38Z | Merged: 2025-04-09T18:19:04Z

Previously when a RSync was deleted with the Orphan deletion propagation policy (default), all managed objects were left unchanged. This includes all of the Config Sync specific metadata injected into managed objects.

Some of this lingering metadata can prevent objects from being adopted by another RepoSync, since RepoSyncs use PolicyAdoptIfNoInventory. If the admission-webhook is enabled, the lingering metadata prevents any client from updating/deleting the object and prevents and RSync from adopting the object.

This change ensures that orphaned objects are treated as unmanaged by Config Sync so that they can be updated/deleted or adopted by a new RSync.


Diff:
diff --git a/e2e/nomostest/config_sync.go b/e2e/nomostest/config_sync.go
index a38b660148..a480e6ae8b 100644
--- a/e2e/nomostest/config_sync.go
+++ b/e2e/nomostest/config_sync.go
@@ -745,7 +745,11 @@ func SetRSyncTestDefaults(nt *NT, obj client.Object) {
 	// Set global defaults
 	// Enable automatic deletion of managed objects by default.
 	// This helps ensure that test artifacts are cleaned up.
-	EnableDeletionPropagation(obj)
+	if nt.deletionPropagationPolicy == nil {
+		RemoveDeletionPropagationPolicy(obj)
+	} else {
+		SetDeletionPropagationPolicy(obj, *nt.deletionPropagationPolicy)
+	}
 }
 
 // rootSyncObjectV1Alpha1Git returns the default RootSync object.
diff --git a/e2e/nomostest/new.go b/e2e/nomostest/new.go
index c7be1efd35..5fd516e3fa 100644
--- a/e2e/nomostest/new.go
+++ b/e2e/nomostest/new.go
@@ -41,6 +41,7 @@ import (
 	"kpt.dev/configsync/pkg/core"
 	"kpt.dev/configsync/pkg/core/k8sobjects"
 	"kpt.dev/configsync/pkg/kinds"
+	"kpt.dev/configsync/pkg/metadata"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/yaml"
 )
@@ -68,6 +69,8 @@ func newOptStruct(testName, tmpDir string, ntOptions ...ntopts.Opt) *ntopts.New
 			// Default to 1m to keep tests fast.
 			// To override, use WithReconcileTimeout.
 			ReconcileTimeout: ptr.To(1 * time.Minute),
+			// Default to foreground to clean up test resources
+			DeletionPropagationPolicy: ptr.To(metadata.DeletionPropagationPolicyForeground),
 		},
 	}
 	for _, opt := range ntOptions {
@@ -148,40 +151,41 @@ func SharedTestEnv(t nomostesting.NTB, opts *ntopts.New) *NT {
 	sharedNt.Logger.SetNTBForTest(t)
 
 	nt := &NT{
-		Context:                 sharedNt.Context,
-		T:                       t,
-		Logger:                  sharedNt.Logger,
-		Shell:                   sharedNt.Shell,
-		ClusterName:             sharedNt.ClusterName,
-		ClusterHash:             sharedNt.ClusterHash,
-		TmpDir:                  opts.TmpDir,
-		Config:                  sharedNt.Config,
-		repoSyncPermissions:     opts.RepoSyncPermissions,
-		KubeClient:              sharedNt.KubeClient,
-		Watcher:                 sharedNt.Watcher,
-		WatchClient:             sharedNt.WatchClient,
-		IsGKEAutopilot:          sharedNt.IsGKEAutopilot,
-		ClusterVersion:          sharedNt.ClusterVersion,
-		ClusterSupportsBursting: sharedNt.ClusterSupportsBursting,
-		DefaultWaitTimeout:      sharedNt.DefaultWaitTimeout,
-		DefaultReconcileTimeout: opts.ReconcileTimeout,
-		kubeconfigPath:          sharedNt.kubeconfigPath,
-		ReconcilerPollingPeriod: sharedNt.ReconcilerPollingPeriod,
-		HydrationPollingPeriod:  sharedNt.HydrationPollingPeriod,
-		SyncSources:             make(map[core.ID]syncsource.SyncSource),
-		MetricsExpectations:     sharedNt.MetricsExpectations,
-		gitPrivateKeyPath:       sharedNt.gitPrivateKeyPath,
-		gitCACertPath:           sharedNt.gitCACertPath,
-		registryCACertPath:      sharedNt.registryCACertPath,
-		Scheme:                  sharedNt.Scheme,
-		RemoteRepositories:      sharedNt.RemoteRepositories,
-		WebhookDisabled:         sharedNt.WebhookDisabled,
-		GitProvider:             sharedNt.GitProvider,
-		OCIProvider:             sharedNt.OCIProvider,
-		HelmProvider:            sharedNt.HelmProvider,
-		HelmClient:              sharedNt.HelmClient,
-		OCIClient:               sharedNt.OCIClient,
-		testStartTime:           time.Now(),
+		Context:                   sharedNt.Context,
+		T:                         t,
+		Logger:                    sharedNt.Logger,
+		Shell:                     sharedNt.Shell,
+		ClusterName:               sharedNt.ClusterName,
+		ClusterHash:               sharedNt.ClusterHash,
+		TmpDir:                    opts.TmpDir,
+		Config:                    sharedNt.Config,
+		repoSyncPermissions:       opts.RepoSyncPermissions,
+		KubeClient:                sharedNt.KubeClient,
+		Watcher:                   sharedNt.Watcher,
+		WatchClient:               sharedNt.WatchClient,
+		IsGKEAutopilot:            sharedNt.IsGKEAutopilot,
+		ClusterVersion:            sharedNt.ClusterVersion,
+		ClusterSupportsBursting:   sharedNt.ClusterSupportsBursting,
+		DefaultWaitTimeout:        sharedNt.DefaultWaitTimeout,
+		DefaultReconcileTimeout:   opts.ReconcileTimeout,
+		kubeconfigPath:            sharedNt.kubeconfigPath,
+		ReconcilerPollingPeriod:   sharedNt.ReconcilerPollingPeriod,
+		HydrationPollingPeriod:    sharedNt.HydrationPollingPeriod,
+		SyncSources:               make(map[core.ID]syncsource.SyncSource),
+		MetricsExpectations:       sharedNt.MetricsExpectations,
+		gitPrivateKeyPath:         sharedNt.gitPrivateKeyPath,
+		gitCACertPath:             sharedNt.gitCACertPath,
+		registryCACertPath:        sharedNt.registryCACertPath,
+		Scheme:                    sharedNt.Scheme,
+		RemoteRepositories:        sharedNt.RemoteRepositories,
+		WebhookDisabled:           sharedNt.WebhookDisabled,
+		GitProvider:               sharedNt.GitProvider,
+		OCIProvider:               sharedNt.OCIProvider,
+		HelmProvider:              sharedNt.HelmProvider,
+		HelmClient:                sharedNt.HelmClient,
+		OCIClient:                 sharedNt.OCIClient,
+		testStartTime:             time.Now(),
+		deletionPropagationPolicy: opts.DeletionPropagationPolicy,
 	}
 
 	t.Logf("using shared test env: %s, cluster version: %s, cluster hash: %s, support bursting: %t", nt.ClusterName, nt.ClusterVersion, nt.ClusterHash, nt.ClusterSupportsBursting)
@@ -254,28 +258,29 @@ func FreshTestEnv(t nomostesting.NTB, opts *ntopts.New) *NT {
 
 	webhookDisabled := false
 	nt := &NT{
-		Context:                 ctx,
-		T:                       t,
-		Logger:                  logger,
-		Shell:                   shell,
-		ClusterName:             opts.ClusterName,
-		ClusterHash:             opts.ClusterHash,
-		TmpDir:                  opts.TmpDir,
-		Config:                  opts.RESTConfig,
-		repoSyncPermissions:     opts.RepoSyncPermissions,
-		DefaultReconcileTimeout: opts.ReconcileTimeout,
-		kubeconfigPath:          opts.KubeconfigPath,
-		SyncSources:             make(map[core.ID]syncsource.SyncSource),
-		MetricsExpectations:     testmetrics.NewSyncSetExpectations(t, scheme),
-		Scheme:                  scheme,
-		RemoteRepositories:      make(map[types.NamespacedName]*gitproviders.ReadWriteRepository),
-		WebhookDisabled:         &webhookDisabled,
-		GitProvider:             gitproviders.NewGitProvider(t, *e2e.GitProvider, opts.ClusterName, logger, shell),
-		OCIProvider:             registryproviders.NewOCIProvider(*e2e.OCIProvider, opts.ClusterName, shell, ociClient),
-		HelmProvider:            registryproviders.NewHelmProvider(*e2e.HelmProvider, opts.ClusterName, shell, helmClient),
-		HelmClient:              helmClient,
-		OCIClient:               ociClient,
-		testStartTime:           time.Now(),
+		Context:                   ctx,
+		T:                         t,
+		Logger:                    logger,
+		Shell:                     shell,
+		ClusterName:               opts.ClusterName,
+		ClusterHash:               opts.ClusterHash,
+		TmpDir:                    opts.TmpDir,
+		Config:                    opts.RESTConfig,
+		repoSyncPermissions:       opts.RepoSyncPermissions,
+		DefaultReconcileTimeout:   opts.ReconcileTimeout,
+		kubeconfigPath:            opts.KubeconfigPath,
+		SyncSources:               make(map[core.ID]syncsource.SyncSource),
+		MetricsExpectations:       testmetrics.NewSyncSetExpectations(t, scheme),
+		Scheme:                    scheme,
+		RemoteRepositories:        make(map[types.NamespacedName]*gitproviders.ReadWriteRepository),
+		WebhookDisabled:           &webhookDisabled,
+		GitProvider:               gitproviders.NewGitProvider(t, *e2e.GitProvider, opts.ClusterName, logger, shell),
+		OCIProvider:               registryproviders.NewOCIProvider(*e2e.OCIProvider, opts.ClusterName, shell, ociClient),
+		HelmProvider:              registryproviders.NewHelmProvider(*e2e.HelmProvider, opts.ClusterName, shell, helmClient),
+		HelmClient:                helmClient,
+		OCIClient:                 ociClient,
+		testStartTime:             time.Now(),
+		deletionPropagationPolicy: opts.DeletionPropagationPolicy,
 	}
 
 	nt.detectClusterVersion()
diff --git a/e2e/nomostest/nt.go b/e2e/nomostest/nt.go
index b7dea0228d..5d4b5766b1 100644
--- a/e2e/nomostest/nt.go
+++ b/e2e/nomostest/nt.go
@@ -55,6 +55,7 @@ import (
 	"kpt.dev/configsync/pkg/api/configsync/v1beta1"
 	"kpt.dev/configsync/pkg/core"
 	"kpt.dev/configsync/pkg/core/k8sobjects"
+	"kpt.dev/configsync/pkg/metadata"
 	ocmetrics "kpt.dev/configsync/pkg/metrics"
 	"kpt.dev/configsync/pkg/reconcilermanager"
 	"kpt.dev/configsync/pkg/reconcilermanager/controllers"
@@ -206,6 +207,10 @@ type NT struct {
 
 	// testStartTime is a timestamp which records when the current test started
 	testStartTime time.Time
+
+	// deletionPropagationPolicy is the deletion propagation policy to use for all
+	// RSyncs. Defaults to Foreground to clean up test resources.
+	deletionPropagationPolicy *metadata.DeletionPropagationPolicy
 }
 
 // Must not error.
diff --git a/e2e/nomostest/ntopts/multi_repo.go b/e2e/nomostest/ntopts/multi_repo.go
index 46845c84a1..b1328749f8 100644
--- a/e2e/nomostest/ntopts/multi_repo.go
+++ b/e2e/nomostest/ntopts/multi_repo.go
@@ -21,6 +21,7 @@ import (
 	"kpt.dev/configsync/e2e/nomostest/syncsource"
 	"kpt.dev/configsync/pkg/api/configsync"
 	"kpt.dev/configsync/pkg/core"
+	"kpt.dev/configsync/pkg/metadata"
 )
 
 // MultiRepo configures the NT for use with multi-repo tests.
@@ -55,6 +56,9 @@ type MultiRepo struct {
 
 	// RepoSyncPermissions will grant a list of PolicyRules to NS reconcilers
 	RepoSyncPermissions []rbacv1.PolicyRule
+
+	// DeletionPropagationPolicy sets the deletion propagation policy for all RSyncs
+	DeletionPropagationPolicy *metadata.DeletionPropagationPolicy
 }
 
 // GitSourceOption mutates a GitSyncSource
@@ -152,3 +156,18 @@ func RepoSyncPermissions(policy ...rbacv1.PolicyRule) Opt {
 		opt.RepoSyncPermissions = append(opt.RepoSyncPermissions, policy...)
 	}
 }
+
+// WithDeletionPropagationPolicy specifies the DeletionPropagationPolicy
+func WithDeletionPropagationPolicy(policy metadata.DeletionPropagationPolicy) Opt {
+	return func(opt *New) {
+		opt.DeletionPropagationPolicy = &policy
+	}
+}
+
+// WithoutDeletionPropagationPolicy specifies no DeletionPropagationPolicy
+// No annotation will be added to RSyncs, causing the default policy to be used ("Orphan")
+func WithoutDeletionPropagationPolicy() Opt {
+	return func(opt *New) {
+		opt.DeletionPropagationPolicy = nil
+	}
+}
diff --git a/e2e/testcases/reconciler_finalizer_test.go b/e2e/testcases/reconciler_finalizer_test.go
index 6da07b01c3..d7b8b5ea23 100644
--- a/e2e/testcases/reconciler_finalizer_test.go
+++ b/e2e/testcases/reconciler_finalizer_test.go
@@ -94,7 +94,6 @@ func TestReconcilerFinalizer_Orphan(t *testing.T) {
 	nt.T.Log("Disabling RootSync deletion propagation")
 	rootSync := &v1beta1.RootSync{}
 	nt.Must(nt.KubeClient.Get(rootSyncID.Name, rootSyncID.Namespace, rootSync))
-	applySetID := core.GetLabel(rootSync, metadata.ApplySetParentIDLabel)
 
 	if nomostest.SetDeletionPropagationPolicy(rootSync, metadata.DeletionPropagationPolicyOrphan) {
 		nt.Must(nt.KubeClient.Update(rootSync))
@@ -102,7 +101,9 @@ func TestReconcilerFinalizer_Orphan(t *testing.T) {
 	nt.Must(nt.Watcher.WatchObject(kinds.RootSyncV1Beta1(), rootSync.GetName(), rootSync.GetNamespace(),
 		testwatcher.WatchPredicates(
 			testpredicates.StatusEquals(nt.Scheme, kstatus.CurrentStatus),
-			testpredicates.MissingFinalizer(metadata.ReconcilerFinalizer),
+			testpredicates.HasFinalizer(metadata.ReconcilerFinalizer),
+			testpredicates.HasAnnotation(metadata.DeletionPropagationPolicyAnnotationKey,
+				string(metadata.DeletionPropagationPolicyOrphan)),
 		)))
 
 	// Delete the RootSync
@@ -116,16 +117,14 @@ func TestReconcilerFinalizer_Orphan(t *testing.T) {
 		// Namespace1 should NOT have been deleted, because it was orphaned by the RootSync.
 		return nt.Watcher.WatchObject(kinds.Namespace(), namespace1.GetName(), namespace1.GetNamespace(),
 			testwatcher.WatchPredicates(
-				testpredicates.HasAllNomosMetadata(), // metadata NOT removed when orphaned
-				testpredicates.HasLabel(metadata.ApplySetPartOfLabel, applySetID),
+				testpredicates.NoConfigSyncMetadata(),
 			))
 	})
 	tg.Go(func() error {
 		// Deployment1 should NOT have been deleted, because it was orphaned by the RootSync.
 		return nt.Watcher.WatchObject(kinds.Deployment(), deployment1.GetName(), deployment1.GetNamespace(),
 			testwatcher.WatchPredicates(
-				testpredicates.HasAllNomosMetadata(), // metadata NOT removed when orphaned
-				testpredicates.HasLabel(metadata.ApplySetPartOfLabel, applySetID),
+				testpredicates.NoConfigSyncMetadata(),
 			))
 	})
 	tg.Go(func() error {
@@ -443,12 +442,12 @@ func TestReconcilerFinalizer_MultiLevelMixed(t *testing.T) {
 	nt.Must(nt.Watcher.WatchObject(kinds.RepoSyncV1Beta1(), repoSync.GetName(), repoSync.GetNamespace(),
 		testwatcher.WatchPredicates(
 			testpredicates.StatusEquals(nt.Scheme, kstatus.CurrentStatus),
-			testpredicates.MissingFinalizer(metadata.ReconcilerFinalizer),
+			testpredicates.HasFinalizer(metadata.ReconcilerFinalizer),
+			testpredicates.MissingAnnotation(metadata.DeletionPropagationPolicyAnnotationKey),
 		)))
 
 	repoSync = &v1beta1.RepoSync{}
 	nt.Must(nt.KubeClient.Get(repoSyncID.Name, repoSyncID.Namespace, repoSync))
-	repoSyncApplySetID := core.GetLabel(repoSync, metadata.ApplySetParentIDLabel)
 
 	// Abandon the test namespace, otherwise it will block the finalizer
 	namespace1Path := nomostest.StructuredNSPath(namespace1NN.Name, namespace1NN.Name)
@@ -492,8 +491,7 @@ func TestReconcilerFinalizer_MultiLevelMixed(t *testing.T) {
 		// Deployment2 should NOT have been deleted, because it was orphaned by the RepoSync.
 		return nt.Watcher.WatchObject(kinds.Deployment(), deployment2.GetName(), deployment2.GetNamespace(),
 			testwatcher.WatchPredicates(
-				testpredicates.HasAllNomosMetadata(), // metadata NOT removed when orphaned
-				testpredicates.HasLabel(metadata.ApplySetPartOfLabel, repoSyncApplySetID),
+				testpredicates.NoConfigSyncMetadata(),
 			))
 	})
 	nt.Must(tg.Wait())
diff --git a/e2e/testcases/sync_ordering_test.go b/e2e/testcases/sync_ordering_test.go
index b90b8820b2..f39206e380 100644
--- a/e2e/testcases/sync_ordering_test.go
+++ b/e2e/testcases/sync_ordering_test.go
@@ -22,10 +22,13 @@ import (
 
 	"go.uber.org/multierr"
 	corev1 "k8s.io/api/core/v1"
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/util/intstr"
 	"kpt.dev/configsync/e2e/nomostest"
 	"kpt.dev/configsync/e2e/nomostest/ntopts"
+	"kpt.dev/configsync/e2e/nomostest/policy"
 	"kpt.dev/configsync/e2e/nomostest/taskgroup"
 	nomostesting "kpt.dev/configsync/e2e/nomostest/testing"
 	"kpt.dev/configsync/e2e/nomostest/testpredicates"
@@ -35,6 +38,7 @@ import (
 	"kpt.dev/configsync/pkg/applier"
 	"kpt.dev/configsync/pkg/core"
 	"kpt.dev/configsync/pkg/core/k8sobjects"
+	"kpt.dev/configsync/pkg/declared"
 	"kpt.dev/configsync/pkg/kinds"
 	"kpt.dev/configsync/pkg/metadata"
 	kstatus "sigs.k8s.io/cli-utils/pkg/kstatus/status"
@@ -662,6 +666,114 @@ func TestDependencyWithReconciliation(t *testing.T) {
 	}
 }
 
+func verifyManagedBy(nt *nomostest.NT, o client.Object, rsync core.ID) error {
+	return nt.Validate(o.GetName(), o.GetNamespace(), o,
+		testpredicates.IsManagedBy(nt.Scheme, declared.ScopeFromSyncNamespace(rsync.Namespace), rsync.Name))
+}
+
+func verifyUnmanaged(nt *nomostest.NT, o client.Object) error {
+	return nt.Validate(o.GetName(), o.GetNamespace(), o,
+		testpredicates.NoConfigSyncMetadata())
+}
+
+func TestSplitRSyncsWithDeletion(t *testing.T) {
+	ns1 := func() *corev1.Namespace {
+		return k8sobjects.NamespaceObject("ns1")
+	}
+	anvilCRD := func() *apiextensionsv1.CustomResourceDefinition {
+		return anvilV1CRD()
+	}
+	anvilCR := func() *unstructured.Unstructured {
+		o := newAnvilObject("v1", "anvil", 42)
+		o.SetNamespace("foo")
+		return o
+	}
+	tenantNS1 := "tenant-ns-1"
+	tenantNS2 := "tenant-ns-2"
+	cm1 := func() *corev1.ConfigMap {
+		return k8sobjects.ConfigMapObject(core.Name("cm1"), core.Namespace(tenantNS1))
+	}
+	sa1 := func() *corev1.ServiceAccount {
+		return k8sobjects.ServiceAccountObject("sa1", core.Namespace(tenantNS2))
+	}
+	rootSync := nomostest.DefaultRootSyncID
+	rootSync1 := core.RootSyncID("root-sync-1")
+	rootSync2 := core.RootSyncID("root-sync-2")
+	repoSync1 := core.RepoSyncID("repo-sync-1", tenantNS1)
+	repoSync2 := core.RepoSyncID("repo-sync-2", tenantNS2)
+	nt := nomostest.New(t, nomostesting.Lifecycle,
+		ntopts.SyncWithGitSource(rootSync, ntopts.Unstructured),
+		ntopts.SyncWithGitSource(rootSync1, ntopts.Unstructured),
+		ntopts.SyncWithGitSource(rootSync2, ntopts.Unstructured),
+		ntopts.SyncWithGitSource(repoSync1, ntopts.Unstructured),
+		ntopts.SyncWithGitSource(repoSync2, ntopts.Unstructured),
+		ntopts.WithDelegatedControl,
+		ntopts.WithoutDeletionPropagationPolicy(),
+		ntopts.RepoSyncPermissions(policy.CoreAdmin()))
+
+	nt.T.Log("Add all resources to a single RootSync")
+	rootSyncGitRepo := nt.SyncSourceGitReadWriteRepository(rootSync)
+	nt.Must(rootSyncGitRepo.Add("acme/ns1.yaml", ns1()))
+	nt.Must(rootSyncGitRepo.Add("acme/anvil_crd.yaml", anvilCRD()))
+	nt.Must(rootSyncGitRepo.Add("acme/anvil_cr.yaml", anvilCR()))
+	nt.Must(rootSyncGitRepo.Add("acme/cm1.yaml", cm1()))
+	nt.Must(rootSyncGitRepo.Add("acme/sa1.yaml", sa1()))
+	nt.Must(rootSyncGitRepo.CommitAndPush("Add objects"))
+	nt.Must(nt.WatchForAllSyncs())
+	// Validate metadata for each object
+	nt.Must(verifyManagedBy(nt, ns1(), rootSync))
+	nt.Must(verifyManagedBy(nt, anvilCRD(), rootSync))
+	nt.Must(verifyManagedBy(nt, anvilCR(), rootSync))
+	nt.Must(verifyManagedBy(nt, cm1(), rootSync))
+	nt.Must(verifyManagedBy(nt, sa1(), rootSync))
+
+	nt.T.Cleanup(func() {
+		rs := nomostest.RootSyncObjectV1Beta1FromRootRepo(nt, rootSync.Name)
+		nt.Must(nt.KubeClient.Apply(rs))
+		nt.Must(nt.WatchForAllSyncs())
+	})
+	nt.T.Log("Delete the single RootSync to orphan all resources")
+	nt.Must(nt.KubeClient.Delete(k8sobjects.RootSyncV1Beta1(rootSync.Name)))
+	nt.Must(nt.Watcher.WatchForNotFound(kinds.RootSyncV1Beta1(), rootSync.Name, rootSync.Namespace))
+	nt.Must(verifyUnmanaged(nt, ns1()))
+	nt.Must(verifyUnmanaged(nt, anvilCRD()))
+	nt.Must(verifyUnmanaged(nt, anvilCR()))
+	nt.Must(verifyUnmanaged(nt, cm1()))
+	nt.Must(verifyUnmanaged(nt, sa1()))
+
+	nt.T.Cleanup(func() {
+		// Remove objects from the original RSync for cleanup
+		repo := nt.SyncSourceGitReadWriteRepository(rootSync)
+		nt.Must(repo.Remove("acme/ns1.yaml"))
+		nt.Must(repo.Remove("acme/anvil_crd.yaml"))
+		nt.Must(repo.Remove("acme/anvil_cr.yaml"))
+		nt.Must(repo.Remove("acme/cm1.yaml"))
+		nt.Must(repo.Remove("acme/sa1.yaml"))
+		nt.Must(repo.CommitAndPush("Remove orphaned objects"))
+	})
+	nt.T.Log("Adopt each resource by various RootSyncs/RepoSyncs")
+	repo := nt.SyncSourceGitReadWriteRepository(rootSync1)
+	nt.Must(repo.Add("acme/ns1.yaml", ns1()))
+	nt.Must(repo.CommitAndPush("Take ownership of ns1 in root-sync-1"))
+	repo = nt.SyncSourceGitReadWriteRepository(rootSync2)
+	nt.Must(repo.Add("acme/anvil_crd.yaml", anvilCRD()))
+	nt.Must(repo.Add("acme/anvil_cr.yaml", anvilCR()))
+	nt.Must(repo.CommitAndPush("Take ownership of anvil in root-sync-2"))
+	repo = nt.SyncSourceGitReadWriteRepository(repoSync1)
+	nt.Must(repo.Add("acme/cm1.yaml", cm1()))
+	nt.Must(repo.CommitAndPush("Take ownership of cm1 in repo-sync-1"))
+	repo = nt.SyncSourceGitReadWriteRepository(repoSync2)
+	nt.Must(repo.Add("acme/sa1.yaml", sa1()))
+	nt.Must(repo.CommitAndPush("Take ownership of sa1 in repo-sync-2"))
+	// Ready check recreates the root-sync, which we don't want
+	nt.Must(nt.WatchForAllSyncs(nomostest.SkipReadyCheck(), nomostest.SkipRootRepos(rootSync.Name)))
+	nt.Must(verifyManagedBy(nt, ns1(), rootSync1))
+	nt.Must(verifyManagedBy(nt, anvilCRD(), rootSync2))
+	nt.Must(verifyManagedBy(nt, anvilCR(), rootSync2))
+	nt.Must(verifyManagedBy(nt, cm1(), repoSync1))
+	nt.Must(verifyManagedBy(nt, sa1(), repoSync2))
+}
+
 func getPodReadyTimestamp(pod *corev1.Pod) *metav1.Time {
 	for _, condition := range pod.Status.Conditions {
 		if condition.Type == "Ready" {
diff --git a/pkg/metadata/annotations.go b/pkg/metadata/annotations.go
index 66c5929f71..33d1d30d16 100644
--- a/pkg/metadata/annotations.go
+++ b/pkg/metadata/annotations.go
@@ -210,6 +210,12 @@ const FleetWorkloadIdentityCredentials = "config.kubernetes.io/fleet-workload-id
 // with the deletion-propagation-policy annotation.
 type DeletionPropagationPolicy string
 
+// String returns the string value of the DeletionPropagationPolicy.
+// Implements the Stringer interface.
+func (p DeletionPropagationPolicy) String() string {
+	return string(p)
+}
+
 const (
 	// DeletionPropagationPolicyForeground indicates that the managed resources
 	// should all be deleted/pruned before the RootSync/RepoSync object is deleted.
diff --git a/pkg/metadata/metadata.go b/pkg/metadata/metadata.go
index 1ca12f0da3..3d39a664e1 100644
--- a/pkg/metadata/metadata.go
+++ b/pkg/metadata/metadata.go
@@ -111,6 +111,37 @@ func HasConfigSyncMetadata(obj client.Object) bool {
 	return false
 }
 
+// ConfigSyncMetadata contains fields needed to set all Config Sync metadata on
+// a managed resource.
+type ConfigSyncMetadata struct {
+	// ApplySetID is the label value to set for ApplySetPartOfLabel
+	ApplySetID string
+	// GitContextValue is annotation the value to set for GitContextKey
+	GitContextValue string
+	// ManagerValue is the annotation value to set for ResourceManagerKey
+	ManagerValue string
+	// SourceHash is the annotation value to set for SyncTokenAnnotationKey
+	SourceHash string
+	// InventoryID is the annotation value to set for OwningInventoryKey
+	InventoryID string
+}
+
+// SetConfigSyncMetadata sets Config Sync metadata, including both Config Sync
+// annotations and labels, on the given resource.
+func (csm *ConfigSyncMetadata) SetConfigSyncMetadata(obj client.Object) {
+	core.SetLabel(obj, ManagedByKey, ManagedByValue)
+	core.SetLabel(obj, ApplySetPartOfLabel, csm.ApplySetID)
+	core.SetAnnotation(obj, GitContextKey, csm.GitContextValue)
+	core.SetAnnotation(obj, ResourceManagerKey, csm.ManagerValue)
+	core.SetAnnotation(obj, SyncTokenAnnotationKey, csm.SourceHash)
+	core.SetAnnotation(obj, ResourceIDKey, core.GKNN(obj))
+	core.SetAnnotation(obj, OwningInventoryKey, csm.InventoryID)
+
+	if !IsManagementDisabled(obj) {
+		core.SetAnnotation(obj, ManagementModeAnnotationKey, ManagementEnabled.String())
+	}
+}
+
 // RemoveConfigSyncMetadata removes the Config Sync metadata, including both Config Sync
 // annotations and labels, from the given resource.
 // The only Config Sync metadata which will not be removed is `LifecycleMutationAnnotation`.
diff --git a/pkg/parse/annotations.go b/pkg/parse/annotations.go
index b8c7c41db2..6e2653fda5 100644
--- a/pkg/parse/annotations.go
+++ b/pkg/parse/annotations.go
@@ -20,7 +20,6 @@ import (
 
 	"kpt.dev/configsync/pkg/applier"
 	"kpt.dev/configsync/pkg/applyset"
-	"kpt.dev/configsync/pkg/core"
 	"kpt.dev/configsync/pkg/declared"
 	"kpt.dev/configsync/pkg/importer/analyzer/ast"
 	"kpt.dev/configsync/pkg/metadata"
@@ -38,21 +37,15 @@ func addAnnotationsAndLabels(objs []ast.FileObject, scope declared.Scope, syncNa
 	if err != nil {
 		return fmt.Errorf("marshaling sourceContext: %w", err)
 	}
-	applySetID := applyset.IDFromSync(syncName, scope)
-	inventoryID := applier.InventoryID(syncName, scope.SyncNamespace())
-	manager := declared.ResourceManager(scope, syncName)
+	csm := metadata.ConfigSyncMetadata{
+		ApplySetID:      applyset.IDFromSync(syncName, scope),
+		GitContextValue: string(gcVal),
+		ManagerValue:    declared.ResourceManager(scope, syncName),
+		SourceHash:      commitHash,
+		InventoryID:     applier.InventoryID(syncName, scope.SyncNamespace()),
+	}
 	for _, obj := range objs {
-		core.SetLabel(obj, metadata.ManagedByKey, metadata.ManagedByValue)
-		core.SetLabel(obj, metadata.ApplySetPartOfLabel, applySetID)
-		core.SetAnnotation(obj, metadata.GitContextKey, string(gcVal))
-		core.SetAnnotation(obj, metadata.ResourceManagerKey, manager)
-		core.SetAnnotation(obj, metadata.SyncTokenAnnotationKey, commitHash)
-		core.SetAnnotation(obj, metadata.ResourceIDKey, core.GKNN(obj))
-		core.SetAnnotation(obj, metadata.OwningInventoryKey, inventoryID)
-
-		if !metadata.IsManagementDisabled(obj) {
-			core.SetAnnotation(obj, metadata.ManagementModeAnnotationKey, metadata.ManagementEnabled.String())
-		}
+		csm.SetConfigSyncMetadata(obj)
 	}
 	return nil
 }
diff --git a/pkg/reconciler/finalizer/base_finalizer.go b/pkg/reconciler/finalizer/base_finalizer.go
index 56c168c60f..5ed3254ca6 100644
--- a/pkg/reconciler/finalizer/base_finalizer.go
+++ b/pkg/reconciler/finalizer/base_finalizer.go
@@ -17,13 +17,26 @@ package finalizer
 import (
 	"context"
 
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/klog/v2"
+	"kpt.dev/configsync/pkg/api/configsync"
+	"kpt.dev/configsync/pkg/api/kpt.dev/v1alpha1"
 	"kpt.dev/configsync/pkg/applier"
+	"kpt.dev/configsync/pkg/metadata"
 	"kpt.dev/configsync/pkg/status"
+	"sigs.k8s.io/controller-runtime/pkg/client"
 )
 
 type baseFinalizer struct {
 	Destroyer applier.Destroyer
+
+	// Client used to update RSync spec and status.
+	Client client.Client
+
+	// ApplySetID used to remove the apply-set label when orphaning objects
+	ApplySetID string
 }
 
 // destroy calls Destroyer.Destroy, collects errors, and handles logging,
@@ -56,3 +69,60 @@ func (bf *baseFinalizer) destroy(ctx context.Context) status.MultiError {
 	klog.Info("Destroyer succeeded")
 	return nil
 }
+
+func (bf *baseFinalizer) unmanageObjects(ctx context.Context, rsyncRef client.ObjectKey, hasSynced bool) status.MultiError {
+	var errs status.MultiError
+	rg := &v1alpha1.ResourceGroup{}
+	if err := bf.Client.Get(ctx, rsyncRef, rg); err != nil {
+		if apierrors.IsNotFound(err) {
+			if hasSynced {
+				// The ResourceGroup should exist if there has been a sync
+				klog.Error("ResourceGroup not found when attempting to unmanage objects for synced RSync")
+			} else {
+				// ResourceGroup doesn't exist, but that's expected since there hasn't been a sync
+				klog.Info("ResourceGroup not found when attempting to unmanage objects for unsynced RSync")
+			}
+			return nil
+		}
+		return status.APIServerError(err, "failed to get ResourceGroup", rg)
+	}
+	numNotFound := 0
+	for _, objRef := range rg.Spec.Resources {
+		// Remove metadata for each object
+		obj := &metav1.PartialObjectMetadata{}
+		obj.SetGroupVersionKind(schema.GroupVersionKind{
+			Group: objRef.Group,
+			Kind:  objRef.Kind,
+		})
+		key := client.ObjectKey{
+			Name:      objRef.Name,
+			Namespace: objRef.Namespace,
+		}
+		if err := bf.Client.Get(ctx, key, obj); err != nil {
+			if apierrors.IsNotFound(err) { // skip orphaning objects that don't exist
+				numNotFound++
+				continue
+			}
+			errs = status.Append(errs, status.APIServerError(err, "failed to get object", obj))
+			continue
+		}
+		existing := obj.DeepCopy()
+		// TODO: always remove apply set label with RemoveConfigSyncMetadata?
+		// All callers of RemoveConfigSyncMetadata also call RemoveApplySetPartOfLabel
+		update1 := metadata.RemoveConfigSyncMetadata(obj)
+		update2 := metadata.RemoveApplySetPartOfLabel(obj, bf.ApplySetID)
+		if update1 || update2 {
+			if err := bf.Client.Patch(ctx, obj, client.MergeFrom(existing),
+				client.FieldOwner(configsync.FieldManager)); err != nil {
+				errs = status.Append(errs, status.APIServerError(err, "failed to patch object to remove metadata", obj))
+			}
+		}
+	}
+	numErrs := 0
+	if errs != nil {
+		numErrs = len(errs.Errors())
+	}
+	klog.Infof("Finalizer unmanaged %d objects (%d not found) (%d errors)",
+		len(rg.Spec.Resources), numNotFound, numErrs)
+	return errs
+}
diff --git a/pkg/reconciler/finalizer/controller.go b/pkg/reconciler/finalizer/controller.go
index 1575c9f9af..8da9a75a53 100644
--- a/pkg/reconciler/finalizer/controller.go
+++ b/pkg/reconciler/finalizer/controller.go
@@ -150,14 +150,10 @@ func (c *Controller) reconcileFinalizer(ctx context.Context, obj client.Object)
 		policy = metadata.DeletionPropagationPolicyOrphan
 	}
 	switch policy {
-	case metadata.DeletionPropagationPolicyForeground:
+	case metadata.DeletionPropagationPolicyForeground, metadata.DeletionPropagationPolicyOrphan:
 		if _, err := c.Finalizer.AddFinalizer(ctx, obj); err != nil {
 			return err
 		}
-	case metadata.DeletionPropagationPolicyOrphan:
-		if _, err := c.Finalizer.RemoveFinalizer(ctx, obj); err != nil {
-			return err
-		}
 	default:
 		klog.Warningf("%T %s has an invalid value for the annotation %q: %q",
 			obj, client.ObjectKeyFromObject(obj), metadata.DeletionPropagationPolicyAnnotationKey, policy)
diff --git a/pkg/reconciler/finalizer/finalizer.go b/pkg/reconciler/finalizer/finalizer.go
index 759a5018a4..72cdac14c6 100644
--- a/pkg/reconciler/finalizer/finalizer.go
+++ b/pkg/reconciler/finalizer/finalizer.go
@@ -34,22 +34,24 @@ type Finalizer interface {
 
 // New constructs a new RootSyncFinalizer or RepoSyncFinalizer, depending on the
 // specified scope.
-func New(scope declared.Scope, destroyer applier.Destroyer, c client.Client, stopControllers context.CancelFunc, controllersStopped <-chan struct{}) Finalizer {
+func New(scope declared.Scope, destroyer applier.Destroyer, c client.Client, stopControllers context.CancelFunc, controllersStopped <-chan struct{}, applySetID string) Finalizer {
 	if scope == declared.RootScope {
 		return &RootSyncFinalizer{
 			baseFinalizer: baseFinalizer{
-				Destroyer: destroyer,
+				Destroyer:  destroyer,
+				Client:     c,
+				ApplySetID: applySetID,
 			},
-			Client:             c,
 			StopControllers:    stopControllers,
 			ControllersStopped: controllersStopped,
 		}
 	}
 	return &RepoSyncFinalizer{
 		baseFinalizer: baseFinalizer{
-			Destroyer: destroyer,
+			Destroyer:  destroyer,
+			Client:     c,
+			ApplySetID: applySetID,
 		},
-		Client:             c,
 		StopControllers:    stopControllers,
 		ControllersStopped: controllersStopped,
 	}
diff --git a/pkg/reconciler/finalizer/reposync_finalizer.go b/pkg/reconciler/finalizer/reposync_finalizer.go
index b80c7c6f59..ed264631d7 100644
--- a/pkg/reconciler/finalizer/reposync_finalizer.go
+++ b/pkg/reconciler/finalizer/reposync_finalizer.go
@@ -21,6 +21,8 @@ import (
 	"k8s.io/klog/v2"
 	"kpt.dev/configsync/pkg/api/configsync"
 	"kpt.dev/configsync/pkg/api/configsync/v1beta1"
+	"kpt.dev/configsync/pkg/core"
+	"kpt.dev/configsync/pkg/metadata"
 	"kpt.dev/configsync/pkg/reposync"
 	"kpt.dev/configsync/pkg/status"
 	"kpt.dev/configsync/pkg/util/mutate"
@@ -33,9 +35,6 @@ import (
 type RepoSyncFinalizer struct {
 	baseFinalizer
 
-	// Client used to update RSync spec and status.
-	Client client.Client
-
 	// StopControllers will be called by the Finalize() method to stop the Parser and Remediator.
 	StopControllers context.CancelFunc
 
@@ -72,10 +71,17 @@ func (f *RepoSyncFinalizer) Finalize(ctx context.Context, syncObj client.Object)
 		return fmt.Errorf("setting Finalizing condition: %w", err)
 	}
 
-	if err := f.deleteManagedObjects(ctx, rs); err != nil {
-		return fmt.Errorf("deleting managed objects: %w", err)
+	switch core.GetAnnotation(rs, metadata.DeletionPropagationPolicyAnnotationKey) {
+	case metadata.DeletionPropagationPolicyForeground.String():
+		if err := f.deleteManagedObjects(ctx, rs); err != nil {
+			return fmt.Errorf("deleting managed objects: %w", err)
+		}
+		klog.Infof("Deletion of managed objects successful")
+	default: // default is "Orphan"
+		if err := f.orphanManagedObjects(ctx, rs); err != nil {
+			return fmt.Errorf("orphaning managed objects: %w", err)
+		}
 	}
-	klog.Infof("Deletion of managed objects successful")
 
 	// TODO: optimize by combining these updates into a single update
 	if _, err := f.removeFinalizingCondition(ctx, rs); err != nil {
@@ -193,6 +199,25 @@ func (f *RepoSyncFinalizer) deleteManagedObjects(ctx context.Context, syncObj *v
 	return nil
 }
 
+// orphanManagedObjects orphans managed objects and then
+// updates the ReconcilerFinalizerFailure condition on the specified object.
+func (f *RepoSyncFinalizer) orphanManagedObjects(ctx context.Context, syncObj *v1beta1.RepoSync) error {
+	hasSynced := syncObj.Status.LastSyncedCommit != ""
+	unmanageErr := f.unmanageObjects(ctx, client.ObjectKeyFromObject(syncObj), hasSynced)
+	// Update the FinalizerFailure condition whether the destroy succeeded or failed
+	if _, updateErr := f.updateFailureCondition(ctx, syncObj, unmanageErr); updateErr != nil {
+		updateErr = fmt.Errorf("updating FinalizerFailure condition: %w", updateErr)
+		if unmanageErr != nil {
+			return status.Append(unmanageErr, updateErr)
+		}
+		return updateErr
+	}
+	if unmanageErr != nil {
+		return unmanageErr
+	}
+	return nil
+}
+
 // updateFailureCondition sets or removes the ReconcilerFinalizerFailure
 // condition on the specified object, depending if errors were specified or not.
 func (f *RepoSyncFinalizer) updateFailureCondition(ctx context.Context, syncObj *v1beta1.RepoSync, destroyErrs status.MultiError) (bool, error) {
diff --git a/pkg/reconciler/finalizer/reposync_finalizer_test.go b/pkg/reconciler/finalizer/reposync_finalizer_test.go
index eaa4a5bd32..668184e282 100644
--- a/pkg/reconciler/finalizer/reposync_finalizer_test.go
+++ b/pkg/reconciler/finalizer/reposync_finalizer_test.go
@@ -26,6 +26,9 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"kpt.dev/configsync/pkg/api/configsync/v1beta1"
+	"kpt.dev/configsync/pkg/api/kpt.dev/v1alpha1"
+	"kpt.dev/configsync/pkg/core"
+	"kpt.dev/configsync/pkg/core/k8sobjects"
 	"kpt.dev/configsync/pkg/kinds"
 	"kpt.dev/configsync/pkg/metadata"
 	"kpt.dev/configsync/pkg/status"
@@ -67,6 +70,7 @@ func TestRepoSyncFinalize(t *testing.T) {
 	testCases := []struct {
 		name                       string
 		rsync                      client.Object
+		deletionPolicy             metadata.DeletionPropagationPolicy
 		setup                      func(*fake.Client) error
 		destroyErrs                []status.Error
 		expectedRsyncBeforeDestroy client.Object
@@ -75,8 +79,9 @@ func TestRepoSyncFinalize(t *testing.T) {
 		expectedRsyncAfterFinalize client.Object
 	}{
 		{
-			name:  "happy path",
-			rsync: repoSync1.DeepCopy(),
+			name:           "destroy happy path",
+			rsync:          repoSync1.DeepCopy(),
+			deletionPolicy: metadata.DeletionPropagationPolicyForeground,
 			expectedRsyncBeforeDestroy: func() client.Object {
 				obj := repoSync1.DeepCopy()
 				// +1 to set ReconcilerFinalizing condition
@@ -107,8 +112,9 @@ func TestRepoSyncFinalize(t *testing.T) {
 			}(),
 		},
 		{
-			name:  "destroy failure",
-			rsync: repoSync1.DeepCopy(),
+			name:           "destroy failure",
+			rsync:          repoSync1.DeepCopy(),
+			deletionPolicy: metadata.DeletionPropagationPolicyForeground,
 			expectedRsyncBeforeDestroy: func() client.Object {
 				obj := repoSync1.DeepCopy()
 				// +1 to set ReconcilerFinalizing condition
@@ -193,10 +199,10 @@ func TestRepoSyncFinalize(t *testing.T) {
 				}
 				return obj
 			}(),
+			deletionPolicy: metadata.DeletionPropagationPolicyForeground,
 			expectedRsyncBeforeDestroy: func() client.Object {
 				obj := repoSync1.DeepCopy()
 				// No changes - continue deleting
-				// TODO: Should finalizer re-entry be vissible to the user by toggling a condition?
 				obj.SetResourceVersion("3")
 				obj.Status.Conditions = []v1beta1.RepoSyncCondition{
 					{
@@ -238,8 +244,9 @@ func TestRepoSyncFinalize(t *testing.T) {
 			}(),
 		},
 		{
-			name:  "rsync not found",
-			rsync: repoSync1.DeepCopy(),
+			name:           "rsync not found",
+			rsync:          repoSync1.DeepCopy(),
+			deletionPolicy: metadata.DeletionPropagationPolicyForeground,
 			setup: func(fakeClient *fake.Client) error {
 				// remove the finalizer that blocks deletion
 				ctx := context.Background()
@@ -271,11 +278,181 @@ func TestRepoSyncFinalize(t *testing.T) {
 			expectedStopped:            true,
 			expectedRsyncAfterFinalize: nil,
 		},
+		{
+			name:           "orphan happy path",
+			rsync:          repoSync1.DeepCopy(),
+			deletionPolicy: metadata.DeletionPropagationPolicyOrphan,
+			expectedRsyncBeforeDestroy: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				obj.SetResourceVersion("2")
+				obj.Status.Conditions = []v1beta1.RepoSyncCondition{
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizing,
+						Status:  metav1.ConditionTrue,
+						Reason:  "ResourcesDeleting",
+						Message: "Deleting managed resource objects",
+					},
+				}
+				return obj
+			}(),
+			expectedError:   nil,
+			expectedStopped: true,
+			expectedRsyncAfterFinalize: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				// +1 to remove ReconcilerFinalizing condition
+				// +1 to remove Finalizer
+				// TODO: optimize by combining consecutive updates
+				obj.SetResourceVersion("4")
+				// Finalizer has been removed
+				obj.SetFinalizers(nil)
+				// ReconcilerFinalizing condition added and then removed
+				return obj
+			}(),
+		},
+		{
+			name:           "orphan recovery",
+			deletionPolicy: metadata.DeletionPropagationPolicyOrphan,
+			rsync: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				// +1 to set ReconcilerFinalizerFailure condition
+				obj.SetResourceVersion("3")
+				// Finalizer NOT removed
+				// ReconcilerFinalizing condition added and NOT removed
+				// ReconcilerFinalizerFailure condition added
+				obj.Status.Conditions = []v1beta1.RepoSyncCondition{
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizing,
+						Status:  metav1.ConditionTrue,
+						Reason:  "ResourcesDeleting",
+						Message: "Deleting managed resource objects",
+					},
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizerFailure,
+						Status:  metav1.ConditionTrue,
+						Reason:  "DestroyFailure",
+						Message: "Failed to delete managed resource objects",
+						Errors: []v1beta1.ConfigSyncError{
+							{
+								Code:         "2002",
+								ErrorMessage: "KNV2002: example message: APIServer error: destroy error\n\nFor more information, see https://g.co/cloud/acm-errors#knv2002",
+							},
+						},
+					},
+				}
+				return obj
+			}(),
+			expectedRsyncBeforeDestroy: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// No changes - continue deleting
+				obj.SetResourceVersion("3")
+				obj.Status.Conditions = []v1beta1.RepoSyncCondition{
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizing,
+						Status:  metav1.ConditionTrue,
+						Reason:  "ResourcesDeleting",
+						Message: "Deleting managed resource objects",
+					},
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizerFailure,
+						Status:  metav1.ConditionTrue,
+						Reason:  "DestroyFailure",
+						Message: "Failed to delete managed resource objects",
+						Errors: []v1beta1.ConfigSyncError{
+							{
+								Code:         "2002",
+								ErrorMessage: "KNV2002: example message: APIServer error: destroy error\n\nFor more information, see https://g.co/cloud/acm-errors#knv2002",
+							},
+						},
+					},
+				}
+				return obj
+			}(),
+			destroyErrs:     nil,
+			expectedError:   nil,
+			expectedStopped: true,
+			expectedRsyncAfterFinalize: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to remove ReconcilerFinalizing condition
+				// +1 to remove ReconcilerFinalizerFailure condition
+				// +1 to remove Finalizer
+				// TODO: optimize by combining consecutive updates
+				obj.SetResourceVersion("6")
+				// Finalizer has been removed
+				obj.SetFinalizers(nil)
+				// ReconcilerFinalizing condition removed
+				// ReconcilerFinalizerFailure condition removed
+				return obj
+			}(),
+		},
 	}
 
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
-			fakeClient := fake.NewClient(t, scheme, tc.rsync)
+			// Set up a toy object referenced by the ResourceGroup
+			cm := k8sobjects.ConfigMapObject(
+				core.Name("cm1"), core.Namespace(tc.rsync.GetNamespace()),
+				core.Annotation("a-key", "a-value"),
+				core.Label("l-key", "l-value"))
+			wantCM := cm.DeepCopy()
+			wantCM.Generation = 1
+			wantCM.ResourceVersion = "1"
+			wantCM.UID = "1"
+
+			applySetID := "apply-set-id"
+			if tc.deletionPolicy == metadata.DeletionPropagationPolicyForeground {
+				wantCM.SetAnnotations(map[string]string{
+					metadata.OwningInventoryKey:          "inventory-id",
+					metadata.ManagementModeAnnotationKey: metadata.ManagementEnabled.String(),
+					metadata.SyncTokenAnnotationKey:      "commit-hash",
+					metadata.GitContextKey:               "git-context",
+					metadata.ResourceManagerKey:          "manager-value",
+					metadata.ResourceIDKey:               "_configmap_example_cm1",
+					"a-key":                              "a-value",
+				})
+				wantCM.SetLabels(map[string]string{
+					metadata.ManagedByKey:        metadata.ManagedByValue,
+					metadata.ApplySetPartOfLabel: applySetID,
+					"l-key":                      "l-value",
+				})
+			} else { // When "Orphan" is used, all CS metadata should be removed from the CM
+				wantCM.ResourceVersion = "2"
+				wantCM.SetAnnotations(map[string]string{
+					"a-key": "a-value",
+				})
+				wantCM.SetLabels(map[string]string{
+					"l-key": "l-value",
+				})
+			}
+			csm := metadata.ConfigSyncMetadata{
+				ApplySetID:      "apply-set-id",
+				GitContextValue: "git-context",
+				ManagerValue:    "manager-value",
+				SourceHash:      "commit-hash",
+				InventoryID:     "inventory-id",
+			}
+			csm.SetConfigSyncMetadata(cm)
+			// Create a ResourceGroup which references the ConfigMap
+			// "Orphan" will look up the ResourceGroup to remove CS metadata from managed objects
+			rg := &v1alpha1.ResourceGroup{}
+			rg.Name = tc.rsync.GetName()
+			rg.Namespace = tc.rsync.GetNamespace()
+			rg.Spec.Resources = []v1alpha1.ObjMetadata{
+				{
+					Name:      cm.Name,
+					Namespace: cm.Namespace,
+					GroupKind: v1alpha1.GroupKind{
+						Group: cm.GroupVersionKind().Group,
+						Kind:  cm.GroupVersionKind().Kind,
+					},
+				},
+			}
+			core.SetAnnotation(tc.rsync,
+				metadata.DeletionPropagationPolicyAnnotationKey,
+				tc.deletionPolicy.String())
+			fakeClient := fake.NewClient(t, scheme, tc.rsync, cm, rg)
 			ctx := context.Background()
 
 			stopped := false
@@ -290,6 +467,9 @@ func TestRepoSyncFinalize(t *testing.T) {
 				rsync := &v1beta1.RepoSync{}
 				err := fakeClient.Get(context.Background(), key, rsync)
 				require.NoError(t, err)
+				core.SetAnnotation(tc.expectedRsyncBeforeDestroy,
+					metadata.DeletionPropagationPolicyAnnotationKey,
+					tc.deletionPolicy.String())
 				asserter.Equal(t, tc.expectedRsyncBeforeDestroy, rsync)
 				// Return errors, if any
 				return tc.destroyErrs
@@ -297,9 +477,10 @@ func TestRepoSyncFinalize(t *testing.T) {
 			fakeDestroyer := newFakeDestroyer(tc.destroyErrs, destroyFunc)
 			finalizer := &RepoSyncFinalizer{
 				baseFinalizer: baseFinalizer{
-					Destroyer: fakeDestroyer,
+					Destroyer:  fakeDestroyer,
+					Client:     fakeClient,
+					ApplySetID: applySetID,
 				},
-				Client:             fakeClient,
 				StopControllers:    stopFunc,
 				ControllersStopped: continueCh,
 			}
@@ -313,8 +494,11 @@ func TestRepoSyncFinalize(t *testing.T) {
 			testerrors.AssertEqual(t, tc.expectedError, err)
 
 			assert.Equal(t, tc.expectedStopped, stopped)
-			var expectedObjs []client.Object
+			expectedObjs := []client.Object{rg, wantCM}
 			if tc.expectedRsyncAfterFinalize != nil {
+				core.SetAnnotation(tc.expectedRsyncAfterFinalize,
+					metadata.DeletionPropagationPolicyAnnotationKey,
+					tc.deletionPolicy.String())
 				expectedObjs = append(expectedObjs, tc.expectedRsyncAfterFinalize)
 			}
 			fakeClient.Check(t, expectedObjs...)
@@ -430,7 +614,9 @@ func TestRepoSyncAddFinalizer(t *testing.T) {
 			ctx := context.Background()
 
 			finalizer := &RepoSyncFinalizer{
-				Client: fakeClient,
+				baseFinalizer: baseFinalizer{
+					Client: fakeClient,
+				},
 			}
 
 			if tc.setup != nil {
@@ -567,7 +753,9 @@ func TestRepoSyncRemoveFinalizer(t *testing.T) {
 			ctx := context.Background()
 
 			finalizer := &RepoSyncFinalizer{
-				Client: fakeClient,
+				baseFinalizer: baseFinalizer{
+					Client: fakeClient,
+				},
 			}
 
 			if tc.setup != nil {
diff --git a/pkg/reconciler/finalizer/rootsync_finalizer.go b/pkg/reconciler/finalizer/rootsync_finalizer.go
index 3b3641036b..557d0c3fed 100644
--- a/pkg/reconciler/finalizer/rootsync_finalizer.go
+++ b/pkg/reconciler/finalizer/rootsync_finalizer.go
@@ -21,6 +21,8 @@ import (
 	"k8s.io/klog/v2"
 	"kpt.dev/configsync/pkg/api/configsync"
 	"kpt.dev/configsync/pkg/api/configsync/v1beta1"
+	"kpt.dev/configsync/pkg/core"
+	"kpt.dev/configsync/pkg/metadata"
 	"kpt.dev/configsync/pkg/rootsync"
 	"kpt.dev/configsync/pkg/status"
 	"kpt.dev/configsync/pkg/util/mutate"
@@ -33,9 +35,6 @@ import (
 type RootSyncFinalizer struct {
 	baseFinalizer
 
-	// Client used to update RSync spec and status.
-	Client client.Client
-
 	// StopControllers will be called by the Finalize() method to stop the Parser and Remediator.
 	StopControllers context.CancelFunc
 
@@ -72,10 +71,17 @@ func (f *RootSyncFinalizer) Finalize(ctx context.Context, syncObj client.Object)
 		return fmt.Errorf("setting Finalizing condition: %w", err)
 	}
 
-	if err := f.deleteManagedObjects(ctx, rs); err != nil {
-		return fmt.Errorf("deleting managed objects: %w", err)
+	switch core.GetAnnotation(rs, metadata.DeletionPropagationPolicyAnnotationKey) {
+	case metadata.DeletionPropagationPolicyForeground.String():
+		if err := f.deleteManagedObjects(ctx, rs); err != nil {
+			return fmt.Errorf("deleting managed objects: %w", err)
+		}
+		klog.Infof("Deletion of managed objects successful")
+	default: // default is "Orphan"
+		if err := f.orphanManagedObjects(ctx, rs); err != nil {
+			return fmt.Errorf("orphaning managed objects: %w", err)
+		}
 	}
-	klog.Infof("Deletion of managed objects successful")
 
 	// TODO: optimize by combining these updates into a single update
 	if _, err := f.removeFinalizingCondition(ctx, rs); err != nil {
@@ -175,6 +181,25 @@ func (f *RootSyncFinalizer) removeFinalizingCondition(ctx context.Context, syncO
 	return updated, nil
 }
 
+// orphanManagedObjects orphans managed objects and then
+// updates the ReconcilerFinalizerFailure condition on the specified object.
+func (f *RootSyncFinalizer) orphanManagedObjects(ctx context.Context, syncObj *v1beta1.RootSync) error {
+	hasSynced := syncObj.Status.LastSyncedCommit != ""
+	unmanageErr := f.unmanageObjects(ctx, client.ObjectKeyFromObject(syncObj), hasSynced)
+	// Update the FinalizerFailure condition whether the destroy succeeded or failed
+	if _, updateErr := f.updateFailureCondition(ctx, syncObj, unmanageErr); updateErr != nil {
+		updateErr = fmt.Errorf("updating FinalizerFailure condition: %w", updateErr)
+		if unmanageErr != nil {
+			return status.Append(unmanageErr, updateErr)
+		}
+		return updateErr
+	}
+	if unmanageErr != nil {
+		return unmanageErr
+	}
+	return nil
+}
+
 // deleteManagedObjects uses the destroyer to delete managed objects and then
 // updates the ReconcilerFinalizerFailure condition on the specified object.
 func (f *RootSyncFinalizer) deleteManagedObjects(ctx context.Context, syncObj *v1beta1.RootSync) error {
diff --git a/pkg/reconciler/finalizer/rootsync_finalizer_test.go b/pkg/reconciler/finalizer/rootsync_finalizer_test.go
index 33a5c6fbfc..db339e5754 100644
--- a/pkg/reconciler/finalizer/rootsync_finalizer_test.go
+++ b/pkg/reconciler/finalizer/rootsync_finalizer_test.go
@@ -28,9 +28,11 @@ import (
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/runtime/serializer"
 	"kpt.dev/configsync/pkg/api/configsync/v1beta1"
+	"kpt.dev/configsync/pkg/api/kpt.dev/v1alpha1"
 	"kpt.dev/configsync/pkg/applier"
 	"kpt.dev/configsync/pkg/applier/stats"
 	"kpt.dev/configsync/pkg/core"
+	"kpt.dev/configsync/pkg/core/k8sobjects"
 	"kpt.dev/configsync/pkg/kinds"
 	"kpt.dev/configsync/pkg/metadata"
 	"kpt.dev/configsync/pkg/status"
@@ -64,6 +66,9 @@ spec:
 
 func TestRootSyncFinalize(t *testing.T) {
 	rootSync1 := yamlToTypedObject(t, rootSync1Yaml).(*v1beta1.RootSync)
+	core.SetAnnotation(rootSync1,
+		metadata.DeletionPropagationPolicyAnnotationKey,
+		string(metadata.DeletionPropagationPolicyForeground))
 	rootSync1.SetFinalizers([]string{
 		metadata.ReconcilerFinalizer,
 	})
@@ -76,6 +81,7 @@ func TestRootSyncFinalize(t *testing.T) {
 	testCases := []struct {
 		name                       string
 		rsync                      client.Object
+		deletionPolicy             metadata.DeletionPropagationPolicy
 		setup                      func(*fake.Client) error
 		destroyErrs                []status.Error
 		expectedRsyncBeforeDestroy client.Object
@@ -84,8 +90,9 @@ func TestRootSyncFinalize(t *testing.T) {
 		expectedRsyncAfterFinalize client.Object
 	}{
 		{
-			name:  "happy path",
-			rsync: rootSync1.DeepCopy(),
+			name:           "destroy happy path",
+			rsync:          rootSync1.DeepCopy(),
+			deletionPolicy: metadata.DeletionPropagationPolicyForeground,
 			expectedRsyncBeforeDestroy: func() client.Object {
 				obj := rootSync1.DeepCopy()
 				// +1 to set ReconcilerFinalizing condition
@@ -116,8 +123,9 @@ func TestRootSyncFinalize(t *testing.T) {
 			}(),
 		},
 		{
-			name:  "destroy failure",
-			rsync: rootSync1.DeepCopy(),
+			name:           "destroy failure",
+			rsync:          rootSync1.DeepCopy(),
+			deletionPolicy: metadata.DeletionPropagationPolicyForeground,
 			expectedRsyncBeforeDestroy: func() client.Object {
 				obj := rootSync1.DeepCopy()
 				// +1 to set ReconcilerFinalizing condition
@@ -171,7 +179,8 @@ func TestRootSyncFinalize(t *testing.T) {
 			}(),
 		},
 		{
-			name: "destroy recovery",
+			name:           "destroy recovery",
+			deletionPolicy: metadata.DeletionPropagationPolicyForeground,
 			rsync: func() client.Object {
 				obj := rootSync1.DeepCopy()
 				// +1 to set ReconcilerFinalizing condition
@@ -205,7 +214,6 @@ func TestRootSyncFinalize(t *testing.T) {
 			expectedRsyncBeforeDestroy: func() client.Object {
 				obj := rootSync1.DeepCopy()
 				// No changes - continue deleting
-				// TODO: Should finalizer re-entry be vissible to the user by toggling a condition?
 				obj.SetResourceVersion("3")
 				obj.Status.Conditions = []v1beta1.RootSyncCondition{
 					{
@@ -247,8 +255,9 @@ func TestRootSyncFinalize(t *testing.T) {
 			}(),
 		},
 		{
-			name:  "rsync not found",
-			rsync: rootSync1.DeepCopy(),
+			name:           "rsync not found",
+			rsync:          rootSync1.DeepCopy(),
+			deletionPolicy: metadata.DeletionPropagationPolicyForeground,
 			setup: func(fakeClient *fake.Client) error {
 				// remove the finalizer that blocks deletion
 				ctx := context.Background()
@@ -278,11 +287,182 @@ func TestRootSyncFinalize(t *testing.T) {
 			expectedStopped:            true,
 			expectedRsyncAfterFinalize: nil,
 		},
+		{
+			name:           "orphan happy path",
+			rsync:          rootSync1.DeepCopy(),
+			deletionPolicy: metadata.DeletionPropagationPolicyOrphan,
+			expectedRsyncBeforeDestroy: func() client.Object {
+				obj := rootSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				obj.SetResourceVersion("2")
+				obj.Status.Conditions = []v1beta1.RootSyncCondition{
+					{
+						Type:    v1beta1.RootSyncReconcilerFinalizing,
+						Status:  metav1.ConditionTrue,
+						Reason:  "ResourcesDeleting",
+						Message: "Deleting managed resource objects",
+					},
+				}
+				return obj
+			}(),
+			expectedError:   nil,
+			expectedStopped: true,
+			expectedRsyncAfterFinalize: func() client.Object {
+				obj := rootSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				// +1 to remove ReconcilerFinalizing condition
+				// +1 to remove Finalizer
+				// TODO: optimize by combining consecutive updates
+				obj.SetResourceVersion("4")
+				// Finalizer has been removed
+				obj.SetFinalizers(nil)
+				// ReconcilerFinalizing condition added and then removed
+				return obj
+			}(),
+		},
+		{
+			name:           "orphan recovery",
+			deletionPolicy: metadata.DeletionPropagationPolicyOrphan,
+			rsync: func() client.Object {
+				obj := rootSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				// +1 to set ReconcilerFinalizerFailure condition
+				obj.SetResourceVersion("3")
+				// Finalizer NOT removed
+				// ReconcilerFinalizing condition added and NOT removed
+				// ReconcilerFinalizerFailure condition added
+				obj.Status.Conditions = []v1beta1.RootSyncCondition{
+					{
+						Type:    v1beta1.RootSyncReconcilerFinalizing,
+						Status:  metav1.ConditionTrue,
+						Reason:  "ResourcesDeleting",
+						Message: "Deleting managed resource objects",
+					},
+					{
+						Type:    v1beta1.RootSyncReconcilerFinalizerFailure,
+						Status:  metav1.ConditionTrue,
+						Reason:  "DestroyFailure",
+						Message: "Failed to delete managed resource objects",
+						Errors: []v1beta1.ConfigSyncError{
+							{
+								Code:         "2002",
+								ErrorMessage: "KNV2002: example message: APIServer error: destroy error\n\nFor more information, see https://g.co/cloud/acm-errors#knv2002",
+							},
+						},
+					},
+				}
+				return obj
+			}(),
+			expectedRsyncBeforeDestroy: func() client.Object {
+				obj := rootSync1.DeepCopy()
+				// No changes - continue deleting
+				obj.SetResourceVersion("3")
+				obj.Status.Conditions = []v1beta1.RootSyncCondition{
+					{
+						Type:    v1beta1.RootSyncReconcilerFinalizing,
+						Status:  metav1.ConditionTrue,
+						Reason:  "ResourcesDeleting",
+						Message: "Deleting managed resource objects",
+					},
+					{
+						Type:    v1beta1.RootSyncReconcilerFinalizerFailure,
+						Status:  metav1.ConditionTrue,
+						Reason:  "DestroyFailure",
+						Message: "Failed to delete managed resource objects",
+						Errors: []v1beta1.ConfigSyncError{
+							{
+								Code:         "2002",
+								ErrorMessage: "KNV2002: example message: APIServer error: destroy error\n\nFor more information, see https://g.co/cloud/acm-errors#knv2002",
+							},
+						},
+					},
+				}
+				return obj
+			}(),
+			destroyErrs:     nil,
+			expectedError:   nil,
+			expectedStopped: true,
+			expectedRsyncAfterFinalize: func() client.Object {
+				obj := rootSync1.DeepCopy()
+				// +1 to remove ReconcilerFinalizing condition
+				// +1 to remove ReconcilerFinalizerFailure condition
+				// +1 to remove Finalizer
+				// TODO: optimize by combining consecutive updates
+				obj.SetResourceVersion("6")
+				// Finalizer has been removed
+				obj.SetFinalizers(nil)
+				// ReconcilerFinalizing condition removed
+				// ReconcilerFinalizerFailure condition removed
+				return obj
+			}(),
+		},
 	}
 
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
-			fakeClient := fake.NewClient(t, scheme, tc.rsync)
+			// Set up a toy object referenced by the ResourceGroup
+			cm := k8sobjects.ConfigMapObject(
+				core.Name("cm1"), core.Namespace(tc.rsync.GetNamespace()),
+				core.Annotation("a-key", "a-value"),
+				core.Label("l-key", "l-value"))
+			wantCM := cm.DeepCopy()
+			wantCM.Generation = 1
+			wantCM.ResourceVersion = "1"
+			wantCM.UID = "1"
+
+			applySetID := "apply-set-id"
+			if tc.deletionPolicy == metadata.DeletionPropagationPolicyForeground {
+				wantCM.SetAnnotations(map[string]string{
+					metadata.OwningInventoryKey:          "inventory-id",
+					metadata.ManagementModeAnnotationKey: metadata.ManagementEnabled.String(),
+					metadata.SyncTokenAnnotationKey:      "commit-hash",
+					metadata.GitContextKey:               "git-context",
+					metadata.ResourceManagerKey:          "manager-value",
+					metadata.ResourceIDKey:               "_configmap_config-management-system_cm1",
+					"a-key":                              "a-value",
+				})
+				wantCM.SetLabels(map[string]string{
+					metadata.ManagedByKey:        metadata.ManagedByValue,
+					metadata.ApplySetPartOfLabel: applySetID,
+					"l-key":                      "l-value",
+				})
+			} else { // When "Orphan" is used, all CS metadata should be removed from the CM
+				wantCM.ResourceVersion = "2"
+				wantCM.SetAnnotations(map[string]string{
+					"a-key": "a-value",
+				})
+				wantCM.SetLabels(map[string]string{
+					"l-key": "l-value",
+				})
+			}
+			csm := metadata.ConfigSyncMetadata{
+				ApplySetID:      "apply-set-id",
+				GitContextValue: "git-context",
+				ManagerValue:    "manager-value",
+				SourceHash:      "commit-hash",
+				InventoryID:     "inventory-id",
+			}
+			csm.SetConfigSyncMetadata(cm)
+			// Create a ResourceGroup which references the ConfigMap
+			// "Orphan" will look up the ResourceGroup to remove CS metadata from managed objects
+			rg := &v1alpha1.ResourceGroup{}
+			rg.Name = tc.rsync.GetName()
+			rg.Namespace = tc.rsync.GetNamespace()
+			rg.Spec.Resources = []v1alpha1.ObjMetadata{
+				{
+					Name:      cm.Name,
+					Namespace: cm.Namespace,
+					GroupKind: v1alpha1.GroupKind{
+						Group: cm.GroupVersionKind().Group,
+						Kind:  cm.GroupVersionKind().Kind,
+					},
+				},
+			}
+			core.SetAnnotation(tc.rsync,
+				metadata.DeletionPropagationPolicyAnnotationKey,
+				tc.deletionPolicy.String())
+
+			fakeClient := fake.NewClient(t, scheme, tc.rsync, cm, rg)
 			ctx := context.Background()
 
 			stopped := false
@@ -297,6 +477,9 @@ func TestRootSyncFinalize(t *testing.T) {
 				rsync := &v1beta1.RootSync{}
 				err := fakeClient.Get(context.Background(), key, rsync)
 				require.NoError(t, err)
+				core.SetAnnotation(tc.expectedRsyncBeforeDestroy,
+					metadata.DeletionPropagationPolicyAnnotationKey,
+					tc.deletionPolicy.String())
 				asserter.Equal(t, tc.expectedRsyncBeforeDestroy, rsync)
 				// Return errors, if any
 				return tc.destroyErrs
@@ -304,9 +487,10 @@ func TestRootSyncFinalize(t *testing.T) {
 			fakeDestroyer := newFakeDestroyer(tc.destroyErrs, destroyFunc)
 			finalizer := &RootSyncFinalizer{
 				baseFinalizer: baseFinalizer{
-					Destroyer: fakeDestroyer,
+					Destroyer:  fakeDestroyer,
+					Client:     fakeClient,
+					ApplySetID: applySetID,
 				},
-				Client:             fakeClient,
 				StopControllers:    stopFunc,
 				ControllersStopped: continueCh,
 			}
@@ -320,8 +504,11 @@ func TestRootSyncFinalize(t *testing.T) {
 			testerrors.AssertEqual(t, tc.expectedError, err)
 
 			assert.Equal(t, tc.expectedStopped, stopped)
-			var expectedObjs []client.Object
+			expectedObjs := []client.Object{rg, wantCM}
 			if tc.expectedRsyncAfterFinalize != nil {
+				core.SetAnnotation(tc.expectedRsyncAfterFinalize,
+					metadata.DeletionPropagationPolicyAnnotationKey,
+					tc.deletionPolicy.String())
 				expectedObjs = append(expectedObjs, tc.expectedRsyncAfterFinalize)
 			}
 			fakeClient.Check(t, expectedObjs...)
@@ -438,7 +625,9 @@ func TestRootSyncAddFinalizer(t *testing.T) {
 			ctx := context.Background()
 
 			finalizer := &RootSyncFinalizer{
-				Client: fakeClient,
+				baseFinalizer: baseFinalizer{
+					Client: fakeClient,
+				},
 			}
 
 			if tc.setup != nil {
@@ -576,7 +765,9 @@ func TestRootSyncRemoveFinalizer(t *testing.T) {
 			ctx := context.Background()
 
 			finalizer := &RootSyncFinalizer{
-				Client: fakeClient,
+				baseFinalizer: baseFinalizer{
+					Client: fakeClient,
+				},
 			}
 
 			if tc.setup != nil {
diff --git a/pkg/reconciler/reconciler.go b/pkg/reconciler/reconciler.go
index 44b9e28799..c8a4815774 100644
--- a/pkg/reconciler/reconciler.go
+++ b/pkg/reconciler/reconciler.go
@@ -371,7 +371,7 @@ func Run(opts Options) {
 	// the GET cache on UPDATE/PATCH. So we need to use the non-caching client
 	// for the finalizer, which does GET/LIST after UPDATE/PATCH.
 	f := finalizer.New(opts.ReconcilerScope, supervisor, cl, // non-caching client
-		stopControllers, continueChanForFinalizer)
+		stopControllers, continueChanForFinalizer, applySetID)
 
 	// Create the Finalizer Controller
 	finalizerController := &finalizer.Controller{
diff --git a/pkg/syncer/syncertest/fake/storage.go b/pkg/syncer/syncertest/fake/storage.go
index ba6c87d7a0..0954c38c33 100644
--- a/pkg/syncer/syncertest/fake/storage.go
+++ b/pkg/syncer/syncertest/fake/storage.go
@@ -304,6 +304,21 @@ func (ms *MemoryStorage) listObjects(gk schema.GroupKind) []*unstructured.Unstru
 	return result
 }
 
+func lookupGVK(obj runtime.Object, scheme *runtime.Scheme) (schema.GroupVersionKind, error) {
+	switch obj.(type) {
+	case *metav1.PartialObjectMetadata:
+		// Workaround for PartialObjectMetadata which are missing the Version.
+		// Missing apiVersion is allowed by real client, but apiutil.GVKForObject returns an error
+		gvk := obj.GetObjectKind().GroupVersionKind()
+		if len(gvk.Kind) == 0 {
+			return schema.GroupVersionKind{}, fmt.Errorf("missing object Kind from PartialObjectMetadata")
+		}
+		return gvk, nil
+	default:
+		return kinds.Lookup(obj, scheme)
+	}
+}
+
 // Get an object from storage
 func (ms *MemoryStorage) Get(_ context.Context, key client.ObjectKey, obj client.Object, opts *client.GetOptions) error {
 	ms.lock.RLock()
@@ -313,7 +328,7 @@ func (ms *MemoryStorage) Get(_ context.Context, key client.ObjectKey, obj client
 	if err != nil {
 		return err
 	}
-	gvk, err := kinds.Lookup(obj, ms.scheme)
+	gvk, err := lookupGVK(obj, ms.scheme)
 	if err != nil {
 		return err
 	}
@@ -823,7 +838,7 @@ func (ms *MemoryStorage) Patch(ctx context.Context, obj client.Object, patch cli
 		return err
 	}
 
-	gvk, err := kinds.Lookup(obj, ms.scheme)
+	gvk, err := lookupGVK(obj, ms.scheme)
 	if err != nil {
 		return fmt.Errorf("failed to lookup GVK from scheme: %w", err)
 	}


Reviews:
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T17:47:49Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T17:47:55Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T17:49:08Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T17:51:30Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T17:53:07Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T17:57:01Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T17:58:14Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T18:01:08Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T18:02:20Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T18:04:40Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T18:05:37Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T18:09:10Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T18:10:06Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T18:11:37Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T18:13:22Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T18:14:47Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T18:15:48Z
user: login: sdowell
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T18:40:21Z
user: login: sdowell
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T18:42:49Z
user: login: sdowell
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T18:45:30Z
user: login: sdowell
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T18:46:23Z
user: login: sdowell
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T18:48:04Z
user: login: sdowell
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T19:38:41Z
user: login: sdowell
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T19:40:51Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T19:43:00Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T19:47:30Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T19:50:01Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T19:50:30Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T20:41:44Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T23:09:06Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T23:29:30Z
user: login: sdowell
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T23:30:49Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T23:34:29Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T23:47:21Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T23:50:37Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T23:51:19Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T23:51:37Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T23:52:13Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T23:54:01Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T00:08:12Z
user: login: sdowell
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T17:36:33Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T17:47:47Z
user: login: karlkfi
body: Looks good. Just need @janetkuo to approve merging after feature freeze.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T17:48:43Z
user: login: janetkuo
body: /lgtm
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T18:18:18Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T19:42:20Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T19:42:29Z
user: login: karlkfi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T19:42:43Z

Review comments:
diff_hunk: @@ -206,6 +207,10 @@ type NT struct {
 
 	// testStartTime is a timestamp which records when the current test started
 	testStartTime time.Time
+
+	// rsyncDeletionPropagation is the deletion propagation policy to use for all
+	// rsyncs. Defaults to Foreground to clean up test resources.
+	rsyncDeletionPropagation metadata.DeletionPropagationPolicy
path: e2e/nomostest/nt.go
user: login: karlkfi
body: nit: rename rsyncDeletionPropagation -> DeletionPropagationPolicy
created_at: 2025-04-08T17:47:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,6 +56,9 @@ type MultiRepo struct {
 
 	// RepoSyncPermissions will grant a list of PolicyRules to NS reconcilers
 	RepoSyncPermissions []rbacv1.PolicyRule
+
+	// RSyncDeletionPropagation sets the deletion propagation policy for all RSyncs
+	RSyncDeletionPropagation metadata.DeletionPropagationPolicy
path: e2e/nomostest/ntopts/multi_repo.go
user: login: karlkfi
body: nit: rename rsyncDeletionPropagation -> DeletionPropagationPolicy
created_at: 2025-04-08T17:47:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -116,16 +117,14 @@ func TestReconcilerFinalizer_Orphan(t *testing.T) {
 		// Namespace1 should NOT have been deleted, because it was orphaned by the RootSync.
 		return nt.Watcher.WatchObject(kinds.Namespace(), namespace1.GetName(), namespace1.GetNamespace(),
 			testwatcher.WatchPredicates(
-				testpredicates.HasAllNomosMetadata(), // metadata NOT removed when orphaned
-				testpredicates.HasLabel(metadata.ApplySetPartOfLabel, applySetID),
+				testpredicates.IsNotManaged(nt.Scheme),
path: e2e/testcases/reconciler_finalizer_test.go
user: login: karlkfi
body: Does this validate just the managed annotation or all the removed metadata?
created_at: 2025-04-08T17:49:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -662,6 +666,114 @@ func TestDependencyWithReconciliation(t *testing.T) {
 	}
 }
 
+func verifyManagedBy(nt *nomostest.NT, o client.Object, rsync core.ID) error {
+	return nt.Validate(o.GetName(), o.GetNamespace(), o,
+		testpredicates.IsManagedBy(nt.Scheme, declared.ScopeFromSyncNamespace(rsync.Namespace), rsync.Name))
+}
+
+func verifyUnmanaged(nt *nomostest.NT, o client.Object) error {
+	return nt.Validate(o.GetName(), o.GetNamespace(), o,
+		testpredicates.IsNotManaged(nt.Scheme))
+}
+
+func TestSplitRSyncsWithDeletion(t *testing.T) {
+	ns1 := func() *corev1.Namespace {
+		return k8sobjects.NamespaceObject("ns1")
+	}
+	anvilCRD := func() *apiextensionsv1.CustomResourceDefinition {
+		return anvilV1CRD()
+	}
+	anvilCR := func() *unstructured.Unstructured {
+		o := newAnvilObject("v1", "anvil", 42)
+		o.SetNamespace("foo")
+		return o
+	}
+	tenantNS1 := "tenant-ns-1"
+	tenantNS2 := "tenant-ns-2"
+	cm1 := func() *corev1.ConfigMap {
+		return k8sobjects.ConfigMapObject(core.Name("cm1"), core.Namespace(tenantNS1))
+	}
+	sa1 := func() *corev1.ServiceAccount {
+		return k8sobjects.ServiceAccountObject("sa1", core.Namespace(tenantNS2))
+	}
+	rootSync := nomostest.DefaultRootSyncID
+	rootSync1 := core.RootSyncID("root-sync-1")
+	rootSync2 := core.RootSyncID("root-sync-2")
+	repoSync1 := core.RepoSyncID("repo-sync-1", tenantNS1)
+	repoSync2 := core.RepoSyncID("repo-sync-2", tenantNS2)
+	nt := nomostest.New(t, nomostesting.Lifecycle,
+		ntopts.SyncWithGitSource(rootSync, ntopts.Unstructured),
+		ntopts.SyncWithGitSource(rootSync1, ntopts.Unstructured),
+		ntopts.SyncWithGitSource(rootSync2, ntopts.Unstructured),
+		ntopts.SyncWithGitSource(repoSync1, ntopts.Unstructured),
+		ntopts.SyncWithGitSource(repoSync2, ntopts.Unstructured),
+		ntopts.WithDelegatedControl,
+		ntopts.WithDeletionPropagationPolicy(metadata.DeletionPropagationPolicyOrphan),
+		ntopts.RepoSyncPermissions(policy.CoreAdmin()))
+
+	nt.T.Log("Add all resources to a single RootSync")
+	rootSyncGitRepo := nt.SyncSourceGitReadWriteRepository(rootSync)
+	nt.Must(rootSyncGitRepo.Add("acme/ns1.yaml", ns1()))
+	nt.Must(rootSyncGitRepo.Add("acme/anvil_crd.yaml", anvilCRD()))
+	nt.Must(rootSyncGitRepo.Add("acme/anvil_cr.yaml", anvilCR()))
+	nt.Must(rootSyncGitRepo.Add("acme/cm1.yaml", cm1()))
+	nt.Must(rootSyncGitRepo.Add("acme/sa1.yaml", sa1()))
+	nt.Must(rootSyncGitRepo.CommitAndPush("Add objects"))
+	nt.Must(nt.WatchForAllSyncs())
+	// Validate metadata for each object
+	nt.Must(verifyManagedBy(nt, ns1(), rootSync))
+	nt.Must(verifyManagedBy(nt, anvilCRD(), rootSync))
+	nt.Must(verifyManagedBy(nt, anvilCR(), rootSync))
+	nt.Must(verifyManagedBy(nt, cm1(), rootSync))
+	nt.Must(verifyManagedBy(nt, sa1(), rootSync))
+
+	nt.T.Cleanup(func() {
+		rs := nomostest.RootSyncObjectV1Beta1FromRootRepo(nt, rootSync.Name)
+		nt.Must(nt.KubeClient.Apply(rs))
+		nt.Must(nt.WatchForAllSyncs())
+	})
+	nt.T.Log("Delete the single RootSync to orphan all resources")
+	nt.Must(nt.KubeClient.Delete(k8sobjects.RootSyncV1Beta1(rootSync.Name)))
+	nt.Must(nt.Watcher.WatchForNotFound(kinds.RootSyncV1Beta1(), rootSync.Name, rootSync.Namespace))
+	nt.Must(verifyUnmanaged(nt, ns1()))
+	nt.Must(verifyUnmanaged(nt, anvilCRD()))
+	nt.Must(verifyUnmanaged(nt, anvilCR()))
+	nt.Must(verifyUnmanaged(nt, cm1()))
+	nt.Must(verifyUnmanaged(nt, sa1()))
+
+	nt.T.Cleanup(func() {
+		// Remove objects from the original RSync for cleanup
+		repo := nt.SyncSourceGitReadWriteRepository(rootSync)
+		nt.Must(repo.Remove("acme/ns1.yaml"))
path: e2e/testcases/sync_ordering_test.go
user: login: karlkfi
body: Might be nice to have an nt.Should that calls nt.T.Error, instead of nt.T.Fatal. Otherwise the first that fails will fail to clean the rest up.

But that would need a different PR.
created_at: 2025-04-08T17:51:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -662,6 +666,114 @@ func TestDependencyWithReconciliation(t *testing.T) {
 	}
 }
 
+func verifyManagedBy(nt *nomostest.NT, o client.Object, rsync core.ID) error {
+	return nt.Validate(o.GetName(), o.GetNamespace(), o,
+		testpredicates.IsManagedBy(nt.Scheme, declared.ScopeFromSyncNamespace(rsync.Namespace), rsync.Name))
+}
+
+func verifyUnmanaged(nt *nomostest.NT, o client.Object) error {
+	return nt.Validate(o.GetName(), o.GetNamespace(), o,
+		testpredicates.IsNotManaged(nt.Scheme))
+}
+
+func TestSplitRSyncsWithDeletion(t *testing.T) {
+	ns1 := func() *corev1.Namespace {
+		return k8sobjects.NamespaceObject("ns1")
+	}
+	anvilCRD := func() *apiextensionsv1.CustomResourceDefinition {
+		return anvilV1CRD()
+	}
+	anvilCR := func() *unstructured.Unstructured {
+		o := newAnvilObject("v1", "anvil", 42)
+		o.SetNamespace("foo")
+		return o
+	}
+	tenantNS1 := "tenant-ns-1"
+	tenantNS2 := "tenant-ns-2"
+	cm1 := func() *corev1.ConfigMap {
+		return k8sobjects.ConfigMapObject(core.Name("cm1"), core.Namespace(tenantNS1))
+	}
+	sa1 := func() *corev1.ServiceAccount {
+		return k8sobjects.ServiceAccountObject("sa1", core.Namespace(tenantNS2))
+	}
+	rootSync := nomostest.DefaultRootSyncID
+	rootSync1 := core.RootSyncID("root-sync-1")
+	rootSync2 := core.RootSyncID("root-sync-2")
+	repoSync1 := core.RepoSyncID("repo-sync-1", tenantNS1)
+	repoSync2 := core.RepoSyncID("repo-sync-2", tenantNS2)
+	nt := nomostest.New(t, nomostesting.Lifecycle,
+		ntopts.SyncWithGitSource(rootSync, ntopts.Unstructured),
+		ntopts.SyncWithGitSource(rootSync1, ntopts.Unstructured),
+		ntopts.SyncWithGitSource(rootSync2, ntopts.Unstructured),
+		ntopts.SyncWithGitSource(repoSync1, ntopts.Unstructured),
+		ntopts.SyncWithGitSource(repoSync2, ntopts.Unstructured),
+		ntopts.WithDelegatedControl,
+		ntopts.WithDeletionPropagationPolicy(metadata.DeletionPropagationPolicyOrphan),
+		ntopts.RepoSyncPermissions(policy.CoreAdmin()))
+
+	nt.T.Log("Add all resources to a single RootSync")
+	rootSyncGitRepo := nt.SyncSourceGitReadWriteRepository(rootSync)
+	nt.Must(rootSyncGitRepo.Add("acme/ns1.yaml", ns1()))
+	nt.Must(rootSyncGitRepo.Add("acme/anvil_crd.yaml", anvilCRD()))
+	nt.Must(rootSyncGitRepo.Add("acme/anvil_cr.yaml", anvilCR()))
+	nt.Must(rootSyncGitRepo.Add("acme/cm1.yaml", cm1()))
+	nt.Must(rootSyncGitRepo.Add("acme/sa1.yaml", sa1()))
+	nt.Must(rootSyncGitRepo.CommitAndPush("Add objects"))
+	nt.Must(nt.WatchForAllSyncs())
+	// Validate metadata for each object
+	nt.Must(verifyManagedBy(nt, ns1(), rootSync))
+	nt.Must(verifyManagedBy(nt, anvilCRD(), rootSync))
+	nt.Must(verifyManagedBy(nt, anvilCR(), rootSync))
+	nt.Must(verifyManagedBy(nt, cm1(), rootSync))
+	nt.Must(verifyManagedBy(nt, sa1(), rootSync))
+
+	nt.T.Cleanup(func() {
+		rs := nomostest.RootSyncObjectV1Beta1FromRootRepo(nt, rootSync.Name)
+		nt.Must(nt.KubeClient.Apply(rs))
+		nt.Must(nt.WatchForAllSyncs())
+	})
+	nt.T.Log("Delete the single RootSync to orphan all resources")
+	nt.Must(nt.KubeClient.Delete(k8sobjects.RootSyncV1Beta1(rootSync.Name)))
+	nt.Must(nt.Watcher.WatchForNotFound(kinds.RootSyncV1Beta1(), rootSync.Name, rootSync.Namespace))
+	nt.Must(verifyUnmanaged(nt, ns1()))
+	nt.Must(verifyUnmanaged(nt, anvilCRD()))
+	nt.Must(verifyUnmanaged(nt, anvilCR()))
+	nt.Must(verifyUnmanaged(nt, cm1()))
+	nt.Must(verifyUnmanaged(nt, sa1()))
+
+	nt.T.Cleanup(func() {
+		// Remove objects from the original RSync for cleanup
+		repo := nt.SyncSourceGitReadWriteRepository(rootSync)
+		nt.Must(repo.Remove("acme/ns1.yaml"))
+		nt.Must(repo.Remove("acme/anvil_crd.yaml"))
+		nt.Must(repo.Remove("acme/anvil_cr.yaml"))
+		nt.Must(repo.Remove("acme/cm1.yaml"))
+		nt.Must(repo.Remove("acme/sa1.yaml"))
+		nt.Must(repo.CommitAndPush("Remove orphaned objects"))
+	})
+	nt.T.Log("Adopt each resource by various RootSyncs/RepoSyncs")
+	repo := nt.SyncSourceGitReadWriteRepository(rootSync1)
+	nt.Must(repo.Add("acme/ns1.yaml", ns1()))
+	nt.Must(repo.CommitAndPush("Take ownership of ns1 in root-sync-1"))
+	repo = nt.SyncSourceGitReadWriteRepository(rootSync2)
+	nt.Must(repo.Add("acme/anvil_crd.yaml", anvilCRD()))
+	nt.Must(repo.Add("acme/anvil_cr.yaml", anvilCR()))
+	nt.Must(repo.CommitAndPush("Take ownership of anvil in root-sync-2"))
+	repo = nt.SyncSourceGitReadWriteRepository(repoSync1)
+	nt.Must(repo.Add("acme/cm1.yaml", cm1()))
+	nt.Must(repo.CommitAndPush("Take ownership of cm1 in repo-sync-1"))
+	repo = nt.SyncSourceGitReadWriteRepository(repoSync2)
+	nt.Must(repo.Add("acme/sa1.yaml", sa1()))
+	nt.Must(repo.CommitAndPush("Take ownership of sa1 in repo-sync-2"))
+	// Ready check recreates the root-sync, which we don't want
+	nt.Must(nt.WatchForAllSyncs(nomostest.SkipReadyCheck(), nomostest.SkipRootRepos(rootSync.Name)))
path: e2e/testcases/sync_ordering_test.go
user: login: karlkfi
body: Will this do anything? Doesn't SkipRootRepos skip the RootSync check?
created_at: 2025-04-08T17:53:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,3 +67,48 @@ func (bf *baseFinalizer) destroy(ctx context.Context) status.MultiError {
 	klog.Info("Destroyer succeeded")
 	return nil
 }
+
+func (bf *baseFinalizer) unmanageObjects(ctx context.Context, rsyncRef client.ObjectKey) status.MultiError {
+	var errs status.MultiError
+	rg := &v1alpha1.ResourceGroup{}
+	if err := bf.Client.Get(ctx, rsyncRef, rg); err != nil {
+		if apierrors.IsNotFound(err) {
+			// ResourceGroup doesn't exist. This could be for a variety of reasons, but
+			// there's no work for the finalizer to do.
path: pkg/reconciler/finalizer/base_finalizer.go
user: login: karlkfi
body: Not entirely sure if this should succeed silently or not. 
Seems like we should at least log something.

I guess one case is that it hasn't successfully applied yet, so the RG hasn't been created, in which case it's fine to exit.

Are there other use cases where it might not?
created_at: 2025-04-08T17:57:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,3 +67,48 @@ func (bf *baseFinalizer) destroy(ctx context.Context) status.MultiError {
 	klog.Info("Destroyer succeeded")
 	return nil
 }
+
+func (bf *baseFinalizer) unmanageObjects(ctx context.Context, rsyncRef client.ObjectKey) status.MultiError {
+	var errs status.MultiError
+	rg := &v1alpha1.ResourceGroup{}
+	if err := bf.Client.Get(ctx, rsyncRef, rg); err != nil {
+		if apierrors.IsNotFound(err) {
+			// ResourceGroup doesn't exist. This could be for a variety of reasons, but
+			// there's no work for the finalizer to do.
path: pkg/reconciler/finalizer/base_finalizer.go
user: login: karlkfi
body: Does the reconciler-manager delete the RG after the RSync is NotFound or just after it's got a deletionTimestamp?
created_at: 2025-04-08T17:58:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,3 +67,48 @@ func (bf *baseFinalizer) destroy(ctx context.Context) status.MultiError {
 	klog.Info("Destroyer succeeded")
 	return nil
 }
+
+func (bf *baseFinalizer) unmanageObjects(ctx context.Context, rsyncRef client.ObjectKey) status.MultiError {
+	var errs status.MultiError
+	rg := &v1alpha1.ResourceGroup{}
+	if err := bf.Client.Get(ctx, rsyncRef, rg); err != nil {
+		if apierrors.IsNotFound(err) {
+			// ResourceGroup doesn't exist. This could be for a variety of reasons, but
+			// there's no work for the finalizer to do.
+			return nil
+		}
+		return status.APIServerError(err, "failed to get ResourceGroup", rg)
+	}
+	removeAnnotations := []string{
+		metadata.ResourceManagerKey,
+		metadata.OwningInventoryKey,
+		metadata.ResourceManagementKey,
+	}
+	for _, objRef := range rg.Spec.Resources {
+		// Remove metadata for each object
+		obj := &metav1.PartialObjectMetadata{}
+		obj.SetGroupVersionKind(schema.GroupVersionKind{
+			Group: objRef.Group,
+			Kind:  objRef.Kind,
+		})
+		key := client.ObjectKey{
+			Name:      objRef.Name,
+			Namespace: objRef.Namespace,
+		}
+		if err := bf.Client.Get(ctx, key, obj); err != nil {
+			if apierrors.IsNotFound(err) { // skip orphaning objects that don't exist
+				continue
path: pkg/reconciler/finalizer/base_finalizer.go
user: login: karlkfi
body: should probably log this
created_at: 2025-04-08T18:01:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,3 +67,48 @@ func (bf *baseFinalizer) destroy(ctx context.Context) status.MultiError {
 	klog.Info("Destroyer succeeded")
 	return nil
 }
+
+func (bf *baseFinalizer) unmanageObjects(ctx context.Context, rsyncRef client.ObjectKey) status.MultiError {
+	var errs status.MultiError
+	rg := &v1alpha1.ResourceGroup{}
+	if err := bf.Client.Get(ctx, rsyncRef, rg); err != nil {
+		if apierrors.IsNotFound(err) {
+			// ResourceGroup doesn't exist. This could be for a variety of reasons, but
+			// there's no work for the finalizer to do.
+			return nil
+		}
+		return status.APIServerError(err, "failed to get ResourceGroup", rg)
+	}
+	removeAnnotations := []string{
+		metadata.ResourceManagerKey,
+		metadata.OwningInventoryKey,
+		metadata.ResourceManagementKey,
+	}
+	for _, objRef := range rg.Spec.Resources {
+		// Remove metadata for each object
+		obj := &metav1.PartialObjectMetadata{}
+		obj.SetGroupVersionKind(schema.GroupVersionKind{
+			Group: objRef.Group,
+			Kind:  objRef.Kind,
+		})
+		key := client.ObjectKey{
+			Name:      objRef.Name,
+			Namespace: objRef.Namespace,
+		}
+		if err := bf.Client.Get(ctx, key, obj); err != nil {
+			if apierrors.IsNotFound(err) { // skip orphaning objects that don't exist
+				continue
path: pkg/reconciler/finalizer/base_finalizer.go
user: login: karlkfi
body: probably want to log the happy path too.

Probably dont want a log for every object tho. Perhaps some stats, similar to the ones the applier prints, so it's all in one or two lines.
created_at: 2025-04-08T18:02:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,3 +67,48 @@ func (bf *baseFinalizer) destroy(ctx context.Context) status.MultiError {
 	klog.Info("Destroyer succeeded")
 	return nil
 }
+
+func (bf *baseFinalizer) unmanageObjects(ctx context.Context, rsyncRef client.ObjectKey) status.MultiError {
+	var errs status.MultiError
+	rg := &v1alpha1.ResourceGroup{}
+	if err := bf.Client.Get(ctx, rsyncRef, rg); err != nil {
+		if apierrors.IsNotFound(err) {
+			// ResourceGroup doesn't exist. This could be for a variety of reasons, but
+			// there's no work for the finalizer to do.
+			return nil
+		}
+		return status.APIServerError(err, "failed to get ResourceGroup", rg)
+	}
+	removeAnnotations := []string{
+		metadata.ResourceManagerKey,
+		metadata.OwningInventoryKey,
+		metadata.ResourceManagementKey,
+	}
+	for _, objRef := range rg.Spec.Resources {
+		// Remove metadata for each object
+		obj := &metav1.PartialObjectMetadata{}
+		obj.SetGroupVersionKind(schema.GroupVersionKind{
+			Group: objRef.Group,
+			Kind:  objRef.Kind,
+		})
+		key := client.ObjectKey{
+			Name:      objRef.Name,
+			Namespace: objRef.Namespace,
+		}
+		if err := bf.Client.Get(ctx, key, obj); err != nil {
+			if apierrors.IsNotFound(err) { // skip orphaning objects that don't exist
+				continue
+			}
+			errs = status.Append(errs, status.APIServerError(err, "failed to get object", obj))
+			continue
+		}
+		existing := obj.DeepCopy()
+		if core.RemoveAnnotations(obj, removeAnnotations...) {
path: pkg/reconciler/finalizer/base_finalizer.go
user: login: karlkfi
body: What about labels?

We have something like this in the Supervisor code already i think, for abandoning individual objects. Might want to make the code similar.
created_at: 2025-04-08T18:04:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,14 +150,10 @@ func (c *Controller) reconcileFinalizer(ctx context.Context, obj client.Object)
 		policy = metadata.DeletionPropagationPolicyOrphan
 	}
 	switch policy {
-	case metadata.DeletionPropagationPolicyForeground:
+	case metadata.DeletionPropagationPolicyForeground, metadata.DeletionPropagationPolicyOrphan:
 		if _, err := c.Finalizer.AddFinalizer(ctx, obj); err != nil {
 			return err
 		}
-	case metadata.DeletionPropagationPolicyOrphan:
-		if _, err := c.Finalizer.RemoveFinalizer(ctx, obj); err != nil {
-			return err
-		}
 	default:
 		klog.Warningf("%T %s has an invalid value for the annotation %q: %q",
path: pkg/reconciler/finalizer/controller.go
user: login: karlkfi
body: weird that there's a warning here. I guess we dont have any CRD field validation for it... Should we add real validation in a separate PR?
created_at: 2025-04-08T18:05:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -72,10 +70,17 @@ func (f *RepoSyncFinalizer) Finalize(ctx context.Context, syncObj client.Object)
 		return fmt.Errorf("setting Finalizing condition: %w", err)
 	}
 
-	if err := f.deleteManagedObjects(ctx, rs); err != nil {
-		return fmt.Errorf("deleting managed objects: %w", err)
+	deletionPropagation, ok := rs.Annotations[metadata.DeletionPropagationPolicyAnnotationKey]
+	if ok && deletionPropagation == string(metadata.DeletionPropagationPolicyForeground) {
path: pkg/reconciler/finalizer/reposync_finalizer.go
user: login: karlkfi
body: is there a String() method on the enum? if not, we could add one
created_at: 2025-04-08T18:09:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -72,10 +70,17 @@ func (f *RepoSyncFinalizer) Finalize(ctx context.Context, syncObj client.Object)
 		return fmt.Errorf("setting Finalizing condition: %w", err)
 	}
 
-	if err := f.deleteManagedObjects(ctx, rs); err != nil {
-		return fmt.Errorf("deleting managed objects: %w", err)
+	deletionPropagation, ok := rs.Annotations[metadata.DeletionPropagationPolicyAnnotationKey]
+	if ok && deletionPropagation == string(metadata.DeletionPropagationPolicyForeground) {
path: pkg/reconciler/finalizer/reposync_finalizer.go
user: login: karlkfi
body: This If could be a switch.
created_at: 2025-04-08T18:10:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -271,11 +279,176 @@ func TestRepoSyncFinalize(t *testing.T) {
 			expectedStopped:            true,
 			expectedRsyncAfterFinalize: nil,
 		},
+		{
+			name:           "orphan happy path",
+			rsync:          repoSync1.DeepCopy(),
+			deletionPolicy: metadata.DeletionPropagationPolicyOrphan,
+			expectedRsyncBeforeDestroy: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				obj.SetResourceVersion("2")
+				obj.Status.Conditions = []v1beta1.RepoSyncCondition{
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizing,
+						Status:  metav1.ConditionTrue,
+						Reason:  "ResourcesDeleting",
+						Message: "Deleting managed resource objects",
+					},
+				}
+				return obj
+			}(),
+			expectedError:   nil,
+			expectedStopped: true,
+			expectedRsyncAfterFinalize: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				// +1 to remove ReconcilerFinalizing condition
+				// +1 to remove Finalizer
+				// TODO: optimize by combining consecutive updates
+				obj.SetResourceVersion("4")
+				// Finalizer has been removed
+				obj.SetFinalizers(nil)
+				// ReconcilerFinalizing condition added and then removed
+				return obj
+			}(),
+		},
+		{
+			name:           "orphan recovery",
+			deletionPolicy: metadata.DeletionPropagationPolicyOrphan,
+			rsync: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				// +1 to set ReconcilerFinalizerFailure condition
+				obj.SetResourceVersion("3")
+				// Finalizer NOT removed
+				// ReconcilerFinalizing condition added and NOT removed
+				// ReconcilerFinalizerFailure condition added
+				obj.Status.Conditions = []v1beta1.RepoSyncCondition{
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizing,
+						Status:  metav1.ConditionTrue,
+						Reason:  "ResourcesDeleting",
+						Message: "Deleting managed resource objects",
+					},
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizerFailure,
+						Status:  metav1.ConditionTrue,
+						Reason:  "DestroyFailure",
+						Message: "Failed to delete managed resource objects",
+						Errors: []v1beta1.ConfigSyncError{
+							{
+								Code:         "2002",
+								ErrorMessage: "KNV2002: example message: APIServer error: destroy error\n\nFor more information, see https://g.co/cloud/acm-errors#knv2002",
+							},
+						},
+					},
+				}
+				return obj
+			}(),
+			expectedRsyncBeforeDestroy: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// No changes - continue deleting
+				// TODO: Should finalizer re-entry be vissible to the user by toggling a condition?
path: pkg/reconciler/finalizer/reposync_finalizer_test.go
user: login: karlkfi
body: not sure what this is asking
created_at: 2025-04-08T18:11:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -271,11 +279,176 @@ func TestRepoSyncFinalize(t *testing.T) {
 			expectedStopped:            true,
 			expectedRsyncAfterFinalize: nil,
 		},
+		{
+			name:           "orphan happy path",
+			rsync:          repoSync1.DeepCopy(),
+			deletionPolicy: metadata.DeletionPropagationPolicyOrphan,
+			expectedRsyncBeforeDestroy: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				obj.SetResourceVersion("2")
+				obj.Status.Conditions = []v1beta1.RepoSyncCondition{
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizing,
+						Status:  metav1.ConditionTrue,
+						Reason:  "ResourcesDeleting",
+						Message: "Deleting managed resource objects",
+					},
+				}
+				return obj
+			}(),
+			expectedError:   nil,
+			expectedStopped: true,
+			expectedRsyncAfterFinalize: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				// +1 to remove ReconcilerFinalizing condition
+				// +1 to remove Finalizer
+				// TODO: optimize by combining consecutive updates
+				obj.SetResourceVersion("4")
+				// Finalizer has been removed
+				obj.SetFinalizers(nil)
+				// ReconcilerFinalizing condition added and then removed
+				return obj
+			}(),
+		},
+		{
+			name:           "orphan recovery",
+			deletionPolicy: metadata.DeletionPropagationPolicyOrphan,
+			rsync: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				// +1 to set ReconcilerFinalizerFailure condition
+				obj.SetResourceVersion("3")
+				// Finalizer NOT removed
+				// ReconcilerFinalizing condition added and NOT removed
+				// ReconcilerFinalizerFailure condition added
+				obj.Status.Conditions = []v1beta1.RepoSyncCondition{
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizing,
+						Status:  metav1.ConditionTrue,
+						Reason:  "ResourcesDeleting",
+						Message: "Deleting managed resource objects",
+					},
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizerFailure,
+						Status:  metav1.ConditionTrue,
+						Reason:  "DestroyFailure",
+						Message: "Failed to delete managed resource objects",
+						Errors: []v1beta1.ConfigSyncError{
+							{
+								Code:         "2002",
+								ErrorMessage: "KNV2002: example message: APIServer error: destroy error\n\nFor more information, see https://g.co/cloud/acm-errors#knv2002",
+							},
+						},
+					},
+				}
+				return obj
+			}(),
+			expectedRsyncBeforeDestroy: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// No changes - continue deleting
+				// TODO: Should finalizer re-entry be vissible to the user by toggling a condition?
+				obj.SetResourceVersion("3")
+				obj.Status.Conditions = []v1beta1.RepoSyncCondition{
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizing,
+						Status:  metav1.ConditionTrue,
+						Reason:  "ResourcesDeleting",
+						Message: "Deleting managed resource objects",
+					},
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizerFailure,
+						Status:  metav1.ConditionTrue,
+						Reason:  "DestroyFailure",
+						Message: "Failed to delete managed resource objects",
+						Errors: []v1beta1.ConfigSyncError{
+							{
+								Code:         "2002",
+								ErrorMessage: "KNV2002: example message: APIServer error: destroy error\n\nFor more information, see https://g.co/cloud/acm-errors#knv2002",
+							},
+						},
+					},
+				}
+				return obj
+			}(),
+			destroyErrs:     nil,
+			expectedError:   nil,
+			expectedStopped: true,
+			expectedRsyncAfterFinalize: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to remove ReconcilerFinalizing condition
+				// +1 to remove ReconcilerFinalizerFailure condition
+				// +1 to remove Finalizer
+				// TODO: optimize by combining consecutive updates
+				obj.SetResourceVersion("6")
+				// Finalizer has been removed
+				obj.SetFinalizers(nil)
+				// ReconcilerFinalizing condition removed
+				// ReconcilerFinalizerFailure condition removed
+				return obj
+			}(),
+		},
 	}
 
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
-			fakeClient := fake.NewClient(t, scheme, tc.rsync)
+			cm := k8sobjects.ConfigMapObject(core.Name("cm1"), core.Namespace(tc.rsync.GetNamespace()))
+			wantCM := cm.DeepCopy()
+			wantCM.Generation = 1
+			wantCM.ResourceVersion = "1"
+			wantCM.UID = "1"
+
+			if tc.deletionPolicy == metadata.DeletionPropagationPolicyForeground {
path: pkg/reconciler/finalizer/reposync_finalizer_test.go
user: login: karlkfi
body: lots going on in this test code. can you add some comments and/or extract some funcs to make it more understandable?
created_at: 2025-04-08T18:13:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -278,11 +288,177 @@ func TestRootSyncFinalize(t *testing.T) {
 			expectedStopped:            true,
 			expectedRsyncAfterFinalize: nil,
 		},
+		{
+			name:           "orphan happy path",
+			rsync:          rootSync1.DeepCopy(),
+			deletionPolicy: metadata.DeletionPropagationPolicyOrphan,
+			expectedRsyncBeforeDestroy: func() client.Object {
+				obj := rootSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				obj.SetResourceVersion("2")
+				obj.Status.Conditions = []v1beta1.RootSyncCondition{
+					{
+						Type:    v1beta1.RootSyncReconcilerFinalizing,
+						Status:  metav1.ConditionTrue,
+						Reason:  "ResourcesDeleting",
+						Message: "Deleting managed resource objects",
+					},
+				}
+				return obj
+			}(),
+			expectedError:   nil,
+			expectedStopped: true,
+			expectedRsyncAfterFinalize: func() client.Object {
+				obj := rootSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				// +1 to remove ReconcilerFinalizing condition
+				// +1 to remove Finalizer
+				// TODO: optimize by combining consecutive updates
path: pkg/reconciler/finalizer/rootsync_finalizer_test.go
user: login: karlkfi
body: this was an existing todo, right?
created_at: 2025-04-08T18:14:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -304,6 +304,21 @@ func (ms *MemoryStorage) listObjects(gk schema.GroupKind) []*unstructured.Unstru
 	return result
 }
 
+func lookupGVK(obj client.Object, scheme *runtime.Scheme) (schema.GroupVersionKind, error) {
+	switch obj.(type) {
+	case *metav1.PartialObjectMetadata:
path: pkg/syncer/syncertest/fake/storage.go
user: login: karlkfi
body: wanna add this case to kinds.Lookup?
created_at: 2025-04-08T18:15:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -662,6 +666,114 @@ func TestDependencyWithReconciliation(t *testing.T) {
 	}
 }
 
+func verifyManagedBy(nt *nomostest.NT, o client.Object, rsync core.ID) error {
+	return nt.Validate(o.GetName(), o.GetNamespace(), o,
+		testpredicates.IsManagedBy(nt.Scheme, declared.ScopeFromSyncNamespace(rsync.Namespace), rsync.Name))
+}
+
+func verifyUnmanaged(nt *nomostest.NT, o client.Object) error {
+	return nt.Validate(o.GetName(), o.GetNamespace(), o,
+		testpredicates.IsNotManaged(nt.Scheme))
+}
+
+func TestSplitRSyncsWithDeletion(t *testing.T) {
+	ns1 := func() *corev1.Namespace {
+		return k8sobjects.NamespaceObject("ns1")
+	}
+	anvilCRD := func() *apiextensionsv1.CustomResourceDefinition {
+		return anvilV1CRD()
+	}
+	anvilCR := func() *unstructured.Unstructured {
+		o := newAnvilObject("v1", "anvil", 42)
+		o.SetNamespace("foo")
+		return o
+	}
+	tenantNS1 := "tenant-ns-1"
+	tenantNS2 := "tenant-ns-2"
+	cm1 := func() *corev1.ConfigMap {
+		return k8sobjects.ConfigMapObject(core.Name("cm1"), core.Namespace(tenantNS1))
+	}
+	sa1 := func() *corev1.ServiceAccount {
+		return k8sobjects.ServiceAccountObject("sa1", core.Namespace(tenantNS2))
+	}
+	rootSync := nomostest.DefaultRootSyncID
+	rootSync1 := core.RootSyncID("root-sync-1")
+	rootSync2 := core.RootSyncID("root-sync-2")
+	repoSync1 := core.RepoSyncID("repo-sync-1", tenantNS1)
+	repoSync2 := core.RepoSyncID("repo-sync-2", tenantNS2)
+	nt := nomostest.New(t, nomostesting.Lifecycle,
+		ntopts.SyncWithGitSource(rootSync, ntopts.Unstructured),
+		ntopts.SyncWithGitSource(rootSync1, ntopts.Unstructured),
+		ntopts.SyncWithGitSource(rootSync2, ntopts.Unstructured),
+		ntopts.SyncWithGitSource(repoSync1, ntopts.Unstructured),
+		ntopts.SyncWithGitSource(repoSync2, ntopts.Unstructured),
+		ntopts.WithDelegatedControl,
+		ntopts.WithDeletionPropagationPolicy(metadata.DeletionPropagationPolicyOrphan),
+		ntopts.RepoSyncPermissions(policy.CoreAdmin()))
+
+	nt.T.Log("Add all resources to a single RootSync")
+	rootSyncGitRepo := nt.SyncSourceGitReadWriteRepository(rootSync)
+	nt.Must(rootSyncGitRepo.Add("acme/ns1.yaml", ns1()))
+	nt.Must(rootSyncGitRepo.Add("acme/anvil_crd.yaml", anvilCRD()))
+	nt.Must(rootSyncGitRepo.Add("acme/anvil_cr.yaml", anvilCR()))
+	nt.Must(rootSyncGitRepo.Add("acme/cm1.yaml", cm1()))
+	nt.Must(rootSyncGitRepo.Add("acme/sa1.yaml", sa1()))
+	nt.Must(rootSyncGitRepo.CommitAndPush("Add objects"))
+	nt.Must(nt.WatchForAllSyncs())
+	// Validate metadata for each object
+	nt.Must(verifyManagedBy(nt, ns1(), rootSync))
+	nt.Must(verifyManagedBy(nt, anvilCRD(), rootSync))
+	nt.Must(verifyManagedBy(nt, anvilCR(), rootSync))
+	nt.Must(verifyManagedBy(nt, cm1(), rootSync))
+	nt.Must(verifyManagedBy(nt, sa1(), rootSync))
+
+	nt.T.Cleanup(func() {
+		rs := nomostest.RootSyncObjectV1Beta1FromRootRepo(nt, rootSync.Name)
+		nt.Must(nt.KubeClient.Apply(rs))
+		nt.Must(nt.WatchForAllSyncs())
+	})
+	nt.T.Log("Delete the single RootSync to orphan all resources")
+	nt.Must(nt.KubeClient.Delete(k8sobjects.RootSyncV1Beta1(rootSync.Name)))
+	nt.Must(nt.Watcher.WatchForNotFound(kinds.RootSyncV1Beta1(), rootSync.Name, rootSync.Namespace))
+	nt.Must(verifyUnmanaged(nt, ns1()))
+	nt.Must(verifyUnmanaged(nt, anvilCRD()))
+	nt.Must(verifyUnmanaged(nt, anvilCR()))
+	nt.Must(verifyUnmanaged(nt, cm1()))
+	nt.Must(verifyUnmanaged(nt, sa1()))
+
+	nt.T.Cleanup(func() {
+		// Remove objects from the original RSync for cleanup
+		repo := nt.SyncSourceGitReadWriteRepository(rootSync)
+		nt.Must(repo.Remove("acme/ns1.yaml"))
+		nt.Must(repo.Remove("acme/anvil_crd.yaml"))
+		nt.Must(repo.Remove("acme/anvil_cr.yaml"))
+		nt.Must(repo.Remove("acme/cm1.yaml"))
+		nt.Must(repo.Remove("acme/sa1.yaml"))
+		nt.Must(repo.CommitAndPush("Remove orphaned objects"))
+	})
+	nt.T.Log("Adopt each resource by various RootSyncs/RepoSyncs")
+	repo := nt.SyncSourceGitReadWriteRepository(rootSync1)
+	nt.Must(repo.Add("acme/ns1.yaml", ns1()))
+	nt.Must(repo.CommitAndPush("Take ownership of ns1 in root-sync-1"))
+	repo = nt.SyncSourceGitReadWriteRepository(rootSync2)
+	nt.Must(repo.Add("acme/anvil_crd.yaml", anvilCRD()))
+	nt.Must(repo.Add("acme/anvil_cr.yaml", anvilCR()))
+	nt.Must(repo.CommitAndPush("Take ownership of anvil in root-sync-2"))
+	repo = nt.SyncSourceGitReadWriteRepository(repoSync1)
+	nt.Must(repo.Add("acme/cm1.yaml", cm1()))
+	nt.Must(repo.CommitAndPush("Take ownership of cm1 in repo-sync-1"))
+	repo = nt.SyncSourceGitReadWriteRepository(repoSync2)
+	nt.Must(repo.Add("acme/sa1.yaml", sa1()))
+	nt.Must(repo.CommitAndPush("Take ownership of sa1 in repo-sync-2"))
+	// Ready check recreates the root-sync, which we don't want
+	nt.Must(nt.WatchForAllSyncs(nomostest.SkipReadyCheck(), nomostest.SkipRootRepos(rootSync.Name)))
path: e2e/testcases/sync_ordering_test.go
user: login: sdowell
body: It skips based on the list of names that are passed in. It still waits for the other RootSyncs and RepoSyncs to be synced
created_at: 2025-04-08T18:40:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,3 +67,48 @@ func (bf *baseFinalizer) destroy(ctx context.Context) status.MultiError {
 	klog.Info("Destroyer succeeded")
 	return nil
 }
+
+func (bf *baseFinalizer) unmanageObjects(ctx context.Context, rsyncRef client.ObjectKey) status.MultiError {
+	var errs status.MultiError
+	rg := &v1alpha1.ResourceGroup{}
+	if err := bf.Client.Get(ctx, rsyncRef, rg); err != nil {
+		if apierrors.IsNotFound(err) {
+			// ResourceGroup doesn't exist. This could be for a variety of reasons, but
+			// there's no work for the finalizer to do.
path: pkg/reconciler/finalizer/base_finalizer.go
user: login: sdowell
body: > I guess one case is that it hasn't successfully applied yet, so the RG hasn't been created, in which case it's fine to exit. Are there other use cases where it might not?

The only happy path case I could think of is if the applier hasn't successfully run yet. It otherwise would be permanently wedged if some other client deleted the ResourceGroup, although nobody else _should_ delete the ResourceGroup

> Does the reconciler-manager delete the RG after the RSync is NotFound or just after it's got a deletionTimestamp?

It waits for the `reconciler` finalizer to complete first before executing the `reconciler-manager` finalizer.
created_at: 2025-04-08T18:42:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -278,11 +288,177 @@ func TestRootSyncFinalize(t *testing.T) {
 			expectedStopped:            true,
 			expectedRsyncAfterFinalize: nil,
 		},
+		{
+			name:           "orphan happy path",
+			rsync:          rootSync1.DeepCopy(),
+			deletionPolicy: metadata.DeletionPropagationPolicyOrphan,
+			expectedRsyncBeforeDestroy: func() client.Object {
+				obj := rootSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				obj.SetResourceVersion("2")
+				obj.Status.Conditions = []v1beta1.RootSyncCondition{
+					{
+						Type:    v1beta1.RootSyncReconcilerFinalizing,
+						Status:  metav1.ConditionTrue,
+						Reason:  "ResourcesDeleting",
+						Message: "Deleting managed resource objects",
+					},
+				}
+				return obj
+			}(),
+			expectedError:   nil,
+			expectedStopped: true,
+			expectedRsyncAfterFinalize: func() client.Object {
+				obj := rootSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				// +1 to remove ReconcilerFinalizing condition
+				// +1 to remove Finalizer
+				// TODO: optimize by combining consecutive updates
path: pkg/reconciler/finalizer/rootsync_finalizer_test.go
user: login: sdowell
body: yes
created_at: 2025-04-08T18:45:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -271,11 +279,176 @@ func TestRepoSyncFinalize(t *testing.T) {
 			expectedStopped:            true,
 			expectedRsyncAfterFinalize: nil,
 		},
+		{
+			name:           "orphan happy path",
+			rsync:          repoSync1.DeepCopy(),
+			deletionPolicy: metadata.DeletionPropagationPolicyOrphan,
+			expectedRsyncBeforeDestroy: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				obj.SetResourceVersion("2")
+				obj.Status.Conditions = []v1beta1.RepoSyncCondition{
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizing,
+						Status:  metav1.ConditionTrue,
+						Reason:  "ResourcesDeleting",
+						Message: "Deleting managed resource objects",
+					},
+				}
+				return obj
+			}(),
+			expectedError:   nil,
+			expectedStopped: true,
+			expectedRsyncAfterFinalize: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				// +1 to remove ReconcilerFinalizing condition
+				// +1 to remove Finalizer
+				// TODO: optimize by combining consecutive updates
+				obj.SetResourceVersion("4")
+				// Finalizer has been removed
+				obj.SetFinalizers(nil)
+				// ReconcilerFinalizing condition added and then removed
+				return obj
+			}(),
+		},
+		{
+			name:           "orphan recovery",
+			deletionPolicy: metadata.DeletionPropagationPolicyOrphan,
+			rsync: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				// +1 to set ReconcilerFinalizerFailure condition
+				obj.SetResourceVersion("3")
+				// Finalizer NOT removed
+				// ReconcilerFinalizing condition added and NOT removed
+				// ReconcilerFinalizerFailure condition added
+				obj.Status.Conditions = []v1beta1.RepoSyncCondition{
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizing,
+						Status:  metav1.ConditionTrue,
+						Reason:  "ResourcesDeleting",
+						Message: "Deleting managed resource objects",
+					},
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizerFailure,
+						Status:  metav1.ConditionTrue,
+						Reason:  "DestroyFailure",
+						Message: "Failed to delete managed resource objects",
+						Errors: []v1beta1.ConfigSyncError{
+							{
+								Code:         "2002",
+								ErrorMessage: "KNV2002: example message: APIServer error: destroy error\n\nFor more information, see https://g.co/cloud/acm-errors#knv2002",
+							},
+						},
+					},
+				}
+				return obj
+			}(),
+			expectedRsyncBeforeDestroy: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// No changes - continue deleting
+				// TODO: Should finalizer re-entry be vissible to the user by toggling a condition?
path: pkg/reconciler/finalizer/reposync_finalizer_test.go
user: login: sdowell
body: it's just copying existing boilerplate. I didn't really follow the TODO but I can remove if you prefer
created_at: 2025-04-08T18:46:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -304,6 +304,21 @@ func (ms *MemoryStorage) listObjects(gk schema.GroupKind) []*unstructured.Unstru
 	return result
 }
 
+func lookupGVK(obj client.Object, scheme *runtime.Scheme) (schema.GroupVersionKind, error) {
+	switch obj.(type) {
+	case *metav1.PartialObjectMetadata:
path: pkg/syncer/syncertest/fake/storage.go
user: login: sdowell
body: I wasn't sure if that may have any side effects due to how much `kinds.Lookup` is used. I only needed this for the fake client to pass unit tests so this seemed like the change with the smallest blast radius.

If you there aren't any anticipated side effects then I could move this to `kinds.Lookup`
created_at: 2025-04-08T18:48:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -116,16 +117,14 @@ func TestReconcilerFinalizer_Orphan(t *testing.T) {
 		// Namespace1 should NOT have been deleted, because it was orphaned by the RootSync.
 		return nt.Watcher.WatchObject(kinds.Namespace(), namespace1.GetName(), namespace1.GetNamespace(),
 			testwatcher.WatchPredicates(
-				testpredicates.HasAllNomosMetadata(), // metadata NOT removed when orphaned
-				testpredicates.HasLabel(metadata.ApplySetPartOfLabel, applySetID),
+				testpredicates.IsNotManaged(nt.Scheme),
path: e2e/testcases/reconciler_finalizer_test.go
user: login: sdowell
body: It [validates the managed annotation](https://github.com/GoogleContainerTools/kpt-config-sync/blob/main/e2e/nomostest/testpredicates/predicates.go#L674)
created_at: 2025-04-08T19:38:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,3 +67,48 @@ func (bf *baseFinalizer) destroy(ctx context.Context) status.MultiError {
 	klog.Info("Destroyer succeeded")
 	return nil
 }
+
+func (bf *baseFinalizer) unmanageObjects(ctx context.Context, rsyncRef client.ObjectKey) status.MultiError {
+	var errs status.MultiError
+	rg := &v1alpha1.ResourceGroup{}
+	if err := bf.Client.Get(ctx, rsyncRef, rg); err != nil {
+		if apierrors.IsNotFound(err) {
+			// ResourceGroup doesn't exist. This could be for a variety of reasons, but
+			// there's no work for the finalizer to do.
+			return nil
+		}
+		return status.APIServerError(err, "failed to get ResourceGroup", rg)
+	}
+	removeAnnotations := []string{
+		metadata.ResourceManagerKey,
+		metadata.OwningInventoryKey,
+		metadata.ResourceManagementKey,
+	}
+	for _, objRef := range rg.Spec.Resources {
+		// Remove metadata for each object
+		obj := &metav1.PartialObjectMetadata{}
+		obj.SetGroupVersionKind(schema.GroupVersionKind{
+			Group: objRef.Group,
+			Kind:  objRef.Kind,
+		})
+		key := client.ObjectKey{
+			Name:      objRef.Name,
+			Namespace: objRef.Namespace,
+		}
+		if err := bf.Client.Get(ctx, key, obj); err != nil {
+			if apierrors.IsNotFound(err) { // skip orphaning objects that don't exist
+				continue
+			}
+			errs = status.Append(errs, status.APIServerError(err, "failed to get object", obj))
+			continue
+		}
+		existing := obj.DeepCopy()
+		if core.RemoveAnnotations(obj, removeAnnotations...) {
path: pkg/reconciler/finalizer/base_finalizer.go
user: login: sdowell
body: The change only removes the management annotations which block the resources from being adopted by another client/RSync (by either the admission-webhook or in the applier logic).

It was intentional to leave the other Config Sync metadata (annotations/labels), but I'm not opposed to removing all of the metadata if you feel strongly
created_at: 2025-04-08T19:40:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,3 +67,48 @@ func (bf *baseFinalizer) destroy(ctx context.Context) status.MultiError {
 	klog.Info("Destroyer succeeded")
 	return nil
 }
+
+func (bf *baseFinalizer) unmanageObjects(ctx context.Context, rsyncRef client.ObjectKey) status.MultiError {
+	var errs status.MultiError
+	rg := &v1alpha1.ResourceGroup{}
+	if err := bf.Client.Get(ctx, rsyncRef, rg); err != nil {
+		if apierrors.IsNotFound(err) {
+			// ResourceGroup doesn't exist. This could be for a variety of reasons, but
+			// there's no work for the finalizer to do.
+			return nil
+		}
+		return status.APIServerError(err, "failed to get ResourceGroup", rg)
+	}
+	removeAnnotations := []string{
+		metadata.ResourceManagerKey,
+		metadata.OwningInventoryKey,
+		metadata.ResourceManagementKey,
+	}
+	for _, objRef := range rg.Spec.Resources {
+		// Remove metadata for each object
+		obj := &metav1.PartialObjectMetadata{}
+		obj.SetGroupVersionKind(schema.GroupVersionKind{
+			Group: objRef.Group,
+			Kind:  objRef.Kind,
+		})
+		key := client.ObjectKey{
+			Name:      objRef.Name,
+			Namespace: objRef.Namespace,
+		}
+		if err := bf.Client.Get(ctx, key, obj); err != nil {
+			if apierrors.IsNotFound(err) { // skip orphaning objects that don't exist
+				continue
+			}
+			errs = status.Append(errs, status.APIServerError(err, "failed to get object", obj))
+			continue
+		}
+		existing := obj.DeepCopy()
+		if core.RemoveAnnotations(obj, removeAnnotations...) {
path: pkg/reconciler/finalizer/base_finalizer.go
user: login: karlkfi
body: I think removing all the CS metadata would be best, unless there's some use case that needs it.
created_at: 2025-04-08T19:43:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,3 +67,48 @@ func (bf *baseFinalizer) destroy(ctx context.Context) status.MultiError {
 	klog.Info("Destroyer succeeded")
 	return nil
 }
+
+func (bf *baseFinalizer) unmanageObjects(ctx context.Context, rsyncRef client.ObjectKey) status.MultiError {
+	var errs status.MultiError
+	rg := &v1alpha1.ResourceGroup{}
+	if err := bf.Client.Get(ctx, rsyncRef, rg); err != nil {
+		if apierrors.IsNotFound(err) {
+			// ResourceGroup doesn't exist. This could be for a variety of reasons, but
+			// there's no work for the finalizer to do.
path: pkg/reconciler/finalizer/base_finalizer.go
user: login: karlkfi
body: How about we check the RSync `status.lastSyncedCommit`. If it's set, then log an error. If it's not set, then log at info level. 

This might reduce visibility on some cases where the apply started, created the RG, and didn't succeed, but it should increase visibility on most other problematic cases where it's synced at least once before.
created_at: 2025-04-08T19:47:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -271,11 +279,176 @@ func TestRepoSyncFinalize(t *testing.T) {
 			expectedStopped:            true,
 			expectedRsyncAfterFinalize: nil,
 		},
+		{
+			name:           "orphan happy path",
+			rsync:          repoSync1.DeepCopy(),
+			deletionPolicy: metadata.DeletionPropagationPolicyOrphan,
+			expectedRsyncBeforeDestroy: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				obj.SetResourceVersion("2")
+				obj.Status.Conditions = []v1beta1.RepoSyncCondition{
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizing,
+						Status:  metav1.ConditionTrue,
+						Reason:  "ResourcesDeleting",
+						Message: "Deleting managed resource objects",
+					},
+				}
+				return obj
+			}(),
+			expectedError:   nil,
+			expectedStopped: true,
+			expectedRsyncAfterFinalize: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				// +1 to remove ReconcilerFinalizing condition
+				// +1 to remove Finalizer
+				// TODO: optimize by combining consecutive updates
+				obj.SetResourceVersion("4")
+				// Finalizer has been removed
+				obj.SetFinalizers(nil)
+				// ReconcilerFinalizing condition added and then removed
+				return obj
+			}(),
+		},
+		{
+			name:           "orphan recovery",
+			deletionPolicy: metadata.DeletionPropagationPolicyOrphan,
+			rsync: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				// +1 to set ReconcilerFinalizerFailure condition
+				obj.SetResourceVersion("3")
+				// Finalizer NOT removed
+				// ReconcilerFinalizing condition added and NOT removed
+				// ReconcilerFinalizerFailure condition added
+				obj.Status.Conditions = []v1beta1.RepoSyncCondition{
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizing,
+						Status:  metav1.ConditionTrue,
+						Reason:  "ResourcesDeleting",
+						Message: "Deleting managed resource objects",
+					},
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizerFailure,
+						Status:  metav1.ConditionTrue,
+						Reason:  "DestroyFailure",
+						Message: "Failed to delete managed resource objects",
+						Errors: []v1beta1.ConfigSyncError{
+							{
+								Code:         "2002",
+								ErrorMessage: "KNV2002: example message: APIServer error: destroy error\n\nFor more information, see https://g.co/cloud/acm-errors#knv2002",
+							},
+						},
+					},
+				}
+				return obj
+			}(),
+			expectedRsyncBeforeDestroy: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// No changes - continue deleting
+				// TODO: Should finalizer re-entry be vissible to the user by toggling a condition?
path: pkg/reconciler/finalizer/reposync_finalizer_test.go
user: login: karlkfi
body: Hmm. I guess the question was whether the condition should flip back and forth when attempting and retrying. Based on more recent work in the RGC, I think it's undesirable for conditions to flap back and forth. So the answer is probably NO.

So it should be safe to remove these TODOs.
created_at: 2025-04-08T19:50:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -304,6 +304,21 @@ func (ms *MemoryStorage) listObjects(gk schema.GroupKind) []*unstructured.Unstru
 	return result
 }
 
+func lookupGVK(obj client.Object, scheme *runtime.Scheme) (schema.GroupVersionKind, error) {
+	switch obj.(type) {
+	case *metav1.PartialObjectMetadata:
path: pkg/syncer/syncertest/fake/storage.go
user: login: karlkfi
body: Should be fine. But you might want to do it in a different PR and let the tests run.
created_at: 2025-04-08T19:50:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -116,16 +117,14 @@ func TestReconcilerFinalizer_Orphan(t *testing.T) {
 		// Namespace1 should NOT have been deleted, because it was orphaned by the RootSync.
 		return nt.Watcher.WatchObject(kinds.Namespace(), namespace1.GetName(), namespace1.GetNamespace(),
 			testwatcher.WatchPredicates(
-				testpredicates.HasAllNomosMetadata(), // metadata NOT removed when orphaned
-				testpredicates.HasLabel(metadata.ApplySetPartOfLabel, applySetID),
+				testpredicates.IsNotManaged(nt.Scheme),
path: e2e/testcases/reconciler_finalizer_test.go
user: login: karlkfi
body: Looks like both the manager and managed annotations.

If we're gonna remove all the CS meta, we should also validate all of it was removed.
created_at: 2025-04-08T20:41:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,6 +56,9 @@ type MultiRepo struct {
 
 	// RepoSyncPermissions will grant a list of PolicyRules to NS reconcilers
 	RepoSyncPermissions []rbacv1.PolicyRule
+
+	// DeletionPropagationPolicy sets the deletion propagation policy for all RSyncs
+	DeletionPropagationPolicy metadata.DeletionPropagationPolicy
path: e2e/nomostest/ntopts/multi_repo.go
user: login: karlkfi
body: Do we want these to be a pointer so you can set it to nil to omit the annotation?
created_at: 2025-04-08T23:09:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,3 +69,60 @@ func (bf *baseFinalizer) destroy(ctx context.Context) status.MultiError {
 	klog.Info("Destroyer succeeded")
 	return nil
 }
+
+func (bf *baseFinalizer) unmanageObjects(ctx context.Context, rsyncRef client.ObjectKey, hasSynced bool) status.MultiError {
+	var errs status.MultiError
+	rg := &v1alpha1.ResourceGroup{}
+	if err := bf.Client.Get(ctx, rsyncRef, rg); err != nil {
+		if apierrors.IsNotFound(err) {
+			if hasSynced {
+				// The ResourceGroup should exist if there has been a sync
+				klog.Error("ResourceGroup not found when attempting to unmanage objects for synced RSync")
+			} else {
+				// ResourceGroup doesn't exist, but that's expected since there hasn't been a sync
+				klog.Info("ResourceGroup not found when attempting to unmanage objects for unsynced RSync")
+			}
+			return nil
+		}
+		return status.APIServerError(err, "failed to get ResourceGroup", rg)
+	}
+	numNotFound := 0
+	for _, objRef := range rg.Spec.Resources {
+		// Remove metadata for each object
+		obj := &metav1.PartialObjectMetadata{}
+		obj.SetGroupVersionKind(schema.GroupVersionKind{
+			Group: objRef.Group,
+			Kind:  objRef.Kind,
+		})
+		key := client.ObjectKey{
+			Name:      objRef.Name,
+			Namespace: objRef.Namespace,
+		}
+		if err := bf.Client.Get(ctx, key, obj); err != nil {
+			if apierrors.IsNotFound(err) { // skip orphaning objects that don't exist
+				numNotFound++
+				continue
+			}
+			errs = status.Append(errs, status.APIServerError(err, "failed to get object", obj))
+			continue
+		}
+		existing := obj.DeepCopy()
+		// TODO: always remove apply set label with RemoveConfigSyncMetadata?
+		// All callers of RemoveConfigSyncMetadata also call RemoveApplySetPartOfLabel
+		update1 := metadata.RemoveConfigSyncMetadata(obj)
+		update2 := metadata.RemoveApplySetPartOfLabel(obj, bf.ApplySetID)
+		if update1 || update2 {
+			if err := bf.Client.Patch(ctx, obj, client.MergeFrom(existing),
+				client.FieldOwner(configsync.FieldManager)); err != nil {
+				errs = status.Append(errs, status.APIServerError(err, "failed to patch object to remove metadata", obj))
+			}
+		}
+	}
+	numErrs := 0
+	if errs != nil {
+		numErrs = len(errs.Errors())
+	}
+	klog.Infof("Finalizer unmanaged %d objects (%d not found) (%d errors)",
path: pkg/reconciler/finalizer/base_finalizer.go
user: login: karlkfi
body: Since this might be re-run multiple times, we might want to show progress (total, successfully unmanaged, not found, and errored), like we do in the Applier with SyncStats. There's even a `stats.DisabledObjStats` you might be able to re-use if you add a field for notfound. Then you can log it like: `klog.Infof("Finalizer made new progress: %s", unmanageStats)`. Might be good to populate an ObjectStatusMap too, so it's even more similar, and we can see exactly which objects fall into each category in the logs, for debugging. https://github.com/GoogleContainerTools/kpt-config-sync/blob/main/pkg/parse/updater.go#L194-L198
created_at: 2025-04-08T23:29:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,6 +56,9 @@ type MultiRepo struct {
 
 	// RepoSyncPermissions will grant a list of PolicyRules to NS reconcilers
 	RepoSyncPermissions []rbacv1.PolicyRule
+
+	// DeletionPropagationPolicy sets the deletion propagation policy for all RSyncs
+	DeletionPropagationPolicy metadata.DeletionPropagationPolicy
path: e2e/nomostest/ntopts/multi_repo.go
user: login: sdowell
body: done
created_at: 2025-04-08T23:30:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -68,6 +70,8 @@ func newOptStruct(testName, tmpDir string, ntOptions ...ntopts.Opt) *ntopts.New
 			// Default to 1m to keep tests fast.
 			// To override, use WithReconcileTimeout.
 			ReconcileTimeout: ptr.To(1 * time.Minute),
+			// Default to foreground to clean up test resources
+			DeletionPropagationPolicy: &foregroundDeletion,
path: e2e/nomostest/new.go
user: login: karlkfi
body: `ptr.To` should make the indirection easier
created_at: 2025-04-08T23:34:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -27,11 +28,22 @@ import (
 // Lookup returns the GVK of a object based on the types registered with the
 // provided Scheme.
 func Lookup(obj runtime.Object, scheme *runtime.Scheme) (schema.GroupVersionKind, error) {
-	gvk, err := apiutil.GVKForObject(obj, scheme)
-	if err != nil {
-		return schema.GroupVersionKind{}, fmt.Errorf("failed to lookup object type: %w", err)
+	switch obj.(type) {
+	case *metav1.PartialObjectMetadata:
path: pkg/kinds/lookup.go
user: login: karlkfi
body: Maybe I misread this earlier...
In what case is version not required?
created_at: 2025-04-08T23:47:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -271,11 +278,181 @@ func TestRepoSyncFinalize(t *testing.T) {
 			expectedStopped:            true,
 			expectedRsyncAfterFinalize: nil,
 		},
+		{
+			name:           "orphan happy path",
+			rsync:          repoSync1.DeepCopy(),
+			deletionPolicy: metadata.DeletionPropagationPolicyOrphan,
+			expectedRsyncBeforeDestroy: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				obj.SetResourceVersion("2")
+				obj.Status.Conditions = []v1beta1.RepoSyncCondition{
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizing,
+						Status:  metav1.ConditionTrue,
+						Reason:  "ResourcesDeleting",
+						Message: "Deleting managed resource objects",
+					},
+				}
+				return obj
+			}(),
+			expectedError:   nil,
+			expectedStopped: true,
+			expectedRsyncAfterFinalize: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				// +1 to remove ReconcilerFinalizing condition
+				// +1 to remove Finalizer
+				// TODO: optimize by combining consecutive updates
+				obj.SetResourceVersion("4")
+				// Finalizer has been removed
+				obj.SetFinalizers(nil)
+				// ReconcilerFinalizing condition added and then removed
+				return obj
+			}(),
+		},
+		{
+			name:           "orphan recovery",
+			deletionPolicy: metadata.DeletionPropagationPolicyOrphan,
+			rsync: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				// +1 to set ReconcilerFinalizerFailure condition
+				obj.SetResourceVersion("3")
+				// Finalizer NOT removed
+				// ReconcilerFinalizing condition added and NOT removed
+				// ReconcilerFinalizerFailure condition added
+				obj.Status.Conditions = []v1beta1.RepoSyncCondition{
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizing,
+						Status:  metav1.ConditionTrue,
+						Reason:  "ResourcesDeleting",
+						Message: "Deleting managed resource objects",
+					},
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizerFailure,
+						Status:  metav1.ConditionTrue,
+						Reason:  "DestroyFailure",
+						Message: "Failed to delete managed resource objects",
+						Errors: []v1beta1.ConfigSyncError{
+							{
+								Code:         "2002",
+								ErrorMessage: "KNV2002: example message: APIServer error: destroy error\n\nFor more information, see https://g.co/cloud/acm-errors#knv2002",
+							},
+						},
+					},
+				}
+				return obj
+			}(),
+			expectedRsyncBeforeDestroy: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// No changes - continue deleting
+				obj.SetResourceVersion("3")
+				obj.Status.Conditions = []v1beta1.RepoSyncCondition{
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizing,
+						Status:  metav1.ConditionTrue,
+						Reason:  "ResourcesDeleting",
+						Message: "Deleting managed resource objects",
+					},
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizerFailure,
+						Status:  metav1.ConditionTrue,
+						Reason:  "DestroyFailure",
+						Message: "Failed to delete managed resource objects",
+						Errors: []v1beta1.ConfigSyncError{
+							{
+								Code:         "2002",
+								ErrorMessage: "KNV2002: example message: APIServer error: destroy error\n\nFor more information, see https://g.co/cloud/acm-errors#knv2002",
+							},
+						},
+					},
+				}
+				return obj
+			}(),
+			destroyErrs:     nil,
+			expectedError:   nil,
+			expectedStopped: true,
+			expectedRsyncAfterFinalize: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to remove ReconcilerFinalizing condition
+				// +1 to remove ReconcilerFinalizerFailure condition
+				// +1 to remove Finalizer
+				// TODO: optimize by combining consecutive updates
+				obj.SetResourceVersion("6")
+				// Finalizer has been removed
+				obj.SetFinalizers(nil)
+				// ReconcilerFinalizing condition removed
+				// ReconcilerFinalizerFailure condition removed
+				return obj
+			}(),
+		},
 	}
 
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
-			fakeClient := fake.NewClient(t, scheme, tc.rsync)
+			// Set up a toy object referenced by the ResourceGroup
+			cm := k8sobjects.ConfigMapObject(
+				core.Name("cm1"), core.Namespace(tc.rsync.GetNamespace()),
+				core.Annotation("a-key", "a-value"),
+				core.Label("l-key", "l-value"))
+			wantCM := cm.DeepCopy()
+			wantCM.Generation = 1
+			wantCM.ResourceVersion = "1"
+			wantCM.UID = "1"
+
+			applySetID := "apply-set-id"
+			if tc.deletionPolicy == metadata.DeletionPropagationPolicyForeground {
+				wantCM.SetAnnotations(map[string]string{
+					metadata.OwningInventoryKey:          "inventory-id",
+					metadata.ManagementModeAnnotationKey: metadata.ManagementEnabled.String(),
+					metadata.SyncTokenAnnotationKey:      "commit-hash",
+					metadata.GitContextKey:               "git-context",
+					metadata.ResourceManagerKey:          "manager-value",
+					metadata.ResourceIDKey:               "_configmap_example_cm1",
+					"a-key":                              "a-value",
+				})
+				wantCM.SetLabels(map[string]string{
+					metadata.ManagedByKey:        metadata.ManagedByValue,
+					metadata.ApplySetPartOfLabel: applySetID,
+					"l-key":                      "l-value",
+				})
+			} else { // When "Orphan" is used, all CS metadata should be removed from the CM
+				wantCM.ResourceVersion = "2"
+				wantCM.SetAnnotations(map[string]string{
path: pkg/reconciler/finalizer/reposync_finalizer_test.go
user: login: karlkfi
body: Looks like these can be pulled out and put up above the if statement. Then you can call SetConfigSyncMetadata on the wantCM if the policy is foreground.
created_at: 2025-04-08T23:50:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -290,16 +467,20 @@ func TestRepoSyncFinalize(t *testing.T) {
 				rsync := &v1beta1.RepoSync{}
 				err := fakeClient.Get(context.Background(), key, rsync)
 				require.NoError(t, err)
+				core.SetAnnotation(tc.expectedRsyncBeforeDestroy,
+					metadata.DeletionPropagationPolicyAnnotationKey,
+					string(tc.deletionPolicy))
path: pkg/reconciler/finalizer/reposync_finalizer_test.go
user: login: karlkfi
body: nit: `.String()`
created_at: 2025-04-08T23:51:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -313,8 +494,11 @@ func TestRepoSyncFinalize(t *testing.T) {
 			testerrors.AssertEqual(t, tc.expectedError, err)
 
 			assert.Equal(t, tc.expectedStopped, stopped)
-			var expectedObjs []client.Object
+			expectedObjs := []client.Object{rg, wantCM}
 			if tc.expectedRsyncAfterFinalize != nil {
+				core.SetAnnotation(tc.expectedRsyncAfterFinalize,
+					metadata.DeletionPropagationPolicyAnnotationKey,
+					string(tc.deletionPolicy))
path: pkg/reconciler/finalizer/reposync_finalizer_test.go
user: login: karlkfi
body: nit: `.String()`
created_at: 2025-04-08T23:51:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -72,10 +71,17 @@ func (f *RootSyncFinalizer) Finalize(ctx context.Context, syncObj client.Object)
 		return fmt.Errorf("setting Finalizing condition: %w", err)
 	}
 
-	if err := f.deleteManagedObjects(ctx, rs); err != nil {
-		return fmt.Errorf("deleting managed objects: %w", err)
+	switch core.GetAnnotation(rs, metadata.DeletionPropagationPolicyAnnotationKey) {
+	case metadata.DeletionPropagationPolicyForeground.String():
+		if err := f.deleteManagedObjects(ctx, rs); err != nil {
+			return fmt.Errorf("deleting managed objects: %w", err)
+		}
+		klog.Infof("Deletion of managed objects successful")
path: pkg/reconciler/finalizer/rootsync_finalizer.go
user: login: karlkfi
body: This is fine for now, but we should probably add stats to this branch too. Maybe in another PR.
created_at: 2025-04-08T23:52:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -111,6 +111,32 @@ func HasConfigSyncMetadata(obj client.Object) bool {
 	return false
 }
 
+// ConfigSyncMetadata contains fields needed to set all Config Sync metadata on
+// a managed resource.
+type ConfigSyncMetadata struct {
+	ApplySetID      string
path: pkg/metadata/metadata.go
user: login: karlkfi
body: linter was happy with these public fields not having descriptions?
created_at: 2025-04-08T23:54:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,3 +69,60 @@ func (bf *baseFinalizer) destroy(ctx context.Context) status.MultiError {
 	klog.Info("Destroyer succeeded")
 	return nil
 }
+
+func (bf *baseFinalizer) unmanageObjects(ctx context.Context, rsyncRef client.ObjectKey, hasSynced bool) status.MultiError {
+	var errs status.MultiError
+	rg := &v1alpha1.ResourceGroup{}
+	if err := bf.Client.Get(ctx, rsyncRef, rg); err != nil {
+		if apierrors.IsNotFound(err) {
+			if hasSynced {
+				// The ResourceGroup should exist if there has been a sync
+				klog.Error("ResourceGroup not found when attempting to unmanage objects for synced RSync")
+			} else {
+				// ResourceGroup doesn't exist, but that's expected since there hasn't been a sync
+				klog.Info("ResourceGroup not found when attempting to unmanage objects for unsynced RSync")
+			}
+			return nil
+		}
+		return status.APIServerError(err, "failed to get ResourceGroup", rg)
+	}
+	numNotFound := 0
+	for _, objRef := range rg.Spec.Resources {
+		// Remove metadata for each object
+		obj := &metav1.PartialObjectMetadata{}
+		obj.SetGroupVersionKind(schema.GroupVersionKind{
+			Group: objRef.Group,
+			Kind:  objRef.Kind,
+		})
+		key := client.ObjectKey{
+			Name:      objRef.Name,
+			Namespace: objRef.Namespace,
+		}
+		if err := bf.Client.Get(ctx, key, obj); err != nil {
+			if apierrors.IsNotFound(err) { // skip orphaning objects that don't exist
+				numNotFound++
+				continue
+			}
+			errs = status.Append(errs, status.APIServerError(err, "failed to get object", obj))
+			continue
+		}
+		existing := obj.DeepCopy()
+		// TODO: always remove apply set label with RemoveConfigSyncMetadata?
+		// All callers of RemoveConfigSyncMetadata also call RemoveApplySetPartOfLabel
+		update1 := metadata.RemoveConfigSyncMetadata(obj)
+		update2 := metadata.RemoveApplySetPartOfLabel(obj, bf.ApplySetID)
+		if update1 || update2 {
+			if err := bf.Client.Patch(ctx, obj, client.MergeFrom(existing),
+				client.FieldOwner(configsync.FieldManager)); err != nil {
+				errs = status.Append(errs, status.APIServerError(err, "failed to patch object to remove metadata", obj))
+			}
+		}
+	}
+	numErrs := 0
+	if errs != nil {
+		numErrs = len(errs.Errors())
+	}
+	klog.Infof("Finalizer unmanaged %d objects (%d not found) (%d errors)",
path: pkg/reconciler/finalizer/base_finalizer.go
user: login: karlkfi
body: I ok with merging this as-is and having you rewrite the stats logging for both branches in a followup PR, if you really want to get this in for 1.21.0.

I'm also ok with merging it a day late, but we'll need Janet's approval.
created_at: 2025-04-09T00:08:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -68,6 +70,8 @@ func newOptStruct(testName, tmpDir string, ntOptions ...ntopts.Opt) *ntopts.New
 			// Default to 1m to keep tests fast.
 			// To override, use WithReconcileTimeout.
 			ReconcileTimeout: ptr.To(1 * time.Minute),
+			// Default to foreground to clean up test resources
+			DeletionPropagationPolicy: &foregroundDeletion,
path: e2e/nomostest/new.go
user: login: sdowell
body: done
created_at: 2025-04-09T17:22:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -27,11 +28,22 @@ import (
 // Lookup returns the GVK of a object based on the types registered with the
 // provided Scheme.
 func Lookup(obj runtime.Object, scheme *runtime.Scheme) (schema.GroupVersionKind, error) {
-	gvk, err := apiutil.GVKForObject(obj, scheme)
-	if err != nil {
-		return schema.GroupVersionKind{}, fmt.Errorf("failed to lookup object type: %w", err)
+	switch obj.(type) {
+	case *metav1.PartialObjectMetadata:
path: pkg/kinds/lookup.go
user: login: sdowell
body: discussed offline, moved workaround back to fake client
created_at: 2025-04-09T17:26:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -111,6 +111,32 @@ func HasConfigSyncMetadata(obj client.Object) bool {
 	return false
 }
 
+// ConfigSyncMetadata contains fields needed to set all Config Sync metadata on
+// a managed resource.
+type ConfigSyncMetadata struct {
+	ApplySetID      string
path: pkg/metadata/metadata.go
user: login: sdowell
body: Evidently so. Added comments
created_at: 2025-04-09T17:28:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,14 +150,10 @@ func (c *Controller) reconcileFinalizer(ctx context.Context, obj client.Object)
 		policy = metadata.DeletionPropagationPolicyOrphan
 	}
 	switch policy {
-	case metadata.DeletionPropagationPolicyForeground:
+	case metadata.DeletionPropagationPolicyForeground, metadata.DeletionPropagationPolicyOrphan:
 		if _, err := c.Finalizer.AddFinalizer(ctx, obj); err != nil {
 			return err
 		}
-	case metadata.DeletionPropagationPolicyOrphan:
-		if _, err := c.Finalizer.RemoveFinalizer(ctx, obj); err != nil {
-			return err
-		}
 	default:
 		klog.Warningf("%T %s has an invalid value for the annotation %q: %q",
path: pkg/reconciler/finalizer/controller.go
user: login: sdowell
body: Can you add CRD validation for annotations? Maybe using [CEL](https://kubernetes.io/docs/reference/using-api/cel/)? Either way we may not be able to add validation for backwards compatibility reasons.
created_at: 2025-04-09T17:31:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -271,11 +278,181 @@ func TestRepoSyncFinalize(t *testing.T) {
 			expectedStopped:            true,
 			expectedRsyncAfterFinalize: nil,
 		},
+		{
+			name:           "orphan happy path",
+			rsync:          repoSync1.DeepCopy(),
+			deletionPolicy: metadata.DeletionPropagationPolicyOrphan,
+			expectedRsyncBeforeDestroy: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				obj.SetResourceVersion("2")
+				obj.Status.Conditions = []v1beta1.RepoSyncCondition{
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizing,
+						Status:  metav1.ConditionTrue,
+						Reason:  "ResourcesDeleting",
+						Message: "Deleting managed resource objects",
+					},
+				}
+				return obj
+			}(),
+			expectedError:   nil,
+			expectedStopped: true,
+			expectedRsyncAfterFinalize: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				// +1 to remove ReconcilerFinalizing condition
+				// +1 to remove Finalizer
+				// TODO: optimize by combining consecutive updates
+				obj.SetResourceVersion("4")
+				// Finalizer has been removed
+				obj.SetFinalizers(nil)
+				// ReconcilerFinalizing condition added and then removed
+				return obj
+			}(),
+		},
+		{
+			name:           "orphan recovery",
+			deletionPolicy: metadata.DeletionPropagationPolicyOrphan,
+			rsync: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to set ReconcilerFinalizing condition
+				// +1 to set ReconcilerFinalizerFailure condition
+				obj.SetResourceVersion("3")
+				// Finalizer NOT removed
+				// ReconcilerFinalizing condition added and NOT removed
+				// ReconcilerFinalizerFailure condition added
+				obj.Status.Conditions = []v1beta1.RepoSyncCondition{
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizing,
+						Status:  metav1.ConditionTrue,
+						Reason:  "ResourcesDeleting",
+						Message: "Deleting managed resource objects",
+					},
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizerFailure,
+						Status:  metav1.ConditionTrue,
+						Reason:  "DestroyFailure",
+						Message: "Failed to delete managed resource objects",
+						Errors: []v1beta1.ConfigSyncError{
+							{
+								Code:         "2002",
+								ErrorMessage: "KNV2002: example message: APIServer error: destroy error\n\nFor more information, see https://g.co/cloud/acm-errors#knv2002",
+							},
+						},
+					},
+				}
+				return obj
+			}(),
+			expectedRsyncBeforeDestroy: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// No changes - continue deleting
+				obj.SetResourceVersion("3")
+				obj.Status.Conditions = []v1beta1.RepoSyncCondition{
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizing,
+						Status:  metav1.ConditionTrue,
+						Reason:  "ResourcesDeleting",
+						Message: "Deleting managed resource objects",
+					},
+					{
+						Type:    v1beta1.RepoSyncReconcilerFinalizerFailure,
+						Status:  metav1.ConditionTrue,
+						Reason:  "DestroyFailure",
+						Message: "Failed to delete managed resource objects",
+						Errors: []v1beta1.ConfigSyncError{
+							{
+								Code:         "2002",
+								ErrorMessage: "KNV2002: example message: APIServer error: destroy error\n\nFor more information, see https://g.co/cloud/acm-errors#knv2002",
+							},
+						},
+					},
+				}
+				return obj
+			}(),
+			destroyErrs:     nil,
+			expectedError:   nil,
+			expectedStopped: true,
+			expectedRsyncAfterFinalize: func() client.Object {
+				obj := repoSync1.DeepCopy()
+				// +1 to remove ReconcilerFinalizing condition
+				// +1 to remove ReconcilerFinalizerFailure condition
+				// +1 to remove Finalizer
+				// TODO: optimize by combining consecutive updates
+				obj.SetResourceVersion("6")
+				// Finalizer has been removed
+				obj.SetFinalizers(nil)
+				// ReconcilerFinalizing condition removed
+				// ReconcilerFinalizerFailure condition removed
+				return obj
+			}(),
+		},
 	}
 
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
-			fakeClient := fake.NewClient(t, scheme, tc.rsync)
+			// Set up a toy object referenced by the ResourceGroup
+			cm := k8sobjects.ConfigMapObject(
+				core.Name("cm1"), core.Namespace(tc.rsync.GetNamespace()),
+				core.Annotation("a-key", "a-value"),
+				core.Label("l-key", "l-value"))
+			wantCM := cm.DeepCopy()
+			wantCM.Generation = 1
+			wantCM.ResourceVersion = "1"
+			wantCM.UID = "1"
+
+			applySetID := "apply-set-id"
+			if tc.deletionPolicy == metadata.DeletionPropagationPolicyForeground {
+				wantCM.SetAnnotations(map[string]string{
+					metadata.OwningInventoryKey:          "inventory-id",
+					metadata.ManagementModeAnnotationKey: metadata.ManagementEnabled.String(),
+					metadata.SyncTokenAnnotationKey:      "commit-hash",
+					metadata.GitContextKey:               "git-context",
+					metadata.ResourceManagerKey:          "manager-value",
+					metadata.ResourceIDKey:               "_configmap_example_cm1",
+					"a-key":                              "a-value",
+				})
+				wantCM.SetLabels(map[string]string{
+					metadata.ManagedByKey:        metadata.ManagedByValue,
+					metadata.ApplySetPartOfLabel: applySetID,
+					"l-key":                      "l-value",
+				})
+			} else { // When "Orphan" is used, all CS metadata should be removed from the CM
+				wantCM.ResourceVersion = "2"
+				wantCM.SetAnnotations(map[string]string{
path: pkg/reconciler/finalizer/reposync_finalizer_test.go
user: login: sdowell
body: It was intentional to not use `SetConfigSyncMetadata` to compute the expected value. I want the assertion to be explicit/unambiguous and not rely on the business logic to test the business logic.

Relevant/recent [tott](https://goto.google.com/tott/741)
created_at: 2025-04-09T17:34:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -290,16 +467,20 @@ func TestRepoSyncFinalize(t *testing.T) {
 				rsync := &v1beta1.RepoSync{}
 				err := fakeClient.Get(context.Background(), key, rsync)
 				require.NoError(t, err)
+				core.SetAnnotation(tc.expectedRsyncBeforeDestroy,
+					metadata.DeletionPropagationPolicyAnnotationKey,
+					string(tc.deletionPolicy))
path: pkg/reconciler/finalizer/reposync_finalizer_test.go
user: login: sdowell
body: fixed here and in other places
created_at: 2025-04-09T17:35:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,14 +150,10 @@ func (c *Controller) reconcileFinalizer(ctx context.Context, obj client.Object)
 		policy = metadata.DeletionPropagationPolicyOrphan
 	}
 	switch policy {
-	case metadata.DeletionPropagationPolicyForeground:
+	case metadata.DeletionPropagationPolicyForeground, metadata.DeletionPropagationPolicyOrphan:
 		if _, err := c.Finalizer.AddFinalizer(ctx, obj); err != nil {
 			return err
 		}
-	case metadata.DeletionPropagationPolicyOrphan:
-		if _, err := c.Finalizer.RemoveFinalizer(ctx, obj); err != nil {
-			return err
-		}
 	default:
 		klog.Warningf("%T %s has an invalid value for the annotation %q: %q",
path: pkg/reconciler/finalizer/controller.go
user: login: karlkfi
body: > Can you add CRD validation for annotations?

Not that I'm aware of. You get the annotations for free without specifying them in the CRD. I don't think you can add validation to unspecified fields.

We should validate them in the code, but it doesn't need to block in this PR.
created_at: 2025-04-09T17:47:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,14 +150,10 @@ func (c *Controller) reconcileFinalizer(ctx context.Context, obj client.Object)
 		policy = metadata.DeletionPropagationPolicyOrphan
 	}
 	switch policy {
-	case metadata.DeletionPropagationPolicyForeground:
+	case metadata.DeletionPropagationPolicyForeground, metadata.DeletionPropagationPolicyOrphan:
 		if _, err := c.Finalizer.AddFinalizer(ctx, obj); err != nil {
 			return err
 		}
-	case metadata.DeletionPropagationPolicyOrphan:
-		if _, err := c.Finalizer.RemoveFinalizer(ctx, obj); err != nil {
-			return err
-		}
 	default:
 		klog.Warningf("%T %s has an invalid value for the annotation %q: %q",
path: pkg/reconciler/finalizer/controller.go
user: login: karlkfi
body: b/409592878
created_at: 2025-04-09T19:42:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,3 +69,60 @@ func (bf *baseFinalizer) destroy(ctx context.Context) status.MultiError {
 	klog.Info("Destroyer succeeded")
 	return nil
 }
+
+func (bf *baseFinalizer) unmanageObjects(ctx context.Context, rsyncRef client.ObjectKey, hasSynced bool) status.MultiError {
+	var errs status.MultiError
+	rg := &v1alpha1.ResourceGroup{}
+	if err := bf.Client.Get(ctx, rsyncRef, rg); err != nil {
+		if apierrors.IsNotFound(err) {
+			if hasSynced {
+				// The ResourceGroup should exist if there has been a sync
+				klog.Error("ResourceGroup not found when attempting to unmanage objects for synced RSync")
+			} else {
+				// ResourceGroup doesn't exist, but that's expected since there hasn't been a sync
+				klog.Info("ResourceGroup not found when attempting to unmanage objects for unsynced RSync")
+			}
+			return nil
+		}
+		return status.APIServerError(err, "failed to get ResourceGroup", rg)
+	}
+	numNotFound := 0
+	for _, objRef := range rg.Spec.Resources {
+		// Remove metadata for each object
+		obj := &metav1.PartialObjectMetadata{}
+		obj.SetGroupVersionKind(schema.GroupVersionKind{
+			Group: objRef.Group,
+			Kind:  objRef.Kind,
+		})
+		key := client.ObjectKey{
+			Name:      objRef.Name,
+			Namespace: objRef.Namespace,
+		}
+		if err := bf.Client.Get(ctx, key, obj); err != nil {
+			if apierrors.IsNotFound(err) { // skip orphaning objects that don't exist
+				numNotFound++
+				continue
+			}
+			errs = status.Append(errs, status.APIServerError(err, "failed to get object", obj))
+			continue
+		}
+		existing := obj.DeepCopy()
+		// TODO: always remove apply set label with RemoveConfigSyncMetadata?
+		// All callers of RemoveConfigSyncMetadata also call RemoveApplySetPartOfLabel
+		update1 := metadata.RemoveConfigSyncMetadata(obj)
+		update2 := metadata.RemoveApplySetPartOfLabel(obj, bf.ApplySetID)
+		if update1 || update2 {
+			if err := bf.Client.Patch(ctx, obj, client.MergeFrom(existing),
+				client.FieldOwner(configsync.FieldManager)); err != nil {
+				errs = status.Append(errs, status.APIServerError(err, "failed to patch object to remove metadata", obj))
+			}
+		}
+	}
+	numErrs := 0
+	if errs != nil {
+		numErrs = len(errs.Errors())
+	}
+	klog.Infof("Finalizer unmanaged %d objects (%d not found) (%d errors)",
path: pkg/reconciler/finalizer/base_finalizer.go
user: login: karlkfi
body: b/409594209
created_at: 2025-04-09T19:42:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -72,10 +71,17 @@ func (f *RootSyncFinalizer) Finalize(ctx context.Context, syncObj client.Object)
 		return fmt.Errorf("setting Finalizing condition: %w", err)
 	}
 
-	if err := f.deleteManagedObjects(ctx, rs); err != nil {
-		return fmt.Errorf("deleting managed objects: %w", err)
+	switch core.GetAnnotation(rs, metadata.DeletionPropagationPolicyAnnotationKey) {
+	case metadata.DeletionPropagationPolicyForeground.String():
+		if err := f.deleteManagedObjects(ctx, rs); err != nil {
+			return fmt.Errorf("deleting managed objects: %w", err)
+		}
+		klog.Infof("Deletion of managed objects successful")
path: pkg/reconciler/finalizer/rootsync_finalizer.go
user: login: karlkfi
body: b/409594209
created_at: 2025-04-09T19:42:43Z
author_association: CONTRIBUTOR

PR comments:
user: login: google-oss-prow[bot]
created_at: 2025-04-09T18:18:34Z
body: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/GoogleContainerTools/kpt-config-sync/pull/1626#pullrequestreview-2754343247" title="LGTM">janetkuo</a>*, *<a href="https://github.com/GoogleContainerTools/kpt-config-sync/pull/1626#pullrequestreview-2754271359" title="Approved">karlkfi</a>*

The full list of commands accepted by this bot can be found [here](https://oss.gprow.dev/command-help?repo=GoogleContainerTools%2Fkpt-config-sync).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/GoogleContainerTools/kpt-config-sync/blob/main/OWNERS)~~ [janetkuo,karlkfi]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
author_association: NONE