diff --git a/src/Umbraco.Core/Services/ContentVersionOperationService.cs b/src/Umbraco.Core/Services/ContentVersionOperationService.cs
new file mode 100644
index 0000000000..0193b2fee7
--- /dev/null
+++ b/src/Umbraco.Core/Services/ContentVersionOperationService.cs
@@ -0,0 +1,230 @@
+// src/Umbraco.Core/Services/ContentVersionOperationService.cs
+using Microsoft.Extensions.Logging;
+using Umbraco.Cms.Core.Events;
+using Umbraco.Cms.Core.Models;
+using Umbraco.Cms.Core.Notifications;
+using Umbraco.Cms.Core.Persistence.Repositories;
+using Umbraco.Cms.Core.Scoping;
+using Umbraco.Extensions;
+
+namespace Umbraco.Cms.Core.Services;
+
+///
+/// Implements content version operations (retrieving versions, rollback, deleting versions).
+///
+public class ContentVersionOperationService : ContentServiceBase, IContentVersionOperationService
+{
+ private readonly ILogger _logger;
+ // v1.2 Fix (Issue 3.3): Added IContentCrudService for proper save with notifications
+ private readonly IContentCrudService _crudService;
+
+ public ContentVersionOperationService(
+ ICoreScopeProvider provider,
+ ILoggerFactory loggerFactory,
+ IEventMessagesFactory eventMessagesFactory,
+ IDocumentRepository documentRepository,
+ IAuditService auditService,
+ IUserIdKeyResolver userIdKeyResolver,
+ IContentCrudService crudService) // v1.2: Added for Rollback save operation
+ : base(provider, loggerFactory, eventMessagesFactory, documentRepository, auditService, userIdKeyResolver)
+ {
+ _logger = loggerFactory.CreateLogger();
+ _crudService = crudService;
+ }
+
+ #region Version Retrieval
+
+ ///
+ public IContent? GetVersion(int versionId)
+ {
+ using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
+ scope.ReadLock(Constants.Locks.ContentTree);
+ return DocumentRepository.GetVersion(versionId);
+ }
+
+ ///
+ public IEnumerable GetVersions(int id)
+ {
+ using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
+ scope.ReadLock(Constants.Locks.ContentTree);
+ return DocumentRepository.GetAllVersions(id);
+ }
+
+ ///
+ public IEnumerable GetVersionsSlim(int id, int skip, int take)
+ {
+ using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
+ scope.ReadLock(Constants.Locks.ContentTree);
+ return DocumentRepository.GetAllVersionsSlim(id, skip, take);
+ }
+
+ ///
+ public IEnumerable GetVersionIds(int id, int maxRows)
+ {
+ // v1.3 Fix (Issue 3.1): Added input validation to match interface documentation.
+ // The interface documents ArgumentOutOfRangeException for maxRows <= 0.
+ if (maxRows <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maxRows), maxRows, "Value must be greater than zero.");
+ }
+
+ using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
+ // v1.1 Fix (Issue 2.3): Added ReadLock for consistency with other read operations.
+ // The original ContentService.GetVersionIds did not acquire a ReadLock, which was
+ // inconsistent with GetVersion, GetVersions, and GetVersionsSlim.
+ scope.ReadLock(Constants.Locks.ContentTree);
+ return DocumentRepository.GetVersionIds(id, maxRows);
+ }
+
+ #endregion
+
+ #region Rollback
+
+ ///
+ public OperationResult Rollback(int id, int versionId, string culture = "*", int userId = Constants.Security.SuperUserId)
+ {
+ EventMessages evtMsgs = EventMessagesFactory.Get();
+
+ // v1.1 Fix (Issue 2.1): Use a single scope for the entire operation to eliminate
+ // TOCTOU race condition. Previously used separate read and write scopes which
+ // could allow concurrent modification between reading content and writing changes.
+ using ICoreScope scope = ScopeProvider.CreateCoreScope();
+
+ // Read operations - acquire read lock first
+ scope.ReadLock(Constants.Locks.ContentTree);
+ IContent? content = DocumentRepository.Get(id);
+ // v1.1 Fix: Use DocumentRepository.GetVersion directly instead of calling
+ // this.GetVersion() which would create a nested scope
+ IContent? version = DocumentRepository.GetVersion(versionId);
+
+ // Null checks - cannot rollback if content or version is missing, or if trashed
+ if (content == null || version == null || content.Trashed)
+ {
+ scope.Complete();
+ return new OperationResult(OperationResultType.FailedCannot, evtMsgs);
+ }
+
+ var rollingBackNotification = new ContentRollingBackNotification(content, evtMsgs);
+ if (scope.Notifications.PublishCancelable(rollingBackNotification))
+ {
+ scope.Complete();
+ return OperationResult.Cancel(evtMsgs);
+ }
+
+ // Copy the changes from the version
+ content.CopyFrom(version, culture);
+
+ // v1.2 Fix (Issue 2.1): Use CrudService.Save to preserve ContentSaving/ContentSaved notifications.
+ // The original ContentService.Rollback called Save(content, userId) which fires these notifications.
+ // Using DocumentRepository.Save directly would bypass validation, audit trail, and cache invalidation.
+ // v1.3 Fix (Issue 3.2): Removed explicit WriteLock - CrudService.Save handles its own locking internally.
+ // v1.3 Fix (Issue 3.4): Fixed return type from OperationResult to OperationResult.
+ OperationResult saveResult = _crudService.Save(content, userId);
+ if (!saveResult.Success)
+ {
+ _logger.LogError("User '{UserId}' was unable to rollback content '{ContentId}' to version '{VersionId}'", userId, id, versionId);
+ scope.Complete();
+ return new OperationResult(OperationResultType.Failed, evtMsgs);
+ }
+
+ // Only publish success notification if save succeeded
+ scope.Notifications.Publish(
+ new ContentRolledBackNotification(content, evtMsgs).WithStateFrom(rollingBackNotification));
+
+ // Logging & Audit
+ _logger.LogInformation("User '{UserId}' rolled back content '{ContentId}' to version '{VersionId}'", userId, content.Id, version.VersionId);
+ Audit(AuditType.RollBack, userId, content.Id, $"Content '{content.Name}' was rolled back to version '{version.VersionId}'");
+
+ scope.Complete();
+
+ return OperationResult.Succeed(evtMsgs);
+ }
+
+ #endregion
+
+ #region Version Deletion
+
+ ///
+ public void DeleteVersions(int id, DateTime versionDate, int userId = Constants.Security.SuperUserId)
+ {
+ EventMessages evtMsgs = EventMessagesFactory.Get();
+
+ using ICoreScope scope = ScopeProvider.CreateCoreScope();
+
+ var deletingVersionsNotification = new ContentDeletingVersionsNotification(id, evtMsgs, dateToRetain: versionDate);
+ if (scope.Notifications.PublishCancelable(deletingVersionsNotification))
+ {
+ scope.Complete();
+ return;
+ }
+
+ scope.WriteLock(Constants.Locks.ContentTree);
+ DocumentRepository.DeleteVersions(id, versionDate);
+
+ scope.Notifications.Publish(
+ new ContentDeletedVersionsNotification(id, evtMsgs, dateToRetain: versionDate).WithStateFrom(deletingVersionsNotification));
+ Audit(AuditType.Delete, userId, Constants.System.Root, "Delete (by version date)");
+
+ scope.Complete();
+ }
+
+ ///
+ public void DeleteVersion(int id, int versionId, bool deletePriorVersions, int userId = Constants.Security.SuperUserId)
+ {
+ EventMessages evtMsgs = EventMessagesFactory.Get();
+
+ using ICoreScope scope = ScopeProvider.CreateCoreScope();
+
+ // v1.2 Fix (Issue 3.1): Acquire WriteLock once at the start instead of multiple times.
+ // This simplifies the code and avoids the read→write lock upgrade pattern.
+ scope.WriteLock(Constants.Locks.ContentTree);
+
+ var deletingVersionsNotification = new ContentDeletingVersionsNotification(id, evtMsgs, versionId);
+ if (scope.Notifications.PublishCancelable(deletingVersionsNotification))
+ {
+ scope.Complete();
+ return;
+ }
+
+ // v1.2 Fix (Issue 2.2): Preserve original double-notification behavior for deletePriorVersions.
+ // The original implementation called DeleteVersions() which fired its own notifications.
+ // We inline the notification firing to maintain backward compatibility.
+ // v1.3 Fix (Issue 3.6): Clarification - if prior versions deletion is cancelled, we still
+ // proceed with deleting the specific version. This matches original ContentService behavior.
+ if (deletePriorVersions)
+ {
+ IContent? versionContent = DocumentRepository.GetVersion(versionId);
+ DateTime cutoffDate = versionContent?.UpdateDate ?? DateTime.UtcNow;
+
+ // Publish notifications for prior versions (matching original behavior)
+ var priorVersionsNotification = new ContentDeletingVersionsNotification(id, evtMsgs, dateToRetain: cutoffDate);
+ if (!scope.Notifications.PublishCancelable(priorVersionsNotification))
+ {
+ DocumentRepository.DeleteVersions(id, cutoffDate);
+ scope.Notifications.Publish(
+ new ContentDeletedVersionsNotification(id, evtMsgs, dateToRetain: cutoffDate)
+ .WithStateFrom(priorVersionsNotification));
+
+ // v1.3 Fix (Issue 3.3): Add audit entry for prior versions deletion.
+ // The original DeleteVersions() method created its own audit entry.
+ Audit(AuditType.Delete, userId, Constants.System.Root, "Delete (by version date)");
+ }
+ }
+
+ IContent? c = DocumentRepository.Get(id);
+
+ // Don't delete the current or published version
+ if (c?.VersionId != versionId && c?.PublishedVersionId != versionId)
+ {
+ DocumentRepository.DeleteVersion(versionId);
+ }
+
+ scope.Notifications.Publish(
+ new ContentDeletedVersionsNotification(id, evtMsgs, versionId).WithStateFrom(deletingVersionsNotification));
+ Audit(AuditType.Delete, userId, Constants.System.Root, "Delete (by version)");
+
+ scope.Complete();
+ }
+
+ #endregion
+}