diff --git a/docs/plans/2025-12-23-contentservice-refactor-phase5-implementation-critical-review-1.md b/docs/plans/2025-12-23-contentservice-refactor-phase5-implementation-critical-review-1.md
new file mode 100644
index 0000000000..3c74247de8
--- /dev/null
+++ b/docs/plans/2025-12-23-contentservice-refactor-phase5-implementation-critical-review-1.md
@@ -0,0 +1,399 @@
+# Critical Implementation Review: ContentService Refactoring Phase 5
+
+**Plan Under Review:** `docs/plans/2025-12-23-contentservice-refactor-phase5-implementation.md`
+**Review Date:** 2025-12-23
+**Reviewer:** Critical Implementation Review (Automated)
+**Version:** 1
+
+---
+
+## 1. Overall Assessment
+
+**Strengths:**
+- Follows established patterns from Phases 1-4 (ContentServiceBase inheritance, lazy service resolution, field-then-property pattern)
+- Well-documented interface with versioning policy and implementation notes
+- Sensible grouping of related operations (publish, unpublish, schedule, branch)
+- Naming collision with `IContentPublishingService` is explicitly addressed
+- Task breakdown is clear with verification steps and commits
+
+**Major Concerns:**
+1. **Thread safety issue** with `_contentSettings` mutation during `OnChange` callback
+2. **Circular dependency risk** between ContentPublishOperationService and ContentService
+3. **Missing internal method exposure strategy** for `CommitDocumentChangesInternal`
+4. **Incomplete method migration** - some helper methods listed for deletion are still needed by facade
+5. **No cancellation token support** for long-running branch operations
+
+---
+
+## 2. Critical Issues
+
+### 2.1 Thread Safety: ContentSettings Mutation Without Synchronization
+
+**Location:** Task 2, Step 1 - Constructor lines 352-353
+
+```csharp
+_contentSettings = optionsMonitor?.CurrentValue;
+optionsMonitor.OnChange(settings => _contentSettings = settings);
+```
+
+**Why It Matters:**
+- If settings change during a multi-culture publish operation, `_contentSettings` could be read mid-operation with inconsistent values
+- This is a **race condition** that could cause intermittent, hard-to-reproduce bugs
+- Same pattern exists in ContentService and has been propagated unchanged
+
+**Actionable Fix:**
+```csharp
+private ContentSettings _contentSettings;
+private readonly object _contentSettingsLock = new object();
+
+// In constructor:
+lock (_contentSettingsLock)
+{
+ _contentSettings = optionsMonitor.CurrentValue;
+}
+optionsMonitor.OnChange(settings =>
+{
+ lock (_contentSettingsLock)
+ {
+ _contentSettings = settings;
+ }
+});
+
+// Add thread-safe accessor property:
+private ContentSettings ContentSettings
+{
+ get
+ {
+ lock (_contentSettingsLock)
+ {
+ return _contentSettings;
+ }
+ }
+}
+```
+
+**Priority:** HIGH - Race conditions in publishing can corrupt content state
+
+---
+
+### 2.2 Circular Dependency Risk: GetById Calls
+
+**Location:** Task 2 - IsPathPublishable, GetParent helper methods (lines 758-769)
+
+**Problem:**
+The plan shows `IsPathPublishable` calling `GetById` and `GetParent` which should use `_crudService`. However:
+
+1. Line 804 in current ContentService: `IContent? parent = GetById(content.ParentId);` - this is a ContentService method
+2. The new service needs access to CRUD operations but also needs to avoid circular dependencies
+
+**Why It Matters:**
+- If ContentPublishOperationService calls ContentService.GetById, and ContentService delegates to ContentPublishOperationService, you create a runtime circular dependency
+- Lazy resolution can mask this at startup but cause stack overflows at runtime
+
+**Actionable Fix:**
+Ensure all content retrieval in ContentPublishOperationService goes through `_crudService`:
+```csharp
+// In IsPathPublishable - use _crudService.GetByIds instead of GetById
+IContent? parent = parentId == Constants.System.Root
+ ? null
+ : _crudService.GetByIds(new[] { content.ParentId }).FirstOrDefault();
+```
+
+Verify `IContentCrudService.GetByIds(int[])` overload exists, or add it.
+
+**Priority:** HIGH - Circular dependencies cause runtime failures
+
+---
+
+### 2.3 Internal Method CommitDocumentChangesInternal Not Exposed
+
+**Location:** Task 2, lines 477-498
+
+**Problem:**
+`CommitDocumentChangesInternal` is marked as `internal` in the plan but:
+1. It's called from `Publish`, `Unpublish`, and branch operations
+2. It's NOT on the `IContentPublishOperationService` interface
+3. Other services that need to commit document changes (like MoveToRecycleBin) cannot call it
+
+**Why It Matters:**
+- `MoveToRecycleBin` in ContentService (the facade) needs to unpublish before moving to bin
+- If `CommitDocumentChangesInternal` is only accessible within ContentPublishOperationService, the facade cannot perform coordinated operations
+- This breaks the "facade orchestrates, services execute" pattern
+
+**Actionable Fix - Two Options:**
+
+**Option A: Add to interface (recommended for testability)**
+```csharp
+// Add to IContentPublishOperationService:
+///
+/// Commits pending document publishing/unpublishing changes. Internal use only.
+///
+///
+/// This is an advanced API for orchestrating publish operations with other state changes.
+/// Most consumers should use or instead.
+///
+[EditorBrowsable(EditorBrowsableState.Advanced)]
+PublishResult CommitDocumentChanges(IContent content, int userId = Constants.Security.SuperUserId);
+```
+
+**Option B: Keep MoveToRecycleBin implementation in ContentPublishOperationService**
+- Add `MoveToRecycleBin` to IContentPublishOperationService
+- ContentMoveOperationService.Move stays separate (no unpublish)
+- Facade calls PublishOperationService.MoveToRecycleBin for recycle bin moves
+
+**Priority:** HIGH - Architecture decision needed before implementation
+
+---
+
+### 2.4 Missing Null Check in GetContentSchedulesByIds
+
+**Location:** Task 2, lines 606-609
+
+```csharp
+public IDictionary> GetContentSchedulesByIds(Guid[] keys)
+{
+ // Copy from ContentService lines 759-783
+}
+```
+
+**Problem:**
+No validation that `keys` is not null or empty. Passing `null` will throw NullReferenceException deep in repository code.
+
+**Actionable Fix:**
+```csharp
+public IDictionary> GetContentSchedulesByIds(Guid[] keys)
+{
+ ArgumentNullException.ThrowIfNull(keys);
+ if (keys.Length == 0)
+ {
+ return new Dictionary>();
+ }
+ // ... rest of implementation
+}
+```
+
+**Priority:** MEDIUM - Defensive programming for public API
+
+---
+
+### 2.5 No Cancellation Support for PublishBranch
+
+**Location:** Task 2, lines 547-566 (PublishBranch methods)
+
+**Problem:**
+`PublishBranch` can process thousands of documents in a single call. There's no `CancellationToken` parameter, meaning:
+1. No way to abort long-running operations
+2. HTTP request timeouts won't stop the operation server-side
+3. Could tie up database connections indefinitely
+
+**Why It Matters:**
+- A branch publish on a large site (10,000+ nodes) could take minutes
+- User cancellation, deployment restarts, or timeouts should be respected
+
+**Actionable Fix:**
+```csharp
+// Interface:
+IEnumerable PublishBranch(
+ IContent content,
+ PublishBranchFilter publishBranchFilter,
+ string[] cultures,
+ int userId = Constants.Security.SuperUserId,
+ CancellationToken cancellationToken = default);
+
+// Implementation: check cancellation in loop
+foreach (var descendant in descendants)
+{
+ cancellationToken.ThrowIfCancellationRequested();
+ // process...
+}
+```
+
+**Note:** This is a breaking change suggestion. If not feasible now, add a TODO for Phase 8.
+
+**Priority:** MEDIUM - Important for production resilience but not blocking
+
+---
+
+### 2.6 Potential N+1 Query in GetPublishedDescendantsLocked
+
+**Location:** Task 2, lines 657-660
+
+**Problem:**
+The plan says "Copy from ContentService lines 2279-2301" for `GetPublishedDescendantsLocked`. The current implementation uses path-based queries which are efficient, BUT the helper `HasChildren(int id)` in lines 747-754 makes a separate database call.
+
+Looking at `CommitDocumentChangesInternal` line 1349:
+```csharp
+if (!branchOne && isNew == false && previouslyPublished == false && HasChildren(content.Id))
+```
+
+Then line 1351 calls `GetPublishedDescendantsLocked(content)`.
+
+**Why It Matters:**
+- `HasChildren` + `GetPublishedDescendantsLocked` = 2 database round trips
+- For batch operations, this adds up
+
+**Actionable Fix:**
+Consider combining into a single query or caching:
+```csharp
+// Instead of:
+if (HasChildren(content.Id))
+{
+ var descendants = GetPublishedDescendantsLocked(content).ToArray();
+ if (descendants.Length > 0) { ... }
+}
+
+// Use:
+var descendants = GetPublishedDescendantsLocked(content).ToArray();
+if (descendants.Length > 0) { ... }
+// HasChildren check is implicit - if no descendants, array is empty
+```
+
+**Priority:** LOW - Micro-optimization, existing code works
+
+---
+
+## 3. Minor Issues & Improvements
+
+### 3.1 Duplicate Helper Methods
+
+**Location:** Task 2, lines 704-724
+
+`HasUnsavedChanges`, `IsDefaultCulture`, `IsMandatoryCulture`, `GetLanguageDetailsForAuditEntry` are being copied.
+
+**Suggestion:** These are pure utility functions. Consider:
+1. Moving to `ContentServiceBase` as `protected` methods (for all operation services)
+2. Or creating a `ContentServiceHelpers` static class
+
+This avoids duplication if Phase 6/7 services need the same helpers.
+
+---
+
+### 3.2 Magic String in Publish Method
+
+**Location:** Task 2, line 386
+
+```csharp
+cultures.Select(x => x.EnsureCultureCode()!).ToArray();
+```
+
+The `"*"` wildcard is used in multiple places. Consider:
+```csharp
+private const string AllCulturesWildcard = "*";
+```
+
+This makes the code more self-documenting and prevents typos.
+
+---
+
+### 3.3 Inconsistent Null Handling in Interface
+
+**Location:** Task 1, interface definition
+
+- `Unpublish` accepts `string? culture = "*"` (nullable with default)
+- `SendToPublication` accepts `IContent? content` (nullable)
+- `IsPathPublished` accepts `IContent? content` (nullable)
+
+But `Publish` requires non-null `IContent content`.
+
+**Suggestion:** Either all methods should accept nullable content (and return failure result), or none should. Consistency improves API ergonomics.
+
+---
+
+### 3.4 GetPagedDescendants Not Needed
+
+**Location:** Task 2, lines 728-742
+
+The plan adds a `GetPagedDescendants` helper, but this method already exists in `IContentQueryOperationService` (from Phase 2). Use the injected service instead:
+
+```csharp
+// Instead of new helper:
+// private IEnumerable GetPagedDescendants(...)
+
+// Use:
+// QueryOperationService.GetPagedDescendants(...)
+```
+
+However, the new service doesn't have `_queryOperationService` injected. Either:
+1. Add IContentQueryOperationService as a dependency
+2. Or add the method to avoid circular dependency (acceptable duplication)
+
+---
+
+### 3.5 Contract Tests Use NUnit but Plan Says xUnit
+
+**Location:** Task 6, lines 1155-1291
+
+The contract tests use `[TestFixture]` and `[Test]` attributes (NUnit), but the plan header says "xUnit for testing".
+
+**Actionable Fix:** Check project conventions. Looking at existing tests in the repository, they use NUnit. This is correct - update the plan header to say "NUnit".
+
+---
+
+### 3.6 Missing Using Statement in Contract Tests
+
+**Location:** Task 6, line 1156
+
+```csharp
+using NUnit.Framework;
+```
+
+Missing `using Umbraco.Cms.Core` for `Constants.Security.SuperUserId` references in other test files.
+
+---
+
+## 4. Questions for Clarification
+
+### Q1: MoveToRecycleBin Orchestration
+
+The plan states "Keep MoveToRecycleBin in facade (orchestrates unpublish + move)". How exactly will the facade call `CommitDocumentChangesInternal` which is private to ContentPublishOperationService?
+
+**Need:** Architecture decision on internal method exposure (see Critical Issue 2.3)
+
+---
+
+### Q2: GetPublishedDescendants Usage
+
+Line 2101 in Step 9 says "GetPublishedDescendants (internal) - Keep if used by MoveToRecycleBin". Is it used? If so, it needs to be exposed on the interface or kept in ContentService.
+
+**Need:** Verification of callers
+
+---
+
+### Q3: Notification State Propagation
+
+`CommitDocumentChangesInternal` accepts `IDictionary? notificationState`. When delegating from ContentService, how is this state managed?
+
+**Need:** Clarification on how `notificationState` flows through delegation
+
+---
+
+### Q4: Scheduled Publishing Error Handling
+
+What happens if `PerformScheduledPublish` fails mid-batch? Are partial results returned? Is there retry logic in the scheduled job caller?
+
+**Need:** Error handling strategy for scheduled jobs
+
+---
+
+## 5. Final Recommendation
+
+**Recommendation: Approve with Changes**
+
+The plan is well-structured and follows established patterns. However, the following changes are required before implementation:
+
+### Must Fix (Blocking):
+1. **Resolve CommitDocumentChangesInternal exposure** (Critical Issue 2.3) - Architecture decision needed
+2. **Add circular dependency guards** (Critical Issue 2.2) - Verify all GetById calls use _crudService
+3. **Add null checks** for public API methods (Critical Issue 2.4)
+
+### Should Fix (Non-Blocking but Important):
+4. **Thread safety for ContentSettings** (Critical Issue 2.1) - Same issue exists in ContentService, could be addressed separately
+5. **Consider cancellation token** for PublishBranch (Critical Issue 2.5) - Can be added in Phase 8
+
+### Nice to Have:
+6. Consolidate helper methods to avoid duplication
+7. Fix NUnit vs xUnit documentation mismatch
+
+---
+
+**Summary:** The plan is 85% production-ready. The main blocker is clarifying how `CommitDocumentChangesInternal` will be accessible for orchestration in the facade. Once that architecture decision is made, implementation can proceed.
diff --git a/docs/plans/2025-12-23-contentservice-refactor-phase5-implementation-critical-review-2.md b/docs/plans/2025-12-23-contentservice-refactor-phase5-implementation-critical-review-2.md
new file mode 100644
index 0000000000..d1450df14b
--- /dev/null
+++ b/docs/plans/2025-12-23-contentservice-refactor-phase5-implementation-critical-review-2.md
@@ -0,0 +1,272 @@
+# Critical Implementation Review: ContentService Refactoring Phase 5
+
+**Plan Under Review:** `docs/plans/2025-12-23-contentservice-refactor-phase5-implementation.md`
+**Review Date:** 2025-12-23
+**Reviewer:** Critical Implementation Review (Automated)
+**Version:** 2
+
+---
+
+## 1. Overall Assessment
+
+**Strengths:**
+- All critical issues from Review 1 have been addressed in the updated plan (v1.1)
+- Thread safety for `ContentSettings` is now properly implemented with lock pattern
+- `CommitDocumentChanges` is exposed on interface with `[EditorBrowsable(EditorBrowsableState.Advanced)]`
+- Null checks added to `GetContentSchedulesByIds`
+- Explicit failure logging added to `PerformScheduledPublish`
+- Key decisions are clearly documented and rationalized
+- The plan is well-structured with clear verification steps
+
+**Remaining Concerns (Non-Blocking):**
+1. **Misleading comment** in `IsPathPublishable` fix - says "_crudService" but uses `DocumentRepository`
+2. **Nested scope inefficiency** in `IsPathPublishable` calling `GetParent` then `IsPathPublished`
+3. **Helper method duplication** across services (still copying rather than consolidating)
+4. **No idempotency documentation** for `Publish` when content is already published
+5. **Missing error recovery documentation** for `PerformScheduledPublish` partial failures
+
+---
+
+## 2. Critical Issues
+
+**NONE** - All blocking issues from Review 1 have been addressed.
+
+The following issues from Review 1 are now resolved:
+
+| Issue | Resolution in v1.1 |
+|-------|-------------------|
+| 2.1 Thread safety | Lines 356-416: Lock pattern with `_contentSettingsLock` |
+| 2.2 Circular dependency | Lines 751-752, 895-905: Uses `DocumentRepository` directly via base class |
+| 2.3 CommitDocumentChanges exposure | Lines 162-187: Added to interface with `notificationState` parameter |
+| 2.4 Null check | Lines 721-726: Added `ArgumentNullException.ThrowIfNull` and empty check |
+| 2.5 Cancellation token | Acknowledged as Phase 8 improvement (non-blocking) |
+| 2.6 N+1 query | Low priority, existing pattern acceptable |
+
+---
+
+## 3. Minor Issues & Improvements
+
+### 3.1 Misleading Comment in IsPathPublishable Fix
+
+**Location:** Task 2, lines 748-752
+
+```csharp
+// Critical Review fix 2.2: Use _crudService to avoid circular dependency
+// Not trashed and has a parent: publishable if the parent is path-published
+IContent? parent = GetParent(content);
+```
+
+**Problem:** The comment says "Use _crudService" but the `GetParent` method actually uses `DocumentRepository.Get()` (lines 903-904). The comment is factually incorrect.
+
+**Why It Matters:**
+- Developers reading this code will be confused about the actual implementation
+- Maintenance programmers might incorrectly refactor thinking `_crudService` is used
+
+**Actionable Fix:**
+```csharp
+// Avoids circular dependency by using DocumentRepository directly (inherited from ContentServiceBase)
+// rather than calling back into ContentService methods.
+IContent? parent = GetParent(content);
+```
+
+**Priority:** LOW - Code is correct, only documentation issue
+
+---
+
+### 3.2 Nested Scope Inefficiency in IsPathPublishable
+
+**Location:** Task 2, lines 736-764 and 895-905
+
+**Problem:** `IsPathPublishable` calls `GetParent` which creates a scope, then calls `IsPathPublished` which creates another scope. This results in two separate database transactions for what could be a single operation.
+
+```csharp
+public bool IsPathPublishable(IContent content)
+{
+ // ...
+ IContent? parent = GetParent(content); // Creates scope 1
+ return parent == null || IsPathPublished(parent); // Creates scope 2
+}
+```
+
+**Why It Matters:**
+- Two separate scopes means two lock acquisitions
+- For deep hierarchies, this could add latency
+- Not a correctness issue, but an efficiency concern
+
+**Actionable Fix (Optional - Not Required):**
+
+Either:
+1. Accept the current implementation (nested scopes are supported, just slightly inefficient)
+2. Or combine into a single scope:
+
+```csharp
+public bool IsPathPublishable(IContent content)
+{
+ if (content.ParentId == Constants.System.Root)
+ {
+ return true;
+ }
+
+ if (content.Trashed)
+ {
+ return false;
+ }
+
+ using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
+ scope.ReadLock(Constants.Locks.ContentTree);
+
+ IContent? parent = content.ParentId == Constants.System.Root
+ ? null
+ : DocumentRepository.Get(content.ParentId);
+
+ return parent == null || DocumentRepository.IsPathPublished(parent);
+}
+```
+
+**Priority:** LOW - Micro-optimization, current implementation works correctly
+
+---
+
+### 3.3 Helper Method Duplication Remains Unaddressed
+
+**Location:** Task 2, lines 841-859
+
+The following methods are still being duplicated from ContentService:
+- `HasUnsavedChanges` (line 842)
+- `GetLanguageDetailsForAuditEntry` (lines 844-852)
+- `IsDefaultCulture` (lines 855-856)
+- `IsMandatoryCulture` (lines 858-859)
+
+**Suggestion (Non-Blocking):**
+Consider adding these as `protected` methods to `ContentServiceBase` during Phase 8 cleanup, so all operation services can share them:
+
+```csharp
+// In ContentServiceBase:
+protected static bool HasUnsavedChanges(IContent content) =>
+ content.HasIdentity is false || content.IsDirty();
+
+protected static bool IsDefaultCulture(IReadOnlyCollection? langs, string culture) =>
+ langs?.Any(x => x.IsDefault && x.IsoCode.InvariantEquals(culture)) ?? false;
+```
+
+**Priority:** LOW - Code duplication is acceptable for now, can be consolidated later
+
+---
+
+### 3.4 Publish Idempotency Not Documented
+
+**Location:** Task 2, lines 429-514
+
+**Problem:** What happens when `Publish` is called on content that is already published with no changes? The method checks `HasUnsavedChanges` but doesn't document the expected behavior for repeat publishes.
+
+**Why It Matters:**
+- API consumers might call `Publish` defensively without checking if already published
+- Should this succeed silently, return a specific result type, or be a no-op?
+
+**Actionable Fix:**
+Add documentation to the interface method (Task 1):
+
+```csharp
+///
+/// ...
+/// Publishing already-published content with no changes is idempotent and succeeds
+/// without re-triggering notifications or updating timestamps.
+///
+```
+
+**Priority:** LOW - Documentation improvement only
+
+---
+
+### 3.5 PerformScheduledPublish Partial Failure Behavior Undocumented
+
+**Location:** Task 2, lines 591-620
+
+**Observation:** The method now logs failures (excellent improvement from Review 1), but the behavior on partial failure is implicit:
+- Each item is processed independently
+- Failed items are logged and added to results
+- Processing continues for remaining items
+- No transaction rollback occurs
+
+**Why It Matters:**
+- Operators need to understand that failures don't stop the batch
+- Retry logic should be at the caller level (scheduled job service)
+
+**Actionable Fix:**
+Add to the interface documentation (Task 1, line 197):
+
+```csharp
+///
+/// Each document is processed independently. Failures on one document do not prevent
+/// processing of subsequent documents. Partial results are returned including both successes
+/// and failures. Callers should inspect results and implement retry logic as needed.
+///
+```
+
+**Priority:** LOW - Documentation improvement only
+
+---
+
+### 3.6 Contract Test Reflection Signature Match
+
+**Location:** Task 6, lines 1440-1442
+
+```csharp
+var methodInfo = typeof(IContentPublishOperationService).GetMethod(
+ nameof(IContentPublishOperationService.CommitDocumentChanges),
+ new[] { typeof(IContent), typeof(int), typeof(IDictionary) });
+```
+
+**Observation:** The method signature uses nullable reference type `IDictionary?` but the test uses `typeof(IDictionary)`. This works because nullable reference types are compile-time only and don't affect runtime type signatures.
+
+**Status:** No issue - reflection works correctly with nullable reference types.
+
+---
+
+## 4. Questions for Clarification
+
+### Q1: Resolved - CommitDocumentChanges Orchestration
+**From Review 1:** "How will facade call CommitDocumentChangesInternal?"
+**Resolution:** Plan now exposes `CommitDocumentChanges` on interface with `notificationState` parameter (Key Decision #4, #6). MoveToRecycleBin can call `PublishOperationService.CommitDocumentChanges(content, userId, state)`.
+
+### Q2: Resolved - GetPublishedDescendants Usage
+**From Review 1:** "Is GetPublishedDescendants used by MoveToRecycleBin?"
+**Resolution:** Key Decision #5 clarifies that `CommitDocumentChanges` handles descendants internally. The method stays internal to `ContentPublishOperationService`.
+
+### Q3: Resolved - Notification State Propagation
+**From Review 1:** "How is notificationState managed?"
+**Resolution:** Line 169 and 186 show `notificationState` is an optional parameter that can be passed through for orchestrated operations.
+
+### Q4: Clarified - Scheduled Publishing Error Handling
+**From Review 1:** "What happens if PerformScheduledPublish fails mid-batch?"
+**Status:** Lines 599-618 now log failures explicitly. However, the broader behavior (partial results returned, no rollback) could use interface documentation (see Minor Issue 3.5).
+
+---
+
+## 5. Final Recommendation
+
+**Recommendation: Approve**
+
+All critical blocking issues from Review 1 have been properly addressed. The remaining issues are documentation improvements and micro-optimizations that are non-blocking.
+
+### Summary of Changes Since Review 1:
+
+| Category | Changes Applied |
+|----------|-----------------|
+| Thread Safety | Lock pattern for ContentSettings |
+| API Design | CommitDocumentChanges exposed with EditorBrowsable |
+| Error Handling | Null checks and failure logging added |
+| Documentation | Key decisions clarified, test framework corrected |
+| Architecture | Circular dependency concern addressed via DocumentRepository |
+
+### Recommended Actions (Post-Implementation, Non-Blocking):
+
+1. **Minor:** Fix misleading comment in IsPathPublishable (says _crudService, uses DocumentRepository)
+2. **Minor:** Add idempotency documentation to Publish method
+3. **Minor:** Add partial failure documentation to PerformScheduledPublish
+4. **Phase 8:** Consider consolidating helper methods to ContentServiceBase
+5. **Phase 8:** Consider adding CancellationToken support to PublishBranch
+
+---
+
+**The plan is ready for implementation.** Execute via `superpowers:executing-plans` skill.
diff --git a/docs/plans/2025-12-23-contentservice-refactor-phase5-implementation-summary-1.md b/docs/plans/2025-12-23-contentservice-refactor-phase5-implementation-summary-1.md
new file mode 100644
index 0000000000..ff5526f590
--- /dev/null
+++ b/docs/plans/2025-12-23-contentservice-refactor-phase5-implementation-summary-1.md
@@ -0,0 +1,53 @@
+# ContentService Refactoring Phase 5: Publish Operation Service Implementation Plan - Completion Summary
+
+## 1. Overview
+
+The original plan aimed to extract all publishing operations (Publish, Unpublish, scheduled publishing, branch publishing, schedule management) from ContentService into a dedicated IContentPublishOperationService. This was identified as the most complex phase of the ContentService refactoring initiative, involving approximately 1,500 lines of publishing logic including complex culture-variant handling, scheduled publishing/expiration, branch publishing with tree traversal, and strategy pattern methods.
+
+**Overall Completion Status:** All 9 tasks have been fully completed and verified. The implementation matches the plan specifications, with all tests passing.
+
+## 2. Completed Items
+
+- **Task 1:** Created `IContentPublishOperationService` interface with 16 public methods covering publishing, unpublishing, scheduled publishing, schedule management, path checks, workflow, and published content queries (commit `0e1d8a3564`)
+- **Task 2:** Implemented `ContentPublishOperationService` class with all publishing operations extracted from ContentService, including thread-safe ContentSettings accessor (commit `26e97dfc81`)
+- **Task 3:** Registered `IContentPublishOperationService` in DI container and updated ContentService factory (commit `392ab5ec87`)
+- **Task 4:** Added `IContentPublishOperationService` injection to ContentService with field, property, and constructor parameter updates; obsolete constructors use lazy resolution (commit `ea4602ec15`)
+- **Task 5:** Delegated all publishing methods from ContentService facade to the new service, removing ~1,500 lines of implementation (commit `6b584497a0`)
+- **Task 6:** Created 12 interface contract tests verifying method signatures, inheritance, and EditorBrowsable attribute (commit `19362eb404`)
+- **Task 7:** Added 4 integration tests for DI resolution, Publish, Unpublish, and IsPathPublishable operations (commit `ab9eb28826`)
+- **Task 8:** Ran full test suite verification:
+ - ContentServiceRefactoringTests: 23 passed
+ - ContentService tests: 220 passed (2 skipped)
+ - Notification tests: 54 passed
+ - Contract tests: 12 passed
+- **Task 9:** Updated design document to mark Phase 5 as complete with revision 1.9 (commit `29837ea348`)
+
+## 3. Partially Completed or Modified Items
+
+None. All items were implemented exactly as specified in the plan.
+
+## 4. Omitted or Deferred Items
+
+- **Git tag `phase-5-publish-extraction`:** The plan specified creating a git tag, but this was not explicitly confirmed in the execution. The tag may have been created but was not verified.
+- **Empty commit for Phase 5 summary:** The plan specified a `git commit --allow-empty` with a Phase 5 summary message, which was not executed as a separate step.
+
+## 5. Discrepancy Explanations
+
+- **Git tag and empty commit:** These were documentation/milestone markers rather than functional requirements. The actual implementation work was fully completed and committed. The design document was updated to reflect Phase 5 completion, which serves the same documentation purpose.
+
+## 6. Key Achievements
+
+- Successfully extracted approximately 1,500 lines of complex publishing logic into a dedicated, focused service
+- Maintained full backward compatibility through the ContentService facade pattern
+- All 309+ tests passing (220 ContentService + 54 Notification + 23 Refactoring + 12 Contract)
+- Implemented critical review recommendations including:
+ - Thread-safe ContentSettings accessor with locking
+ - `CommitDocumentChanges` exposed on interface for facade orchestration (Option A)
+ - Optional `notificationState` parameter for state propagation
+ - Explicit failure logging in `PerformScheduledPublish`
+ - Null/empty check in `GetContentSchedulesByIds`
+- ContentService reduced from approximately 3,000 lines to approximately 1,500 lines
+
+## 7. Final Assessment
+
+The Phase 5 implementation has been completed successfully with full alignment to the original plan specifications. All 9 tasks were executed as documented, with the implementation following established patterns from Phases 1-4. The most complex phase of the ContentService refactoring is now complete, with the publishing logic properly encapsulated in a dedicated service that maintains the same external behavior while improving internal code organization, testability, and maintainability. The comprehensive test coverage (300+ tests passing) provides confidence that the refactoring preserved existing functionality. Minor documentation markers (git tag, empty commit) were omitted but do not affect the functional completeness of the implementation.
diff --git a/docs/plans/2025-12-23-contentservice-refactor-phase5-implementation.md b/docs/plans/2025-12-23-contentservice-refactor-phase5-implementation.md
new file mode 100644
index 0000000000..fee67ab151
--- /dev/null
+++ b/docs/plans/2025-12-23-contentservice-refactor-phase5-implementation.md
@@ -0,0 +1,1699 @@
+# ContentService Refactoring Phase 5: Publish Operation Service Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Extract all publishing operations (Publish, Unpublish, scheduled publishing, branch publishing, schedule management) from ContentService into a dedicated IContentPublishOperationService.
+
+**Architecture:** Create a new IContentPublishOperationService interface and ContentPublishOperationService implementation that handles all publish/unpublish operations. The ContentService facade will delegate to this new service. The new service inherits from ContentServiceBase and follows the established pattern from Phases 1-4.
+
+**Tech Stack:** C#, .NET 10.0, Umbraco CMS Core/Infrastructure, NUnit for testing
+
+---
+
+## Phase Overview
+
+This is the most complex phase of the refactoring. The publishing logic includes:
+- Complex culture-variant handling (CommitDocumentChangesInternal ~330 lines)
+- Scheduled publishing/expiration operations
+- Branch publishing with tree traversal
+- Strategy pattern methods (StrategyCanPublish, StrategyPublish, StrategyCanUnpublish, StrategyUnpublish)
+- Multiple notification types
+
+**Key Decisions:**
+1. Keep `MoveToRecycleBin` in facade (orchestrates unpublish + move)
+2. The new service name is `IContentPublishOperationService` to avoid collision with existing `IContentPublishingService` (API layer)
+3. Complex methods like `CommitDocumentChangesInternal` stay intact - no further splitting
+4. **Expose `CommitDocumentChanges` on interface** (Option A from critical review) - allows facade to orchestrate publish/unpublish with other operations (e.g., MoveToRecycleBin)
+5. Keep `GetPublishedDescendants` internal - facade uses `CommitDocumentChanges` which handles descendants internally
+6. Add optional `notificationState` parameter to `CommitDocumentChanges` for state propagation across orchestrated operations
+
+---
+
+## Methods to Extract
+
+| Method | Lines (approx) | Notes |
+|--------|----------------|-------|
+| `Publish` | 85 | Core publish operation |
+| `Unpublish` | 65 | Core unpublish operation |
+| `CommitDocumentChanges` | 20 | Public wrapper |
+| `CommitDocumentChangesInternal` | 330 | Core publishing logic |
+| `PerformScheduledPublish` | 10 | Entry point for scheduled jobs |
+| `PerformScheduledPublishingExpiration` | 70 | Handle expirations |
+| `PerformScheduledPublishingRelease` | 115 | Handle releases |
+| `PublishBranch` (public) | 50 | Entry point for branch publish |
+| `PublishBranch` (internal) | 115 | Core branch logic |
+| `PublishBranchItem` | 55 | Individual item in branch |
+| `PublishBranch_PublishCultures` | 20 | Utility |
+| `PublishBranch_ShouldPublish` | 25 | Utility |
+| `EnsureCultures` | 10 | Utility |
+| `ProvidedCulturesIndicatePublishAll` | 2 | Utility |
+| `SendToPublication` | 55 | Workflow trigger |
+| `GetPublishedChildren` | 10 | Read operation |
+| `GetPublishedDescendants` | 10 | Internal read |
+| `GetPublishedDescendantsLocked` | 20 | Internal read locked |
+| `StrategyCanPublish` | 175 | Publishing strategy |
+| `StrategyPublish` | 60 | Publishing strategy |
+| `StrategyCanUnpublish` | 25 | Unpublishing strategy |
+| `StrategyUnpublish` | 40 | Unpublishing strategy |
+| `GetContentScheduleByContentId` (int) | 10 | Schedule retrieval |
+| `GetContentScheduleByContentId` (Guid) | 10 | Schedule retrieval |
+| `PersistContentSchedule` | 10 | Schedule persistence |
+| `GetContentSchedulesByIds` | 25 | Bulk schedule retrieval |
+| `GetContentForExpiration` | 10 | Schedule query |
+| `GetContentForRelease` | 10 | Schedule query |
+| `IsPathPublishable` | 15 | Path checks |
+| `IsPathPublished` | 10 | Path checks |
+| Helper methods | 40 | `HasUnsavedChanges`, `GetLanguageDetailsForAuditEntry`, `IsDefaultCulture`, `IsMandatoryCulture` |
+
+**Estimated Total:** ~1,500 lines (ContentService will reduce from ~3000 to ~1500 lines)
+
+---
+
+## Task 1: Create IContentPublishOperationService Interface
+
+**Files:**
+- Create: `src/Umbraco.Core/Services/IContentPublishOperationService.cs`
+
+**Step 1: Write the interface file**
+
+```csharp
+// src/Umbraco.Core/Services/IContentPublishOperationService.cs
+using System.ComponentModel;
+using Umbraco.Cms.Core.Models;
+using Umbraco.Cms.Core.Persistence.Querying;
+
+namespace Umbraco.Cms.Core.Services;
+
+///
+/// Service for content publishing operations (publish, unpublish, scheduled publishing, branch publishing).
+///
+///
+///
+/// Implementation Note: Do not implement this interface directly.
+/// Instead, inherit from which provides required
+/// infrastructure (scoping, repository access, auditing). Direct implementation
+/// without this base class will result in missing functionality.
+///
+///
+/// This interface is part of the ContentService refactoring initiative (Phase 5).
+/// It extracts publishing operations into a focused, testable service.
+///
+///
+/// Note: This interface is named IContentPublishOperationService to avoid
+/// collision with the existing IContentPublishingService which is an API-layer orchestrator.
+///
+///
+/// Versioning Policy: This interface follows additive-only changes.
+/// New methods may be added with default implementations. Existing methods will not
+/// be removed or have signatures changed without a 2 major version deprecation period.
+///
+///
+public interface IContentPublishOperationService : IService
+{
+ #region Publishing
+
+ ///
+ /// Publishes a document.
+ ///
+ /// The document to publish.
+ /// The cultures to publish. Use "*" for all cultures or specific culture codes.
+ /// The identifier of the user performing the action.
+ /// The publish result indicating success or failure.
+ ///
+ /// When a culture is being published, it includes all varying values along with all invariant values.
+ /// Wildcards (*) can be used as culture identifier to publish all cultures.
+ /// An empty array (or a wildcard) can be passed for culture invariant content.
+ /// Fires ContentPublishingNotification (cancellable) before publish and ContentPublishedNotification after.
+ ///
+ PublishResult Publish(IContent content, string[] cultures, int userId = Constants.Security.SuperUserId);
+
+ ///
+ /// Publishes a document branch.
+ ///
+ /// The root document of the branch.
+ /// Options for force publishing unpublished or re-publishing unchanged content.
+ /// The cultures to publish.
+ /// The identifier of the user performing the operation.
+ /// Results for each document in the branch.
+ /// The root of the branch is always published, regardless of .
+ IEnumerable PublishBranch(IContent content, PublishBranchFilter publishBranchFilter, string[] cultures, int userId = Constants.Security.SuperUserId);
+
+ #endregion
+
+ #region Unpublishing
+
+ ///
+ /// Unpublishes a document.
+ ///
+ /// The document to unpublish.
+ /// The culture to unpublish, or "*" for all cultures.
+ /// The identifier of the user performing the action.
+ /// The publish result indicating success or failure.
+ ///
+ /// By default, unpublishes the document as a whole, but it is possible to specify a culture.
+ /// If the content type is variant, culture can be either '*' or an actual culture.
+ /// If the content type is invariant, culture can be either '*' or null or empty.
+ /// Fires ContentUnpublishingNotification (cancellable) before and ContentUnpublishedNotification after.
+ ///
+ PublishResult Unpublish(IContent content, string? culture = "*", int userId = Constants.Security.SuperUserId);
+
+ #endregion
+
+ #region Document Changes (Advanced API)
+
+ ///
+ /// Commits pending document publishing/unpublishing changes.
+ ///
+ /// The document with pending publish state changes.
+ /// The identifier of the user performing the action.
+ /// Optional state dictionary for notification propagation across orchestrated operations.
+ /// The publish result indicating success or failure.
+ ///
+ ///
+ /// This is an advanced API. Most consumers should use or
+ /// instead.
+ ///
+ ///
+ /// Call this after setting to
+ /// or .
+ ///
+ ///
+ /// This method is exposed for orchestration scenarios where publish/unpublish must be coordinated
+ /// with other operations (e.g., MoveToRecycleBin unpublishes before moving).
+ ///
+ ///
+ [EditorBrowsable(EditorBrowsableState.Advanced)]
+ PublishResult CommitDocumentChanges(IContent content, int userId = Constants.Security.SuperUserId, IDictionary? notificationState = null);
+
+ #endregion
+
+ #region Scheduled Publishing
+
+ ///
+ /// Publishes and unpublishes scheduled documents.
+ ///
+ /// The date to check schedules against.
+ /// Results for each processed document.
+ IEnumerable PerformScheduledPublish(DateTime date);
+
+ ///
+ /// Gets documents having an expiration date before (lower than, or equal to) a specified date.
+ ///
+ /// The date to check against.
+ /// Documents scheduled for expiration.
+ IEnumerable GetContentForExpiration(DateTime date);
+
+ ///
+ /// Gets documents having a release date before (lower than, or equal to) a specified date.
+ ///
+ /// The date to check against.
+ /// Documents scheduled for release.
+ IEnumerable GetContentForRelease(DateTime date);
+
+ #endregion
+
+ #region Schedule Management
+
+ ///
+ /// Gets publish/unpublish schedule for a content node by integer id.
+ ///
+ /// Id of the content to load schedule for.
+ /// The content schedule collection.
+ ContentScheduleCollection GetContentScheduleByContentId(int contentId);
+
+ ///
+ /// Gets publish/unpublish schedule for a content node by GUID.
+ ///
+ /// Key of the content to load schedule for.
+ /// The content schedule collection.
+ ContentScheduleCollection GetContentScheduleByContentId(Guid contentId);
+
+ ///
+ /// Persists publish/unpublish schedule for a content node.
+ ///
+ /// The content item.
+ /// The schedule to persist.
+ void PersistContentSchedule(IContent content, ContentScheduleCollection contentSchedule);
+
+ ///
+ /// Gets a dictionary of content Ids and their matching content schedules.
+ ///
+ /// The content keys.
+ /// A dictionary with nodeId and an IEnumerable of matching ContentSchedules.
+ IDictionary> GetContentSchedulesByIds(Guid[] keys);
+
+ #endregion
+
+ #region Path Checks
+
+ ///
+ /// Gets a value indicating whether a document is path-publishable.
+ ///
+ /// The content to check.
+ /// True if all ancestors are published.
+ /// A document is path-publishable when all its ancestors are published.
+ bool IsPathPublishable(IContent content);
+
+ ///
+ /// Gets a value indicating whether a document is path-published.
+ ///
+ /// The content to check.
+ /// True if all ancestors and the document itself are published.
+ /// A document is path-published when all its ancestors, and the document itself, are published.
+ bool IsPathPublished(IContent? content);
+
+ #endregion
+
+ #region Workflow
+
+ ///
+ /// Saves a document and raises the "sent to publication" events.
+ ///
+ /// The content to send to publication.
+ /// The identifier of the user issuing the send to publication.
+ /// True if sending publication was successful otherwise false.
+ ///
+ /// Fires ContentSendingToPublishNotification (cancellable) before and ContentSentToPublishNotification after.
+ ///
+ bool SendToPublication(IContent? content, int userId = Constants.Security.SuperUserId);
+
+ #endregion
+
+ #region Published Content Queries
+
+ ///
+ /// Gets published children of a parent content item.
+ ///
+ /// Id of the parent to retrieve children from.
+ /// Published child content items, ordered by sort order.
+ IEnumerable GetPublishedChildren(int id);
+
+ #endregion
+}
+```
+
+**Step 2: Verify the file compiles**
+
+Run: `dotnet build src/Umbraco.Core/Umbraco.Core.csproj --no-restore`
+Expected: Build succeeded
+
+**Step 3: Commit**
+
+```bash
+git add src/Umbraco.Core/Services/IContentPublishOperationService.cs
+git commit -m "feat(core): add IContentPublishOperationService interface for Phase 5
+
+Defines interface for content publishing operations:
+- Publish/Unpublish operations
+- Scheduled publishing (release/expiration)
+- Schedule management
+- Path checks
+- Workflow (SendToPublication)
+- Published content queries
+
+Part of ContentService refactoring Phase 5."
+```
+
+---
+
+## Task 2: Create ContentPublishOperationService Implementation
+
+**Files:**
+- Create: `src/Umbraco.Core/Services/ContentPublishOperationService.cs`
+
+This is the largest task. We'll extract all publishing methods from ContentService.
+
+**Step 1: Create the implementation file with constructor and dependencies**
+
+```csharp
+// src/Umbraco.Core/Services/ContentPublishOperationService.cs
+using System.Collections.Immutable;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Umbraco.Cms.Core.Configuration.Models;
+using Umbraco.Cms.Core.Events;
+using Umbraco.Cms.Core.Models;
+using Umbraco.Cms.Core.Notifications;
+using Umbraco.Cms.Core.Persistence;
+using Umbraco.Cms.Core.Persistence.Querying;
+using Umbraco.Cms.Core.Persistence.Repositories;
+using Umbraco.Cms.Core.PropertyEditors;
+using Umbraco.Cms.Core.Scoping;
+using Umbraco.Cms.Core.Services.Changes;
+using Umbraco.Extensions;
+
+namespace Umbraco.Cms.Core.Services;
+
+///
+/// Implements content publishing operations.
+///
+public class ContentPublishOperationService : ContentServiceBase, IContentPublishOperationService
+{
+ private readonly ILogger _logger;
+ private readonly IContentCrudService _crudService;
+ private readonly ILanguageRepository _languageRepository;
+ private readonly Lazy _propertyValidationService;
+ private readonly ICultureImpactFactory _cultureImpactFactory;
+ private readonly PropertyEditorCollection _propertyEditorCollection;
+ private readonly IIdKeyMap _idKeyMap;
+
+ // Thread-safe ContentSettings (Critical Review fix 2.1)
+ private ContentSettings _contentSettings;
+ private readonly object _contentSettingsLock = new object();
+
+ public ContentPublishOperationService(
+ ICoreScopeProvider provider,
+ ILoggerFactory loggerFactory,
+ IEventMessagesFactory eventMessagesFactory,
+ IDocumentRepository documentRepository,
+ IAuditService auditService,
+ IUserIdKeyResolver userIdKeyResolver,
+ IContentCrudService crudService,
+ ILanguageRepository languageRepository,
+ Lazy propertyValidationService,
+ ICultureImpactFactory cultureImpactFactory,
+ PropertyEditorCollection propertyEditorCollection,
+ IIdKeyMap idKeyMap,
+ IOptionsMonitor optionsMonitor)
+ : base(provider, loggerFactory, eventMessagesFactory, documentRepository, auditService, userIdKeyResolver)
+ {
+ _logger = loggerFactory.CreateLogger();
+ _crudService = crudService ?? throw new ArgumentNullException(nameof(crudService));
+ _languageRepository = languageRepository ?? throw new ArgumentNullException(nameof(languageRepository));
+ _propertyValidationService = propertyValidationService ?? throw new ArgumentNullException(nameof(propertyValidationService));
+ _cultureImpactFactory = cultureImpactFactory ?? throw new ArgumentNullException(nameof(cultureImpactFactory));
+ _propertyEditorCollection = propertyEditorCollection ?? throw new ArgumentNullException(nameof(propertyEditorCollection));
+ _idKeyMap = idKeyMap ?? throw new ArgumentNullException(nameof(idKeyMap));
+
+ // Thread-safe settings initialization and subscription (Critical Review fix 2.1)
+ ArgumentNullException.ThrowIfNull(optionsMonitor);
+ lock (_contentSettingsLock)
+ {
+ _contentSettings = optionsMonitor.CurrentValue;
+ }
+ optionsMonitor.OnChange(settings =>
+ {
+ lock (_contentSettingsLock)
+ {
+ _contentSettings = settings;
+ }
+ });
+ }
+
+ ///
+ /// Thread-safe accessor for ContentSettings.
+ ///
+ private ContentSettings ContentSettings
+ {
+ get
+ {
+ lock (_contentSettingsLock)
+ {
+ return _contentSettings;
+ }
+ }
+ }
+
+ // Implementation methods will be added in subsequent steps
+}
+```
+
+**Step 2: Copy and adapt the Publish method**
+
+Add to the class:
+
+```csharp
+ #region Publishing
+
+ ///
+ public PublishResult Publish(IContent content, string[] cultures, int userId = Constants.Security.SuperUserId)
+ {
+ if (content == null)
+ {
+ throw new ArgumentNullException(nameof(content));
+ }
+
+ if (cultures is null)
+ {
+ throw new ArgumentNullException(nameof(cultures));
+ }
+
+ if (cultures.Any(c => c.IsNullOrWhiteSpace()) || cultures.Distinct().Count() != cultures.Length)
+ {
+ throw new ArgumentException("Cultures cannot be null or whitespace", nameof(cultures));
+ }
+
+ cultures = cultures.Select(x => x.EnsureCultureCode()!).ToArray();
+
+ EventMessages evtMsgs = EventMessagesFactory.Get();
+
+ // we need to guard against unsaved changes before proceeding
+ if (HasUnsavedChanges(content))
+ {
+ return new PublishResult(PublishResultType.FailedPublishUnsavedChanges, evtMsgs, content);
+ }
+
+ if (content.Name != null && content.Name.Length > 255)
+ {
+ throw new InvalidOperationException("Name cannot be more than 255 characters in length.");
+ }
+
+ PublishedState publishedState = content.PublishedState;
+ if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished)
+ {
+ throw new InvalidOperationException(
+ $"Cannot save-and-publish (un)publishing content, use the dedicated {nameof(CommitDocumentChangesInternal)} method.");
+ }
+
+ // cannot accept invariant (null or empty) culture for variant content type
+ // cannot accept a specific culture for invariant content type (but '*' is ok)
+ if (content.ContentType.VariesByCulture())
+ {
+ if (cultures.Length > 1 && cultures.Contains("*"))
+ {
+ throw new ArgumentException("Cannot combine wildcard and specific cultures when publishing variant content types.", nameof(cultures));
+ }
+ }
+ else
+ {
+ if (cultures.Length == 0)
+ {
+ cultures = new[] { "*" };
+ }
+
+ if (cultures[0] != "*" || cultures.Length > 1)
+ {
+ throw new ArgumentException($"Only wildcard culture is supported when publishing invariant content types.", nameof(cultures));
+ }
+ }
+
+ using (ICoreScope scope = ScopeProvider.CreateCoreScope())
+ {
+ scope.WriteLock(Constants.Locks.ContentTree);
+
+ var allLangs = _languageRepository.GetMany().ToList();
+
+ // this will create the correct culture impact even if culture is * or null
+ IEnumerable impacts =
+ cultures.Select(culture => _cultureImpactFactory.Create(culture, IsDefaultCulture(allLangs, culture), content));
+
+ // publish the culture(s)
+ var publishTime = DateTime.UtcNow;
+ foreach (CultureImpact? impact in impacts)
+ {
+ content.PublishCulture(impact, publishTime, _propertyEditorCollection);
+ }
+
+ // Change state to publishing
+ content.PublishedState = PublishedState.Publishing;
+
+ PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, new Dictionary(), userId);
+ scope.Complete();
+ return result;
+ }
+ }
+
+ #endregion
+```
+
+**Step 3: Add remaining publishing methods**
+
+Continue adding all the methods from ContentService. Due to size, I'll document the key structure:
+
+```csharp
+ #region Unpublishing
+
+ ///
+ public PublishResult Unpublish(IContent content, string? culture = "*", int userId = Constants.Security.SuperUserId)
+ {
+ // Copy exact implementation from ContentService lines 918-1010
+ }
+
+ #endregion
+
+ #region Commit Document Changes (Advanced API)
+
+ ///
+ ///
+ /// This is the public wrapper for CommitDocumentChangesInternal.
+ /// Exposed on interface for orchestration scenarios (e.g., MoveToRecycleBin).
+ ///
+ [EditorBrowsable(EditorBrowsableState.Advanced)]
+ public PublishResult CommitDocumentChanges(
+ IContent content,
+ int userId = Constants.Security.SuperUserId,
+ IDictionary? notificationState = null)
+ {
+ if (content == null)
+ {
+ throw new ArgumentNullException(nameof(content));
+ }
+
+ EventMessages evtMsgs = EventMessagesFactory.Get();
+
+ using (ICoreScope scope = ScopeProvider.CreateCoreScope())
+ {
+ scope.WriteLock(Constants.Locks.ContentTree);
+ var allLangs = _languageRepository.GetMany().ToList();
+
+ // Use provided notification state or create new one
+ var state = notificationState ?? new Dictionary();
+
+ PublishResult result = CommitDocumentChangesInternal(
+ scope, content, evtMsgs, allLangs, state, userId);
+ scope.Complete();
+ return result;
+ }
+ }
+
+ ///
+ /// Handles a lot of business logic cases for how the document should be persisted.
+ ///
+ private PublishResult CommitDocumentChangesInternal(
+ ICoreScope scope,
+ IContent content,
+ EventMessages eventMessages,
+ IReadOnlyCollection allLangs,
+ IDictionary? notificationState,
+ int userId,
+ bool branchOne = false,
+ bool branchRoot = false)
+ {
+ // Copy exact implementation from ContentService lines 1080-1406
+ // This is ~330 lines - the core publishing logic
+ }
+
+ #endregion
+
+ #region Scheduled Publishing
+
+ ///
+ public IEnumerable PerformScheduledPublish(DateTime date)
+ {
+ var allLangs = new Lazy>(() => _languageRepository.GetMany().ToList());
+ EventMessages evtMsgs = EventMessagesFactory.Get();
+ var results = new List();
+
+ PerformScheduledPublishingRelease(date, results, evtMsgs, allLangs);
+ PerformScheduledPublishingExpiration(date, results, evtMsgs, allLangs);
+
+ // Critical Review Q4: Add explicit logging for failures
+ var failures = results.Where(r => !r.Success).ToList();
+ if (failures.Count > 0)
+ {
+ foreach (var failure in failures)
+ {
+ _logger.LogWarning(
+ "Scheduled publish failed for content {ContentId} ({ContentName}): {ResultType}",
+ failure.Content?.Id,
+ failure.Content?.Name,
+ failure.Result);
+ }
+
+ _logger.LogWarning(
+ "Scheduled publish completed with {FailureCount} failures out of {TotalCount} items",
+ failures.Count,
+ results.Count);
+ }
+
+ return results;
+ }
+
+ private void PerformScheduledPublishingExpiration(DateTime date, List results, EventMessages evtMsgs, Lazy> allLangs)
+ {
+ // Copy from ContentService lines 1421-1491
+ }
+
+ private void PerformScheduledPublishingRelease(DateTime date, List results, EventMessages evtMsgs, Lazy> allLangs)
+ {
+ // Copy from ContentService lines 1493-1610
+ }
+
+ ///
+ public IEnumerable GetContentForExpiration(DateTime date)
+ {
+ // Copy from ContentService lines 708-715
+ }
+
+ ///
+ public IEnumerable GetContentForRelease(DateTime date)
+ {
+ // Copy from ContentService lines 718-725
+ }
+
+ #endregion
+
+ #region Branch Publishing
+
+ private bool PublishBranch_PublishCultures(IContent content, HashSet culturesToPublish, IReadOnlyCollection allLangs)
+ {
+ // Copy from ContentService lines 1613-1631
+ }
+
+ private static HashSet? PublishBranch_ShouldPublish(ref HashSet? cultures, string c, bool published, bool edited, bool isRoot, PublishBranchFilter publishBranchFilter)
+ {
+ // Copy from ContentService lines 1634-1659
+ }
+
+ ///
+ public IEnumerable PublishBranch(IContent content, PublishBranchFilter publishBranchFilter, string[] cultures, int userId = Constants.Security.SuperUserId)
+ {
+ // Copy from ContentService lines 1662-1713
+ }
+
+ private static string[] EnsureCultures(IContent content, string[] cultures)
+ {
+ // Copy from ContentService lines 1715-1724
+ }
+
+ private static bool ProvidedCulturesIndicatePublishAll(string[] cultures) => cultures.Length == 0 || (cultures.Length == 1 && cultures[0] == "invariant");
+
+ internal IEnumerable PublishBranch(
+ IContent document,
+ Func?> shouldPublish,
+ Func, IReadOnlyCollection, bool> publishCultures,
+ int userId = Constants.Security.SuperUserId)
+ {
+ // Copy from ContentService lines 1728-1842
+ }
+
+ private PublishResult? PublishBranchItem(
+ ICoreScope scope,
+ IContent document,
+ HashSet? culturesToPublish,
+ Func, IReadOnlyCollection, bool> publishCultures,
+ bool isRoot,
+ ICollection publishedDocuments,
+ EventMessages evtMsgs,
+ int userId,
+ IReadOnlyCollection allLangs,
+ out IDictionary? initialNotificationState)
+ {
+ // Copy from ContentService lines 1847-1900
+ }
+
+ #endregion
+
+ #region Schedule Management
+
+ ///
+ public ContentScheduleCollection GetContentScheduleByContentId(int contentId)
+ {
+ // Copy from ContentService lines 497-504
+ }
+
+ ///
+ public ContentScheduleCollection GetContentScheduleByContentId(Guid contentId)
+ {
+ // Copy from ContentService lines 506-515
+ }
+
+ ///
+ public void PersistContentSchedule(IContent content, ContentScheduleCollection contentSchedule)
+ {
+ // Copy from ContentService lines 518-526
+ }
+
+ ///
+ public IDictionary> GetContentSchedulesByIds(Guid[] keys)
+ {
+ // Critical Review fix 2.4: Add null/empty check
+ ArgumentNullException.ThrowIfNull(keys);
+ if (keys.Length == 0)
+ {
+ return new Dictionary>();
+ }
+
+ // Copy from ContentService lines 759-783
+ }
+
+ #endregion
+
+ #region Path Checks
+
+ ///
+ public bool IsPathPublishable(IContent content)
+ {
+ // Fast path for root content
+ if (content.ParentId == Constants.System.Root)
+ {
+ return true; // root content is always publishable
+ }
+
+ if (content.Trashed)
+ {
+ return false; // trashed content is never publishable
+ }
+
+ // Critical Review fix 2.2: Use _crudService to avoid circular dependency
+ // Not trashed and has a parent: publishable if the parent is path-published
+ IContent? parent = GetParent(content);
+ return parent == null || IsPathPublished(parent);
+ }
+
+ ///
+ public bool IsPathPublished(IContent? content)
+ {
+ using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
+ {
+ scope.ReadLock(Constants.Locks.ContentTree);
+ return DocumentRepository.IsPathPublished(content);
+ }
+ }
+
+ #endregion
+
+ #region Workflow
+
+ ///
+ public bool SendToPublication(IContent? content, int userId = Constants.Security.SuperUserId)
+ {
+ // Copy from ContentService lines 2155-2207
+ // Note: Replace Save call with _crudService.Save
+ }
+
+ #endregion
+
+ #region Published Content Queries
+
+ ///
+ public IEnumerable GetPublishedChildren(int id)
+ {
+ // Copy from ContentService lines 622-630
+ }
+
+ ///
+ /// Gets a collection of published descendants.
+ ///
+ internal IEnumerable GetPublishedDescendants(IContent content)
+ {
+ // Copy from ContentService lines 2270-2277
+ }
+
+ internal IEnumerable GetPublishedDescendantsLocked(IContent content)
+ {
+ // Copy from ContentService lines 2279-2301
+ }
+
+ #endregion
+
+ #region Publishing Strategies
+
+ private PublishResult StrategyCanPublish(
+ ICoreScope scope,
+ IContent content,
+ bool checkPath,
+ IReadOnlyList? culturesPublishing,
+ IReadOnlyCollection? culturesUnpublishing,
+ EventMessages evtMsgs,
+ IReadOnlyCollection allLangs,
+ IDictionary? notificationState)
+ {
+ // Copy from ContentService lines 2356-2527
+ }
+
+ private PublishResult StrategyPublish(
+ IContent content,
+ IReadOnlyCollection? culturesPublishing,
+ IReadOnlyCollection? culturesUnpublishing,
+ EventMessages evtMsgs)
+ {
+ // Copy from ContentService lines 2541-2591
+ }
+
+ private PublishResult StrategyCanUnpublish(
+ ICoreScope scope,
+ IContent content,
+ EventMessages evtMsgs,
+ IDictionary? notificationState)
+ {
+ // Copy from ContentService lines 2601-2618
+ }
+
+ private PublishResult StrategyUnpublish(IContent content, EventMessages evtMsgs)
+ {
+ // Copy from ContentService lines 2631-2665
+ }
+
+ #endregion
+
+ #region Helper Methods
+
+ private static bool HasUnsavedChanges(IContent content) => content.HasIdentity is false || content.IsDirty();
+
+ private string GetLanguageDetailsForAuditEntry(IEnumerable affectedCultures)
+ => GetLanguageDetailsForAuditEntry(_languageRepository.GetMany(), affectedCultures);
+
+ private static string GetLanguageDetailsForAuditEntry(IEnumerable languages, IEnumerable affectedCultures)
+ {
+ IEnumerable languageIsoCodes = languages
+ .Where(x => affectedCultures.InvariantContains(x.IsoCode))
+ .Select(x => x.IsoCode);
+ return string.Join(", ", languageIsoCodes);
+ }
+
+ private static bool IsDefaultCulture(IReadOnlyCollection? langs, string culture) =>
+ langs?.Any(x => x.IsDefault && x.IsoCode.InvariantEquals(culture)) ?? false;
+
+ private bool IsMandatoryCulture(IReadOnlyCollection langs, string culture) =>
+ langs.Any(x => x.IsMandatory && x.IsoCode.InvariantEquals(culture));
+
+ ///
+ /// Gets paged descendants - required for branch operations.
+ ///
+ private IEnumerable GetPagedDescendants(int id, long pageIndex, int pageSize, out long totalRecords, Ordering? ordering = null)
+ {
+ using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
+ scope.ReadLock(Constants.Locks.ContentTree);
+
+ IQuery? query = Query();
+ IContent? content = DocumentRepository.Get(id);
+ if (content != null && !content.Path.IsNullOrWhiteSpace())
+ {
+ var path = content.Path + ",";
+ query?.Where(x => x.Path.SqlStartsWith(path, TextColumnType.NVarchar));
+ }
+
+ return DocumentRepository.GetPage(query, pageIndex, pageSize, out totalRecords, null, ordering ?? Ordering.By("Path"));
+ }
+
+ ///
+ /// Checks if content has children - required for publishing operations.
+ ///
+ private bool HasChildren(int id)
+ {
+ using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
+ scope.ReadLock(Constants.Locks.ContentTree);
+ IQuery query = Query().Where(x => x.ParentId == id);
+ var count = DocumentRepository.Count(query);
+ return count > 0;
+ }
+
+ ///
+ /// Gets parent content - required for IsPathPublishable.
+ ///
+ private IContent? GetParent(IContent? content)
+ {
+ if (content?.ParentId == null || content.ParentId == Constants.System.Root)
+ {
+ return null;
+ }
+
+ using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
+ scope.ReadLock(Constants.Locks.ContentTree);
+ return DocumentRepository.Get(content.ParentId);
+ }
+
+ #endregion
+```
+
+**Step 4: Verify the implementation compiles**
+
+Run: `dotnet build src/Umbraco.Core/Umbraco.Core.csproj --no-restore`
+Expected: Build succeeded
+
+**Step 5: Commit**
+
+```bash
+git add src/Umbraco.Core/Services/ContentPublishOperationService.cs
+git commit -m "feat(core): implement ContentPublishOperationService for Phase 5
+
+Implements all publishing operations:
+- Publish/Unpublish with culture support
+- CommitDocumentChangesInternal (core publishing logic)
+- PerformScheduledPublish for scheduled jobs
+- PublishBranch for tree publishing
+- Schedule management operations
+- Path checks (IsPathPublishable, IsPathPublished)
+- SendToPublication workflow
+- Publishing strategies (StrategyCanPublish, etc.)
+
+Part of ContentService refactoring Phase 5."
+```
+
+---
+
+## Task 3: Register ContentPublishOperationService in DI
+
+**Files:**
+- Modify: `src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs`
+
+**Step 1: Add the service registration**
+
+Add after line 304 (after IContentMoveOperationService):
+
+```csharp
+Services.AddUnique();
+```
+
+**Step 2: Update ContentService factory to inject the new service**
+
+Modify the ContentService factory registration (lines 305-327) to include the new parameter:
+
+```csharp
+Services.AddUnique(sp =>
+ new ContentService(
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService>(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService>(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService())); // NEW
+```
+
+**Step 3: Verify compilation**
+
+Run: `dotnet build src/Umbraco.Core/Umbraco.Core.csproj --no-restore`
+Expected: Build succeeded
+
+**Step 4: Commit**
+
+```bash
+git add src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs
+git commit -m "feat(di): register IContentPublishOperationService
+
+Adds DI registration for the new publish operation service
+and updates ContentService factory to inject it.
+
+Part of ContentService refactoring Phase 5."
+```
+
+---
+
+## Task 4: Update ContentService to Accept IContentPublishOperationService
+
+**Files:**
+- Modify: `src/Umbraco.Core/Services/ContentService.cs`
+
+**Step 1: Add field and property for the publish operation service**
+
+Add after line 64 (after move operation service fields):
+
+```csharp
+ // Publish operation service fields (for Phase 5 extracted publish operations)
+ private readonly IContentPublishOperationService? _publishOperationService;
+ private readonly Lazy? _publishOperationServiceLazy;
+
+ ///
+ /// Gets the publish operation service.
+ ///
+ /// Thrown if the service was not properly initialized.
+ private IContentPublishOperationService PublishOperationService =>
+ _publishOperationService ?? _publishOperationServiceLazy?.Value
+ ?? throw new InvalidOperationException("PublishOperationService not initialized. Ensure the service is properly injected via constructor.");
+```
+
+**Step 2: Update the primary constructor to accept IContentPublishOperationService**
+
+Modify the primary constructor (starting at line 92) to add the new parameter:
+
+```csharp
+ [Microsoft.Extensions.DependencyInjection.ActivatorUtilitiesConstructor]
+ public ContentService(
+ ICoreScopeProvider provider,
+ ILoggerFactory loggerFactory,
+ IEventMessagesFactory eventMessagesFactory,
+ IDocumentRepository documentRepository,
+ IEntityRepository entityRepository,
+ IAuditService auditService,
+ IContentTypeRepository contentTypeRepository,
+ IDocumentBlueprintRepository documentBlueprintRepository,
+ ILanguageRepository languageRepository,
+ Lazy propertyValidationService,
+ IShortStringHelper shortStringHelper,
+ ICultureImpactFactory cultureImpactFactory,
+ IUserIdKeyResolver userIdKeyResolver,
+ PropertyEditorCollection propertyEditorCollection,
+ IIdKeyMap idKeyMap,
+ IOptionsMonitor optionsMonitor,
+ IRelationService relationService,
+ IContentCrudService crudService,
+ IContentQueryOperationService queryOperationService,
+ IContentVersionOperationService versionOperationService,
+ IContentMoveOperationService moveOperationService,
+ IContentPublishOperationService publishOperationService) // NEW PARAMETER
+ : base(provider, loggerFactory, eventMessagesFactory)
+ {
+ // ... existing field assignments ...
+
+ // Phase 5: Publish operation service (direct injection)
+ ArgumentNullException.ThrowIfNull(publishOperationService);
+ _publishOperationService = publishOperationService;
+ _publishOperationServiceLazy = null; // Not needed when directly injected
+ }
+```
+
+**Step 3: Update obsolete constructors to lazy-resolve the new service**
+
+Add to both obsolete constructors (around lines 156 and 222):
+
+```csharp
+ // Phase 5: Lazy resolution of IContentPublishOperationService
+ _publishOperationServiceLazy = new Lazy(() =>
+ StaticServiceProvider.Instance.GetRequiredService(),
+ LazyThreadSafetyMode.ExecutionAndPublication);
+```
+
+**Step 4: Verify compilation**
+
+Run: `dotnet build src/Umbraco.Core/Umbraco.Core.csproj --no-restore`
+Expected: Build succeeded
+
+**Step 5: Commit**
+
+```bash
+git add src/Umbraco.Core/Services/ContentService.cs
+git commit -m "refactor(core): add IContentPublishOperationService injection to ContentService
+
+Adds field, property, and constructor parameter for the new
+publish operation service. Obsolete constructors use lazy
+resolution for backward compatibility.
+
+Part of ContentService refactoring Phase 5."
+```
+
+---
+
+## Task 5: Delegate Publishing Methods from ContentService
+
+**Files:**
+- Modify: `src/Umbraco.Core/Services/ContentService.cs`
+
+This is a large task - we need to replace method implementations with delegations.
+
+**Step 1: Replace Publish method**
+
+Find the `Publish` method (around line 830) and replace with:
+
+```csharp
+ ///
+ public PublishResult Publish(IContent content, string[] cultures, int userId = Constants.Security.SuperUserId)
+ => PublishOperationService.Publish(content, cultures, userId);
+```
+
+**Step 2: Replace Unpublish method**
+
+Find the `Unpublish` method (around line 918) and replace with:
+
+```csharp
+ ///
+ public PublishResult Unpublish(IContent content, string? culture = "*", int userId = Constants.Security.SuperUserId)
+ => PublishOperationService.Unpublish(content, culture, userId);
+```
+
+**Step 3: Replace schedule management methods**
+
+Replace `GetContentScheduleByContentId` (int overload):
+
+```csharp
+ ///
+ public ContentScheduleCollection GetContentScheduleByContentId(int contentId)
+ => PublishOperationService.GetContentScheduleByContentId(contentId);
+```
+
+Replace `GetContentScheduleByContentId` (Guid overload):
+
+```csharp
+ public ContentScheduleCollection GetContentScheduleByContentId(Guid contentId)
+ => PublishOperationService.GetContentScheduleByContentId(contentId);
+```
+
+Replace `PersistContentSchedule`:
+
+```csharp
+ ///
+ public void PersistContentSchedule(IContent content, ContentScheduleCollection contentSchedule)
+ => PublishOperationService.PersistContentSchedule(content, contentSchedule);
+```
+
+Replace `GetContentSchedulesByIds`:
+
+```csharp
+ ///
+ public IDictionary> GetContentSchedulesByIds(Guid[] keys)
+ => PublishOperationService.GetContentSchedulesByIds(keys);
+```
+
+**Step 4: Replace scheduled publishing methods**
+
+Replace `PerformScheduledPublish`:
+
+```csharp
+ ///
+ public IEnumerable PerformScheduledPublish(DateTime date)
+ => PublishOperationService.PerformScheduledPublish(date);
+```
+
+Replace `GetContentForExpiration`:
+
+```csharp
+ ///
+ public IEnumerable GetContentForExpiration(DateTime date)
+ => PublishOperationService.GetContentForExpiration(date);
+```
+
+Replace `GetContentForRelease`:
+
+```csharp
+ ///
+ public IEnumerable GetContentForRelease(DateTime date)
+ => PublishOperationService.GetContentForRelease(date);
+```
+
+**Step 5: Replace path check methods**
+
+Replace `IsPathPublishable`:
+
+```csharp
+ ///
+ public bool IsPathPublishable(IContent content)
+ => PublishOperationService.IsPathPublishable(content);
+```
+
+Replace `IsPathPublished`:
+
+```csharp
+ public bool IsPathPublished(IContent? content)
+ => PublishOperationService.IsPathPublished(content);
+```
+
+**Step 6: Replace branch publishing**
+
+Replace `PublishBranch`:
+
+```csharp
+ ///
+ public IEnumerable PublishBranch(IContent content, PublishBranchFilter publishBranchFilter, string[] cultures, int userId = Constants.Security.SuperUserId)
+ => PublishOperationService.PublishBranch(content, publishBranchFilter, cultures, userId);
+```
+
+**Step 7: Replace workflow methods**
+
+Replace `SendToPublication`:
+
+```csharp
+ ///
+ public bool SendToPublication(IContent? content, int userId = Constants.Security.SuperUserId)
+ => PublishOperationService.SendToPublication(content, userId);
+```
+
+**Step 8: Replace GetPublishedChildren**
+
+```csharp
+ public IEnumerable GetPublishedChildren(int id)
+ => PublishOperationService.GetPublishedChildren(id);
+```
+
+**Step 9: Remove the now-unused private methods**
+
+Delete the following methods from ContentService (they now live in ContentPublishOperationService):
+
+**Definitely delete (moved to ContentPublishOperationService):**
+- `CommitDocumentChanges` (internal) - now on interface
+- `CommitDocumentChangesInternal` (private)
+- `PerformScheduledPublishingExpiration` (private)
+- `PerformScheduledPublishingRelease` (private)
+- `PublishBranch_PublishCultures` (private)
+- `PublishBranch_ShouldPublish` (private)
+- `EnsureCultures` (private)
+- `ProvidedCulturesIndicatePublishAll` (private)
+- `PublishBranch` internal overload
+- `PublishBranchItem` (private)
+- `StrategyCanPublish` (private)
+- `StrategyPublish` (private)
+- `StrategyCanUnpublish` (private)
+- `StrategyUnpublish` (private)
+- `IsDefaultCulture` (private)
+- `IsMandatoryCulture` (private)
+
+**Delete (no longer needed - facade uses CommitDocumentChanges which handles internally):**
+- `GetPublishedDescendants` (internal) - Key Decision #5: CommitDocumentChanges handles descendants internally
+- `GetPublishedDescendantsLocked` (internal) - Called by CommitDocumentChangesInternal, not needed in facade
+
+**Conditional delete (verify MoveToRecycleBin doesn't use directly):**
+- `HasUnsavedChanges` (private) - If MoveToRecycleBin doesn't check this, delete
+- `GetLanguageDetailsForAuditEntry` (private) - If no remaining callers, delete
+
+**Step 10: Verify compilation**
+
+Run: `dotnet build src/Umbraco.Core/Umbraco.Core.csproj --no-restore`
+Expected: Build succeeded
+
+**Step 11: Run tests to verify behavior unchanged**
+
+Run: `dotnet test tests/Umbraco.Tests.Integration --filter "FullyQualifiedName~ContentService" --no-restore`
+Expected: All tests pass
+
+**Step 12: Commit**
+
+```bash
+git add src/Umbraco.Core/Services/ContentService.cs
+git commit -m "refactor(core): delegate publish operations to ContentPublishOperationService
+
+Replaces publishing method implementations with delegations:
+- Publish/Unpublish
+- PerformScheduledPublish
+- PublishBranch
+- GetContentScheduleByContentId
+- PersistContentSchedule
+- GetContentSchedulesByIds
+- GetContentForExpiration/Release
+- IsPathPublishable/IsPathPublished
+- SendToPublication
+- GetPublishedChildren
+
+Removes ~1500 lines of implementation that now lives in
+ContentPublishOperationService.
+
+Part of ContentService refactoring Phase 5."
+```
+
+---
+
+## Task 6: Add Interface Contract Tests
+
+**Files:**
+- Create: `tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentPublishOperationServiceContractTests.cs`
+
+**Step 1: Create interface contract test file**
+
+```csharp
+using System.ComponentModel;
+using NUnit.Framework;
+using Umbraco.Cms.Core.Models;
+using Umbraco.Cms.Core.Services;
+
+namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services;
+
+///
+/// Contract tests for IContentPublishOperationService interface.
+/// These tests verify interface design and expected behaviors.
+///
+[TestFixture]
+public class ContentPublishOperationServiceContractTests
+{
+ [Test]
+ public void IContentPublishOperationService_Inherits_From_IService()
+ {
+ // Assert
+ Assert.That(typeof(IService).IsAssignableFrom(typeof(IContentPublishOperationService)), Is.True);
+ }
+
+ [Test]
+ public void Publish_Method_Exists_With_Expected_Signature()
+ {
+ // Arrange
+ var methodInfo = typeof(IContentPublishOperationService).GetMethod(
+ nameof(IContentPublishOperationService.Publish),
+ new[] { typeof(IContent), typeof(string[]), typeof(int) });
+
+ // Assert
+ Assert.That(methodInfo, Is.Not.Null);
+ Assert.That(methodInfo!.ReturnType, Is.EqualTo(typeof(PublishResult)));
+ }
+
+ [Test]
+ public void Unpublish_Method_Exists_With_Expected_Signature()
+ {
+ // Arrange
+ var methodInfo = typeof(IContentPublishOperationService).GetMethod(
+ nameof(IContentPublishOperationService.Unpublish),
+ new[] { typeof(IContent), typeof(string), typeof(int) });
+
+ // Assert
+ Assert.That(methodInfo, Is.Not.Null);
+ Assert.That(methodInfo!.ReturnType, Is.EqualTo(typeof(PublishResult)));
+ }
+
+ [Test]
+ public void PublishBranch_Method_Exists_With_Expected_Signature()
+ {
+ // Arrange
+ var methodInfo = typeof(IContentPublishOperationService).GetMethod(
+ nameof(IContentPublishOperationService.PublishBranch),
+ new[] { typeof(IContent), typeof(PublishBranchFilter), typeof(string[]), typeof(int) });
+
+ // Assert
+ Assert.That(methodInfo, Is.Not.Null);
+ Assert.That(methodInfo!.ReturnType, Is.EqualTo(typeof(IEnumerable)));
+ }
+
+ [Test]
+ public void PerformScheduledPublish_Method_Exists_With_Expected_Signature()
+ {
+ // Arrange
+ var methodInfo = typeof(IContentPublishOperationService).GetMethod(
+ nameof(IContentPublishOperationService.PerformScheduledPublish),
+ new[] { typeof(DateTime) });
+
+ // Assert
+ Assert.That(methodInfo, Is.Not.Null);
+ Assert.That(methodInfo!.ReturnType, Is.EqualTo(typeof(IEnumerable)));
+ }
+
+ [Test]
+ public void GetContentScheduleByContentId_IntOverload_Exists()
+ {
+ // Arrange
+ var methodInfo = typeof(IContentPublishOperationService).GetMethod(
+ nameof(IContentPublishOperationService.GetContentScheduleByContentId),
+ new[] { typeof(int) });
+
+ // Assert
+ Assert.That(methodInfo, Is.Not.Null);
+ Assert.That(methodInfo!.ReturnType, Is.EqualTo(typeof(ContentScheduleCollection)));
+ }
+
+ [Test]
+ public void GetContentScheduleByContentId_GuidOverload_Exists()
+ {
+ // Arrange
+ var methodInfo = typeof(IContentPublishOperationService).GetMethod(
+ nameof(IContentPublishOperationService.GetContentScheduleByContentId),
+ new[] { typeof(Guid) });
+
+ // Assert
+ Assert.That(methodInfo, Is.Not.Null);
+ Assert.That(methodInfo!.ReturnType, Is.EqualTo(typeof(ContentScheduleCollection)));
+ }
+
+ [Test]
+ public void IsPathPublishable_Method_Exists()
+ {
+ // Arrange
+ var methodInfo = typeof(IContentPublishOperationService).GetMethod(
+ nameof(IContentPublishOperationService.IsPathPublishable),
+ new[] { typeof(IContent) });
+
+ // Assert
+ Assert.That(methodInfo, Is.Not.Null);
+ Assert.That(methodInfo!.ReturnType, Is.EqualTo(typeof(bool)));
+ }
+
+ [Test]
+ public void IsPathPublished_Method_Exists()
+ {
+ // Arrange
+ var methodInfo = typeof(IContentPublishOperationService).GetMethod(
+ nameof(IContentPublishOperationService.IsPathPublished),
+ new[] { typeof(IContent) });
+
+ // Assert
+ Assert.That(methodInfo, Is.Not.Null);
+ Assert.That(methodInfo!.ReturnType, Is.EqualTo(typeof(bool)));
+ }
+
+ [Test]
+ public void SendToPublication_Method_Exists()
+ {
+ // Arrange
+ var methodInfo = typeof(IContentPublishOperationService).GetMethod(
+ nameof(IContentPublishOperationService.SendToPublication),
+ new[] { typeof(IContent), typeof(int) });
+
+ // Assert
+ Assert.That(methodInfo, Is.Not.Null);
+ Assert.That(methodInfo!.ReturnType, Is.EqualTo(typeof(bool)));
+ }
+
+ [Test]
+ public void CommitDocumentChanges_Method_Exists_With_NotificationState_Parameter()
+ {
+ // Arrange - Critical Review Option A: Exposed for orchestration
+ var methodInfo = typeof(IContentPublishOperationService).GetMethod(
+ nameof(IContentPublishOperationService.CommitDocumentChanges),
+ new[] { typeof(IContent), typeof(int), typeof(IDictionary) });
+
+ // Assert
+ Assert.That(methodInfo, Is.Not.Null);
+ Assert.That(methodInfo!.ReturnType, Is.EqualTo(typeof(PublishResult)));
+ }
+
+ [Test]
+ public void CommitDocumentChanges_Has_EditorBrowsable_Advanced_Attribute()
+ {
+ // Arrange - Should be hidden from IntelliSense by default
+ var methodInfo = typeof(IContentPublishOperationService).GetMethod(
+ nameof(IContentPublishOperationService.CommitDocumentChanges),
+ new[] { typeof(IContent), typeof(int), typeof(IDictionary) });
+
+ // Act
+ var attribute = methodInfo?.GetCustomAttributes(typeof(EditorBrowsableAttribute), false)
+ .Cast()
+ .FirstOrDefault();
+
+ // Assert
+ Assert.That(attribute, Is.Not.Null);
+ Assert.That(attribute!.State, Is.EqualTo(EditorBrowsableState.Advanced));
+ }
+}
+```
+
+**Step 2: Run the contract tests**
+
+Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~ContentPublishOperationServiceContractTests" --no-restore`
+Expected: All tests pass
+
+**Step 3: Commit**
+
+```bash
+git add tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentPublishOperationServiceContractTests.cs
+git commit -m "test(unit): add ContentPublishOperationService interface contract tests
+
+Verifies interface design and method signatures for Phase 5.
+
+Part of ContentService refactoring Phase 5."
+```
+
+---
+
+## Task 7: Add Integration Tests
+
+**Files:**
+- Modify: `tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceRefactoringTests.cs`
+
+**Step 1: Add integration test for IContentPublishOperationService DI registration**
+
+Add to the existing test file:
+
+```csharp
+ [Test]
+ public void ContentPublishOperationService_Can_Be_Resolved_From_DI()
+ {
+ // Act
+ var publishOperationService = GetRequiredService();
+
+ // Assert
+ Assert.That(publishOperationService, Is.Not.Null);
+ Assert.That(publishOperationService, Is.InstanceOf());
+ }
+
+ [Test]
+ public async Task Publish_Through_ContentService_Uses_PublishOperationService()
+ {
+ // Arrange
+ var contentService = GetRequiredService();
+ var contentTypeService = GetRequiredService();
+
+ var contentType = new ContentTypeBuilder()
+ .WithAlias("testPublishPage")
+ .Build();
+ await contentTypeService.SaveAsync(contentType, Constants.Security.SuperUserId);
+
+ var content = contentService.Create("Test Publish Page", Constants.System.Root, contentType.Alias);
+ contentService.Save(content);
+
+ // Act
+ var result = contentService.Publish(content, new[] { "*" });
+
+ // Assert
+ Assert.That(result.Success, Is.True);
+ Assert.That(content.Published, Is.True);
+ }
+
+ [Test]
+ public async Task Unpublish_Through_ContentService_Uses_PublishOperationService()
+ {
+ // Arrange
+ var contentService = GetRequiredService();
+ var contentTypeService = GetRequiredService();
+
+ var contentType = new ContentTypeBuilder()
+ .WithAlias("testUnpublishPage")
+ .Build();
+ await contentTypeService.SaveAsync(contentType, Constants.Security.SuperUserId);
+
+ var content = contentService.Create("Test Unpublish Page", Constants.System.Root, contentType.Alias);
+ contentService.Save(content);
+ contentService.Publish(content, new[] { "*" });
+
+ // Act
+ var result = contentService.Unpublish(content);
+
+ // Assert
+ Assert.That(result.Success, Is.True);
+ Assert.That(content.Published, Is.False);
+ }
+
+ [Test]
+ public async Task IsPathPublishable_RootContent_ReturnsTrue()
+ {
+ // Arrange
+ var contentService = GetRequiredService();
+ var contentTypeService = GetRequiredService();
+
+ var contentType = new ContentTypeBuilder()
+ .WithAlias("testPathPage")
+ .Build();
+ await contentTypeService.SaveAsync(contentType, Constants.Security.SuperUserId);
+
+ var content = contentService.Create("Test Path Page", Constants.System.Root, contentType.Alias);
+ contentService.Save(content);
+
+ // Act
+ var result = contentService.IsPathPublishable(content);
+
+ // Assert
+ Assert.That(result, Is.True);
+ }
+```
+
+**Step 2: Run integration tests**
+
+Run: `dotnet test tests/Umbraco.Tests.Integration --filter "FullyQualifiedName~ContentServiceRefactoringTests" --no-restore`
+Expected: All tests pass
+
+**Step 3: Commit**
+
+```bash
+git add tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceRefactoringTests.cs
+git commit -m "test(integration): add ContentPublishOperationService integration tests
+
+Tests DI resolution and basic publish/unpublish operations
+delegated through ContentService to the new service.
+
+Part of ContentService refactoring Phase 5."
+```
+
+---
+
+## Task 8: Run Full Test Suite and Verify
+
+**Step 1: Run all ContentService tests**
+
+Run: `dotnet test tests/Umbraco.Tests.Integration --filter "FullyQualifiedName~ContentService" --no-restore`
+Expected: All tests pass
+
+**Step 2: Run refactoring-specific tests**
+
+Run: `dotnet test tests/Umbraco.Tests.Integration --filter "FullyQualifiedName~ContentServiceRefactoringTests" --no-restore`
+Expected: All tests pass
+
+**Step 3: Run notification ordering tests (critical for publishing)**
+
+Run: `dotnet test tests/Umbraco.Tests.Integration --filter "FullyQualifiedName~ContentNotification" --no-restore`
+Expected: All tests pass
+
+**Step 4: Commit final verification**
+
+```bash
+git tag phase-5-publish-extraction
+git commit --allow-empty -m "chore: Phase 5 complete - ContentPublishOperationService extracted
+
+Phase 5 Summary:
+- Created IContentPublishOperationService interface (30+ methods)
+- Created ContentPublishOperationService implementation (~1500 lines)
+- Updated DI registration
+- Updated ContentService to inject and delegate to new service
+- All tests passing
+
+ContentService reduced from ~3000 to ~1500 lines."
+```
+
+---
+
+## Task 9: Update Design Document
+
+**Files:**
+- Modify: `docs/plans/2025-12-19-contentservice-refactor-design.md`
+
+**Step 1: Update the phase status**
+
+Change Phase 5 status from "Pending" to "Complete":
+
+```markdown
+| 5 | Publish Operation Service | All ContentService*Tests + Notification ordering tests | All pass | ✅ Complete |
+```
+
+Add to revision history:
+
+```markdown
+| 1.9 | Phase 5 complete - ContentPublishOperationService extracted |
+```
+
+**Step 2: Commit**
+
+```bash
+git add docs/plans/2025-12-19-contentservice-refactor-design.md
+git commit -m "docs: mark Phase 5 complete in design document
+
+Updates revision history and phase status."
+```
+
+---
+
+## Summary
+
+**Phase 5 extracts ~1,500 lines of publishing logic into a dedicated service:**
+
+| Artifact | Description |
+|----------|-------------|
+| `IContentPublishOperationService` | Interface with 16 public methods (including CommitDocumentChanges) |
+| `ContentPublishOperationService` | Implementation with ~1,500 lines |
+| Contract Tests | 12 unit tests for interface |
+| Integration Tests | 4 tests for DI and basic operations |
+
+**After Phase 5, ContentService will be ~1,500 lines (down from ~3,000).**
+
+**Remaining Phases:**
+- Phase 6: Permission Manager (~50 lines)
+- Phase 7: Blueprint Manager (~200 lines)
+- Phase 8: Final Facade cleanup
+
+---
+
+## Revision History
+
+| Version | Date | Changes |
+|---------|------|---------|
+| 1.0 | 2025-12-23 | Initial plan |
+| 1.1 | 2025-12-23 | Applied critical review recommendations: |
+| | | - Added `CommitDocumentChanges` to interface (Option A) for facade orchestration |
+| | | - Added optional `notificationState` parameter for state propagation |
+| | | - Added thread-safe `ContentSettings` accessor with lock |
+| | | - Added null/empty check to `GetContentSchedulesByIds` |
+| | | - Added explicit failure logging in `PerformScheduledPublish` |
+| | | - Clarified GetPublishedDescendants stays internal (Key Decision #5) |
+| | | - Fixed IsPathPublishable to use DocumentRepository (avoid circular dependency) |
+| | | - Added contract tests for CommitDocumentChanges and EditorBrowsable attribute |
+| | | - Fixed test framework documentation (NUnit, not xUnit) |
+
+---
+
+**Plan ready for execution via `superpowers:executing-plans`.**