Adds IContentCrudService registration to UmbracoBuilder alongside IContentService. Both services are now resolvable from DI. Includes integration test verifying successful resolution. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
531 lines
19 KiB
Markdown
531 lines
19 KiB
Markdown
# Critical Implementation Review: Phase 1 ContentService CRUD Extraction
|
|
|
|
**Plan Version:** 1.2
|
|
**Plan File:** `docs/plans/2025-12-20-contentservice-refactor-phase1-implementation.md`
|
|
**Reviewer:** Critical Implementation Review
|
|
**Date:** 2025-12-20
|
|
|
|
---
|
|
|
|
## 1. Overall Assessment
|
|
|
|
**Summary:** The plan is well-structured with clear task sequencing, versioning strategy, and already incorporates fixes from a previous review (v1.1). The architecture follows established Umbraco patterns. However, several critical issues remain that must be addressed before implementation to prevent production bugs, performance degradation, and maintenance challenges.
|
|
|
|
**Strengths:**
|
|
- Clear versioning strategy with deprecation policy
|
|
- TDD approach with failing tests first
|
|
- Incremental commits for easy rollback
|
|
- Good separation of concerns via delegation pattern
|
|
- Previous review feedback already incorporated (nested scope fix, Lazy<T> pattern, batch audit fix)
|
|
|
|
**Major Concerns:**
|
|
1. **GetAncestors has O(n) database round trips** for deeply nested content
|
|
2. **Incomplete interface vs. IContentService parity** - missing methods will break delegation
|
|
3. **Constructor bloat risk** - 17+ parameters creates maintenance burden
|
|
4. **Filter parameter ignored** in GetPagedChildren/GetPagedDescendants
|
|
5. **Race condition** in delete loop with concurrent operations
|
|
|
|
---
|
|
|
|
## 2. Critical Issues
|
|
|
|
### 2.1 GetAncestors N+1 Query Pattern (Performance)
|
|
|
|
**Location:** Lines 795-807 in ContentCrudService implementation
|
|
|
|
**Description:** The `GetAncestors` implementation walks up the tree one node at a time:
|
|
|
|
```csharp
|
|
while (current is not null)
|
|
{
|
|
ancestors.Add(current);
|
|
current = GetParent(current); // Each call = 1 database round trip
|
|
}
|
|
```
|
|
|
|
**Impact:** For content at level 10, this triggers 10 separate database queries. Large sites with deep hierarchies will suffer severe performance degradation.
|
|
|
|
**Fix:**
|
|
```csharp
|
|
public IEnumerable<IContent> GetAncestors(IContent content)
|
|
{
|
|
if (content?.Path == null || content.Level <= 1)
|
|
{
|
|
return Enumerable.Empty<IContent>();
|
|
}
|
|
|
|
// Parse path to get ancestor IDs: "-1,123,456,789" -> [123, 456]
|
|
var ancestorIds = content.Path
|
|
.Split(',')
|
|
.Skip(1) // Skip root (-1)
|
|
.Select(int.Parse)
|
|
.Where(id => id != content.Id) // Exclude self
|
|
.ToArray();
|
|
|
|
return GetByIds(ancestorIds); // Single batch query
|
|
}
|
|
```
|
|
|
|
**Severity:** HIGH - Performance critical for tree operations
|
|
|
|
---
|
|
|
|
### 2.2 Incomplete Interface Parity with IContentService
|
|
|
|
**Location:** Lines 210-431 in IContentCrudService interface definition
|
|
|
|
**Description:** The plan delegates these methods to `CrudService`, but `IContentCrudService` doesn't define them all. Specifically:
|
|
- `GetPagedChildren` signature mismatch - existing ContentService has multiple overloads
|
|
- Missing `HasChildren(int id)` which may be called internally
|
|
- Missing `Exists(int id)` and `Exists(Guid key)` read operations
|
|
|
|
**Impact:** When `ContentService` delegates to `CrudService`, it will get compilation errors if the interface lacks these methods.
|
|
|
|
**Fix:** Audit all methods being delegated in Task 5 (lines 1249-1312) and ensure every signature exists in `IContentCrudService`. Add:
|
|
```csharp
|
|
bool HasChildren(int id);
|
|
bool Exists(int id);
|
|
bool Exists(Guid key);
|
|
```
|
|
|
|
**Severity:** HIGH - Build failure during implementation
|
|
|
|
---
|
|
|
|
### 2.3 Filter Parameter Silently Ignored in GetPagedChildren
|
|
|
|
**Location:** Lines 818-825 in ContentCrudService implementation
|
|
|
|
**Description:**
|
|
```csharp
|
|
if (filter is not null)
|
|
{
|
|
// Note: Query combination logic would need to be implemented
|
|
// For now, the filter is applied after the parent filter
|
|
}
|
|
```
|
|
|
|
The comment says "would need to be implemented" but the code doesn't implement it. The filter is passed to `DocumentRepository.GetPage` but the parent query is separate, meaning **the filter may not work correctly**.
|
|
|
|
**Impact:** Callers relying on filter behavior will get unexpected results.
|
|
|
|
**Fix:** Properly combine queries using the repository's query capabilities. The `DocumentRepository.GetPage` method accepts both a base query and a filter parameter, which it combines internally:
|
|
|
|
```csharp
|
|
/// <inheritdoc />
|
|
public IEnumerable<IContent> GetPagedChildren(int id, long pageIndex, int pageSize, out long totalChildren, IQuery<IContent>? filter = null, Ordering? ordering = null)
|
|
{
|
|
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
|
|
{
|
|
scope.ReadLock(Constants.Locks.ContentTree);
|
|
|
|
// Create base query for parent constraint
|
|
IQuery<IContent> query = Query<IContent>().Where(x => x.ParentId == id);
|
|
|
|
// Pass both query and filter to repository - repository handles combination
|
|
// The filter parameter is applied as an additional WHERE clause by the repository
|
|
return DocumentRepository.GetPage(query, pageIndex, pageSize, out totalChildren, filter, ordering);
|
|
}
|
|
}
|
|
```
|
|
|
|
Remove the misleading comment block entirely. The repository's `GetPage` signature is:
|
|
```csharp
|
|
IEnumerable<IContent> GetPage(IQuery<IContent>? query, long pageIndex, int pageSize,
|
|
out long totalRecords, IQuery<IContent>? filter, Ordering? ordering);
|
|
```
|
|
|
|
The `query` parameter provides the base constraint (parentId), and `filter` provides additional caller-specified filtering. Both are combined by the repository layer.
|
|
|
|
**Severity:** HIGH - Behavioral parity violation
|
|
|
|
---
|
|
|
|
### 2.4 Race Condition in Delete Loop
|
|
|
|
**Location:** Lines 1020-1034 in DeleteLocked
|
|
|
|
**Description:**
|
|
```csharp
|
|
while (total > 0)
|
|
{
|
|
IEnumerable<IContent> descendants = GetPagedDescendantsLocked(content.Id, 0, pageSize, out total);
|
|
foreach (IContent c in descendants)
|
|
{
|
|
DoDelete(c);
|
|
}
|
|
}
|
|
```
|
|
|
|
If another process adds content while this loop runs, `total` might never reach 0 (content added between deletion batches). Though unlikely in practice due to write locks, the loop condition is fragile.
|
|
|
|
**Impact:** Potential infinite loop in rare concurrent scenarios.
|
|
|
|
**Fix:** Use a bounded iteration count or break when no results returned:
|
|
```csharp
|
|
const int maxIterations = 10000; // Safety limit
|
|
int iterations = 0;
|
|
while (total > 0 && iterations++ < maxIterations)
|
|
{
|
|
IEnumerable<IContent> descendants = GetPagedDescendantsLocked(content.Id, 0, pageSize, out total);
|
|
var batch = descendants.ToList(); // Materialize once
|
|
if (batch.Count == 0) break; // No more results, exit even if total > 0
|
|
|
|
foreach (IContent c in batch)
|
|
{
|
|
DoDelete(c);
|
|
}
|
|
}
|
|
|
|
if (iterations >= maxIterations)
|
|
{
|
|
_logger.LogWarning("Delete operation for content {ContentId} reached max iterations ({MaxIterations})",
|
|
content.Id, maxIterations);
|
|
}
|
|
```
|
|
|
|
**Severity:** MEDIUM - Edge case but could cause production issues
|
|
|
|
---
|
|
|
|
### 2.5 Missing Logging for Operation Failures
|
|
|
|
**Location:** Throughout ContentCrudService implementation
|
|
|
|
**Description:** The `Save` and `Delete` methods don't log when operations are cancelled or fail. The original `ContentService` has `_logger` usage in critical paths.
|
|
|
|
**Impact:** Production debugging becomes difficult without visibility into cancelled operations.
|
|
|
|
**Fix:** Add logging for cancellation and validation failures:
|
|
```csharp
|
|
if (scope.Notifications.PublishCancelable(savingNotification))
|
|
{
|
|
_logger.LogInformation("Save operation cancelled for content {ContentId} by notification handler", content.Id);
|
|
scope.Complete();
|
|
return OperationResult.Cancel(eventMessages);
|
|
}
|
|
```
|
|
|
|
**Severity:** MEDIUM - Operational visibility
|
|
|
|
---
|
|
|
|
### 2.6 ContentServiceBase Audit Method Blocks Async
|
|
|
|
**Location:** Lines 133-135 in ContentServiceBase
|
|
|
|
**Description:**
|
|
```csharp
|
|
protected void Audit(AuditType type, int userId, int objectId, string? message = null, string? parameters = null) =>
|
|
AuditAsync(type, userId, objectId, message, parameters).GetAwaiter().GetResult();
|
|
```
|
|
|
|
Using `.GetAwaiter().GetResult()` blocks the thread and can cause deadlocks in certain synchronization contexts.
|
|
|
|
**Impact:** Potential deadlocks on ASP.NET Core when called from async context.
|
|
|
|
**Note:** This matches the existing `ContentService` pattern (line 3122-3123), so maintaining parity is acceptable for Phase 1. However, consider marking this for future refactoring to async-first approach.
|
|
|
|
**Severity:** LOW (maintains existing behavior) - Flag for future phases
|
|
|
|
---
|
|
|
|
## 3. Minor Issues & Improvements
|
|
|
|
### 3.1 Inconsistent Null-Forgiving Operator Usage
|
|
|
|
**Location:** Line 643 - `new Content(name, parent!, contentType, userId)`
|
|
|
|
**Issue:** Uses `!` on parent despite checking `parentId > 0 && parent is null` throws. This is correct but looks suspicious.
|
|
|
|
**Suggestion:** Add comment or restructure:
|
|
```csharp
|
|
// Parent is guaranteed non-null here because parentId > 0 and we throw if parent not found
|
|
Content content = new Content(name, parent!, contentType, userId);
|
|
```
|
|
|
|
### 3.2 Code Duplication in CreateAndSave Overloads
|
|
|
|
**Location:** Lines 620-683 (both CreateAndSave methods)
|
|
|
|
**Issue:** Both methods have nearly identical validation and setup logic.
|
|
|
|
**Suggestion:** Extract common logic to private helper:
|
|
```csharp
|
|
private Content CreateContentInternal(string name, IContent? parent, int parentId, IContentType contentType, int userId)
|
|
{
|
|
// Shared validation and construction
|
|
}
|
|
```
|
|
|
|
### 3.3 Missing XML Documentation for Internal Methods
|
|
|
|
**Location:** `GetPagedDescendantsLocked`, `DeleteLocked`
|
|
|
|
**Issue:** Internal methods lack documentation about preconditions (must be called within scope with write lock).
|
|
|
|
**Suggestion:** Add `<remarks>` documenting the scope/lock requirements.
|
|
|
|
### 3.4 Test Coverage Gap
|
|
|
|
**Location:** Task 3, Step 1 - Only one unit test
|
|
|
|
**Issue:** The single constructor test (`Constructor_WithValidDependencies_CreatesInstance`) doesn't test any actual behavior.
|
|
|
|
**Suggestion:** Add unit tests for:
|
|
- `Create` with invalid parent ID throws
|
|
- `Create` with null content type throws
|
|
- `GetById` returns null for non-existent ID
|
|
- Batch save with empty collection
|
|
|
|
### 3.5 Potential Memory Pressure in GetByIds Dictionary
|
|
|
|
**Location:** Lines 723-724
|
|
|
|
```csharp
|
|
var index = items.ToDictionary(x => x.Id, x => x);
|
|
return idsA.Select(x => index.GetValueOrDefault(x)).WhereNotNull();
|
|
```
|
|
|
|
**Issue:** For large ID arrays, creating a dictionary doubles memory.
|
|
|
|
**Suggestion:** Only optimize if profiling shows issues. Current approach is correct for most use cases.
|
|
|
|
---
|
|
|
|
## 4. Questions for Clarification
|
|
|
|
1. **Circular Dependency Verification:** Has the `Lazy<IContentCrudService>` pattern in obsolete constructors been verified to work at runtime? The plan mentions the risk but doesn't confirm testing.
|
|
|
|
2. **ContentSchedule Handling:** The `Save` method accepts `ContentScheduleCollection` but the batch save doesn't. Is this intentional parity with existing behavior?
|
|
|
|
3. **Tree Traversal Methods:** The plan adds `GetAncestors`, `GetPagedChildren`, `GetPagedDescendants` to the CRUD service. Are these truly CRUD operations, or should they remain in a separate "navigation" service in future phases?
|
|
|
|
4. **Notification State Passing:** The `ContentSavedNotification` uses `.WithStateFrom(savingNotification)`. Is the state transfer pattern tested for the delegated implementation?
|
|
|
|
---
|
|
|
|
## 5. Benchmark Regression Threshold Enforcement
|
|
|
|
**Question from Plan:** The plan mentions "no >20% regression" in benchmarks. Where is this threshold defined and enforced?
|
|
|
|
**Recommendation:** Implement in-test assertions with specific gating for Phase 1 CRUD benchmarks.
|
|
|
|
### 5.1 Implementation: Update ContentServiceBenchmarkBase
|
|
|
|
Add the following to `tests/Umbraco.Tests.Integration/Testing/ContentServiceBenchmarkBase.cs`:
|
|
|
|
```csharp
|
|
// Add to class fields
|
|
private static readonly string BaselinePath = Path.Combine(
|
|
TestContext.CurrentContext.TestDirectory,
|
|
"..", "..", "..", "..", "..",
|
|
"docs", "plans", "baseline-phase0.json");
|
|
|
|
private Dictionary<string, BenchmarkResult>? _baseline;
|
|
private const double DefaultRegressionThreshold = 20.0;
|
|
|
|
/// <summary>
|
|
/// Records a benchmark and asserts no regression beyond the threshold.
|
|
/// </summary>
|
|
/// <param name="name">Benchmark name (must match baseline JSON key).</param>
|
|
/// <param name="elapsedMs">Measured elapsed time in milliseconds.</param>
|
|
/// <param name="itemCount">Number of items processed.</param>
|
|
/// <param name="thresholdPercent">Maximum allowed regression percentage (default: 20%).</param>
|
|
protected void AssertNoRegression(string name, long elapsedMs, int itemCount, double thresholdPercent = DefaultRegressionThreshold)
|
|
{
|
|
RecordBenchmark(name, elapsedMs, itemCount);
|
|
|
|
_baseline ??= LoadBaseline();
|
|
|
|
if (_baseline.TryGetValue(name, out var baselineResult))
|
|
{
|
|
var maxAllowed = baselineResult.ElapsedMs * (1 + thresholdPercent / 100);
|
|
|
|
if (elapsedMs > maxAllowed)
|
|
{
|
|
var regressionPct = ((double)(elapsedMs - baselineResult.ElapsedMs) / baselineResult.ElapsedMs) * 100;
|
|
Assert.Fail(
|
|
$"Performance regression detected for '{name}': " +
|
|
$"{elapsedMs}ms exceeds threshold of {maxAllowed:F0}ms " +
|
|
$"(baseline: {baselineResult.ElapsedMs}ms, regression: +{regressionPct:F1}%, threshold: {thresholdPercent}%)");
|
|
}
|
|
|
|
TestContext.WriteLine($"[REGRESSION_CHECK] {name}: PASS ({elapsedMs}ms <= {maxAllowed:F0}ms, baseline: {baselineResult.ElapsedMs}ms)");
|
|
}
|
|
else
|
|
{
|
|
TestContext.WriteLine($"[REGRESSION_CHECK] {name}: SKIPPED (no baseline entry)");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Measures, records, and asserts no regression for the given action.
|
|
/// </summary>
|
|
protected long MeasureAndAssertNoRegression(string name, int itemCount, Action action, bool skipWarmup = false, double thresholdPercent = DefaultRegressionThreshold)
|
|
{
|
|
// Warmup iteration (skip for destructive operations)
|
|
if (!skipWarmup)
|
|
{
|
|
try { action(); }
|
|
catch { /* Warmup failure acceptable */ }
|
|
}
|
|
|
|
var sw = Stopwatch.StartNew();
|
|
action();
|
|
sw.Stop();
|
|
|
|
AssertNoRegression(name, sw.ElapsedMilliseconds, itemCount, thresholdPercent);
|
|
return sw.ElapsedMilliseconds;
|
|
}
|
|
|
|
private Dictionary<string, BenchmarkResult> LoadBaseline()
|
|
{
|
|
if (!File.Exists(BaselinePath))
|
|
{
|
|
TestContext.WriteLine($"[BASELINE] File not found: {BaselinePath}");
|
|
return new Dictionary<string, BenchmarkResult>();
|
|
}
|
|
|
|
try
|
|
{
|
|
var json = File.ReadAllText(BaselinePath);
|
|
var results = JsonSerializer.Deserialize<List<BenchmarkResult>>(json) ?? new List<BenchmarkResult>();
|
|
TestContext.WriteLine($"[BASELINE] Loaded {results.Count} baseline entries from {BaselinePath}");
|
|
return results.ToDictionary(r => r.Name, r => r);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
TestContext.WriteLine($"[BASELINE] Failed to load baseline: {ex.Message}");
|
|
return new Dictionary<string, BenchmarkResult>();
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5.2 Phase 1 CRUD Benchmarks to Gate
|
|
|
|
Update `ContentServiceRefactoringBenchmarks.cs` to use `AssertNoRegression` for these Phase 1 CRUD operations:
|
|
|
|
| Benchmark | Gate? | Baseline (ms) | Max Allowed (ms) |
|
|
|-----------|-------|---------------|------------------|
|
|
| `Save_SingleItem` | ✅ Yes | 7 | 8.4 |
|
|
| `Save_BatchOf100` | ✅ Yes | 676 | 811.2 |
|
|
| `Save_BatchOf1000` | ✅ Yes | 7649 | 9178.8 |
|
|
| `GetById_Single` | ✅ Yes | 8 | 9.6 |
|
|
| `GetByIds_BatchOf100` | ✅ Yes | 14 | 16.8 |
|
|
| `Delete_SingleItem` | ✅ Yes | 35 | 42 |
|
|
| `Delete_WithDescendants` | ✅ Yes | 243 | 291.6 |
|
|
| `GetAncestors_DeepHierarchy` | ✅ Yes | 31 | 37.2 |
|
|
| `GetPagedChildren_100Items` | ✅ Yes | 16 | 19.2 |
|
|
| `GetPagedDescendants_DeepTree` | ✅ Yes | 25 | 30 |
|
|
|
|
**Non-gated (Phase 2+):** `Publish_*`, `Move_*`, `Copy_*`, `Sort_*`, `EmptyRecycleBin_*`, `Rollback_*`, `*Versions*`
|
|
|
|
### 5.3 Example Updated Benchmark Test
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// Benchmark 1: Single content save latency.
|
|
/// </summary>
|
|
[Test]
|
|
[LongRunning]
|
|
public void Benchmark_Save_SingleItem()
|
|
{
|
|
// Warmup with throwaway content
|
|
var warmupContent = ContentBuilder.CreateSimpleContent(ContentType, "Warmup_SingleItem", -1);
|
|
ContentService.Save(warmupContent);
|
|
|
|
// Measured run with fresh content
|
|
var content = ContentBuilder.CreateSimpleContent(ContentType, "BenchmarkSingle", -1);
|
|
var sw = Stopwatch.StartNew();
|
|
ContentService.Save(content);
|
|
sw.Stop();
|
|
|
|
// Gate: Fail if >20% regression from baseline
|
|
AssertNoRegression("Save_SingleItem", sw.ElapsedMilliseconds, 1);
|
|
|
|
Assert.That(content.Id, Is.GreaterThan(0));
|
|
}
|
|
```
|
|
|
|
### 5.4 Baseline Update Process
|
|
|
|
When a phase completes and performance characteristics change intentionally:
|
|
|
|
```bash
|
|
# 1. Run benchmarks and capture new results
|
|
dotnet test tests/Umbraco.Tests.Integration \
|
|
--filter "Category=Benchmark&FullyQualifiedName~ContentServiceRefactoringBenchmarks" \
|
|
--logger "console;verbosity=detailed" \
|
|
| tee benchmark-output.txt
|
|
|
|
# 2. Extract JSON from output
|
|
grep -oP '\[BENCHMARK_JSON\]\K.*(?=\[/BENCHMARK_JSON\])' benchmark-output.txt \
|
|
| python3 -m json.tool > docs/plans/baseline-phase1.json
|
|
|
|
# 3. Commit with phase tag
|
|
git add docs/plans/baseline-phase1.json
|
|
git commit -m "chore: update benchmark baseline for Phase 1"
|
|
git tag phase-1-baseline
|
|
```
|
|
|
|
### 5.5 Environment Variability Considerations
|
|
|
|
Benchmark thresholds account for environment variability:
|
|
- **20% threshold** provides buffer for CI vs. local differences
|
|
- **Warmup runs** reduce JIT compilation variance
|
|
- **Database per test** ensures isolation
|
|
- **Non-parallelizable** attribute prevents resource contention
|
|
|
|
If false positives occur in CI, consider:
|
|
1. Increasing threshold to 30% for CI-only runs
|
|
2. Using environment variable: `BENCHMARK_THRESHOLD=30`
|
|
3. Running benchmarks on dedicated hardware
|
|
|
|
---
|
|
|
|
## 6. Final Recommendation
|
|
|
|
**Recommendation:** :warning: **Approve with Changes**
|
|
|
|
The plan is sound architecturally but has critical issues that must be fixed before implementation:
|
|
|
|
### Required Changes (Must Fix)
|
|
|
|
| Priority | Issue | Fix |
|
|
|----------|-------|-----|
|
|
| P0 | GetAncestors N+1 | Use batch query via Path parsing |
|
|
| P0 | Incomplete interface parity | Audit and add missing methods (`HasChildren`, `Exists`) |
|
|
| P0 | Filter ignored silently | Use repository query combination (pass base query + filter to `GetPage`) |
|
|
| P0 | Benchmark regression enforcement | Implement `AssertNoRegression` in `ContentServiceBenchmarkBase` |
|
|
| P1 | Delete loop race condition | Add iteration bound and empty-batch break |
|
|
| P1 | Missing operation logging | Add cancellation/failure logging |
|
|
|
|
### Recommended Changes (Should Fix)
|
|
|
|
| Priority | Issue | Fix |
|
|
|----------|-------|-----|
|
|
| P2 | Code duplication in CreateAndSave | Extract to helper method |
|
|
| P2 | Thin test coverage | Add behavioral unit tests |
|
|
| P2 | Internal method documentation | Add precondition remarks |
|
|
| P2 | Update CRUD benchmarks | Replace `RecordBenchmark` with `AssertNoRegression` for Phase 1 ops |
|
|
|
|
### Implementation Checklist
|
|
|
|
Before proceeding to implementation, ensure:
|
|
|
|
- [ ] `GetAncestors` uses `Path` parsing with batch `GetByIds`
|
|
- [ ] `IContentCrudService` includes `HasChildren(int)`, `Exists(int)`, `Exists(Guid)`
|
|
- [ ] `GetPagedChildren`/`GetPagedDescendants` pass both `query` and `filter` to repository
|
|
- [ ] `ContentServiceBenchmarkBase` has `AssertNoRegression` method
|
|
- [ ] 10 Phase 1 CRUD benchmarks use `AssertNoRegression` instead of `RecordBenchmark`
|
|
- [ ] `DeleteLocked` has iteration bound and empty-batch exit
|
|
- [ ] Save/Delete operations log cancellations
|
|
|
|
Once the P0 and P1 issues are addressed, the plan can proceed to implementation.
|
|
|
|
---
|
|
|
|
**Reviewed by:** Critical Implementation Review Skill
|
|
**Review Date:** 2025-12-20
|