--- story_id: story_5_3 sprint_id: sprint_5 phase: Phase 1 - Foundation status: not_started priority: P0 story_points: 5 assignee: backend estimated_days: 2 created_date: 2025-11-06 dependencies: [] --- # Story 5.3: MCP Domain Layer Design **Phase**: Phase 1 - Foundation (Week 1-2) **Priority**: P0 CRITICAL **Estimated Effort**: 5 Story Points (2 days) ## User Story **As a** Backend Developer **I want** well-designed domain entities and aggregates for MCP operations **So that** the system has a solid foundation following DDD principles and can be easily extended ## Business Value Clean domain design is critical for long-term maintainability. This Story establishes the domain model for all MCP operations, ensuring business rules are enforced consistently and the system can evolve without accumulating technical debt. **Impact**: - Provides solid foundation for all MCP features - Enforces business rules at domain level - Enables rich domain events for audit and notifications - Reduces bugs through domain-driven validation ## Acceptance Criteria ### AC1: McpApiKey Aggregate Root (Covered in Story 5.2) - [ ] McpApiKey entity implemented as aggregate root - [ ] Factory method for creation with validation - [ ] Domain methods (Revoke, RecordUsage, UpdatePermissions) - [ ] Domain events (ApiKeyCreated, ApiKeyRevoked) ### AC2: PendingChange Aggregate Root - [ ] PendingChange entity with status lifecycle - [ ] Factory method for creation from Diff Preview - [ ] Approve() method that triggers execution - [ ] Reject() method with reason logging - [ ] Expire() method for 24-hour timeout - [ ] Domain events (PendingChangeCreated, Approved, Rejected, Expired) ### AC3: DiffPreview Value Object - [ ] Immutable value object for before/after data - [ ] Field-level change tracking (DiffField collection) - [ ] Support for CREATE, UPDATE, DELETE operations - [ ] JSON serialization for complex objects - [ ] Equality comparison (value semantics) ### AC4: Domain Events - [ ] ApiKeyCreatedEvent - [ ] ApiKeyRevokedEvent - [ ] PendingChangeCreatedEvent - [ ] PendingChangeApprovedEvent - [ ] PendingChangeRejectedEvent - [ ] PendingChangeExpiredEvent - [ ] All events inherit from `DomainEvent` base class ### AC5: Business Rule Validation - [ ] PendingChange cannot be approved if expired - [ ] PendingChange cannot be rejected if already approved - [ ] McpApiKey cannot be used if revoked or expired - [ ] DiffPreview must have at least one changed field for UPDATE - [ ] All invariants enforced in domain entities ### AC6: Testing - [ ] Unit test coverage > 90% for domain layer - [ ] Test all domain methods (Create, Approve, Reject, etc.) - [ ] Test domain events are raised correctly - [ ] Test business rule violations throw exceptions ## Technical Design ### Domain Model Diagram ``` ┌─────────────────────────────────────┐ │ McpApiKey (Aggregate Root) │ │ - TenantId │ │ - KeyHash, KeyPrefix │ │ - Permissions │ │ - Status (Active, Revoked) │ │ - ExpiresAt │ │ + Create() │ │ + Revoke() │ │ + RecordUsage() │ └─────────────────────────────────────┘ ┌─────────────────────────────────────┐ │ PendingChange (Aggregate Root) │ │ - TenantId, ApiKeyId │ │ - ToolName │ │ - Diff (DiffPreview) │ │ - Status (Pending, Approved, ...) │ │ - ExpiresAt │ │ + Create() │ │ + Approve() │ │ + Reject() │ │ + Expire() │ └─────────────────────────────────────┘ │ │ has-a ↓ ┌─────────────────────────────────────┐ │ DiffPreview (Value Object) │ │ - Operation (CREATE/UPDATE/DELETE) │ │ - EntityType, EntityId │ │ - BeforeData, AfterData │ │ - ChangedFields[] │ │ + CalculateDiff() │ └─────────────────────────────────────┘ ``` ### PendingChange Aggregate Root ```csharp public class PendingChange : AggregateRoot { public Guid TenantId { get; private set; } public Guid ApiKeyId { get; private set; } public string ToolName { get; private set; } public DiffPreview Diff { get; private set; } public PendingChangeStatus Status { get; private set; } public DateTime ExpiresAt { get; private set; } public Guid? ApprovedBy { get; private set; } public DateTime? ApprovedAt { get; private set; } public Guid? RejectedBy { get; private set; } public DateTime? RejectedAt { get; private set; } public string? RejectionReason { get; private set; } // Factory method public static PendingChange Create( string toolName, DiffPreview diff, Guid tenantId, Guid apiKeyId) { if (string.IsNullOrWhiteSpace(toolName)) throw new ArgumentException("Tool name required", nameof(toolName)); if (diff == null) throw new ArgumentNullException(nameof(diff)); var pendingChange = new PendingChange { Id = Guid.NewGuid(), TenantId = tenantId, ApiKeyId = apiKeyId, ToolName = toolName, Diff = diff, Status = PendingChangeStatus.PendingApproval, ExpiresAt = DateTime.UtcNow.AddHours(24) }; pendingChange.AddDomainEvent(new PendingChangeCreatedEvent( pendingChange.Id, toolName, diff.EntityType, diff.Operation )); return pendingChange; } // Domain method: Approve public void Approve(Guid approvedBy) { if (Status != PendingChangeStatus.PendingApproval) throw new InvalidOperationException( $"Cannot approve change with status {Status}"); if (DateTime.UtcNow > ExpiresAt) throw new InvalidOperationException( "Cannot approve expired change"); Status = PendingChangeStatus.Approved; ApprovedBy = approvedBy; ApprovedAt = DateTime.UtcNow; AddDomainEvent(new PendingChangeApprovedEvent( Id, ToolName, Diff, approvedBy )); } // Domain method: Reject public void Reject(Guid rejectedBy, string reason) { if (Status != PendingChangeStatus.PendingApproval) throw new InvalidOperationException( $"Cannot reject change with status {Status}"); Status = PendingChangeStatus.Rejected; RejectedBy = rejectedBy; RejectedAt = DateTime.UtcNow; RejectionReason = reason; AddDomainEvent(new PendingChangeRejectedEvent( Id, ToolName, reason, rejectedBy )); } // Domain method: Expire public void Expire() { if (Status != PendingChangeStatus.PendingApproval) return; // Already processed if (DateTime.UtcNow <= ExpiresAt) throw new InvalidOperationException( "Cannot expire change before expiration time"); Status = PendingChangeStatus.Expired; AddDomainEvent(new PendingChangeExpiredEvent(Id, ToolName)); } } public enum PendingChangeStatus { PendingApproval = 0, Approved = 1, Rejected = 2, Expired = 3 } ``` ### DiffPreview Value Object ```csharp public class DiffPreview : ValueObject { public string Operation { get; private set; } // CREATE, UPDATE, DELETE public string EntityType { get; private set; } public Guid? EntityId { get; private set; } public string? EntityKey { get; private set; } // e.g., "COLA-146" public object? BeforeData { get; private set; } public object? AfterData { get; private set; } public IReadOnlyList ChangedFields { get; private set; } public DiffPreview( string operation, string entityType, Guid? entityId, string? entityKey, object? beforeData, object? afterData, IReadOnlyList changedFields) { if (string.IsNullOrWhiteSpace(operation)) throw new ArgumentException("Operation required", nameof(operation)); if (string.IsNullOrWhiteSpace(entityType)) throw new ArgumentException("EntityType required", nameof(entityType)); if (operation == "UPDATE" && (changedFields == null || !changedFields.Any())) throw new ArgumentException( "UPDATE operation must have at least one changed field", nameof(changedFields)); Operation = operation; EntityType = entityType; EntityId = entityId; EntityKey = entityKey; BeforeData = beforeData; AfterData = afterData; ChangedFields = changedFields ?? new List().AsReadOnly(); } // Value object equality protected override IEnumerable GetEqualityComponents() { yield return Operation; yield return EntityType; yield return EntityId; yield return EntityKey; yield return BeforeData; yield return AfterData; foreach (var field in ChangedFields) yield return field; } } public class DiffField : ValueObject { public string FieldName { get; private set; } public string DisplayName { get; private set; } public object? OldValue { get; private set; } public object? NewValue { get; private set; } public string? DiffHtml { get; private set; } public DiffField( string fieldName, string displayName, object? oldValue, object? newValue, string? diffHtml = null) { FieldName = fieldName; DisplayName = displayName; OldValue = oldValue; NewValue = newValue; DiffHtml = diffHtml; } protected override IEnumerable GetEqualityComponents() { yield return FieldName; yield return DisplayName; yield return OldValue; yield return NewValue; yield return DiffHtml; } } ``` ### Domain Events ```csharp // Base class (should already exist in M1) public abstract record DomainEvent { public Guid EventId { get; init; } = Guid.NewGuid(); public DateTime OccurredAt { get; init; } = DateTime.UtcNow; } // PendingChange events public record PendingChangeCreatedEvent( Guid PendingChangeId, string ToolName, string EntityType, string Operation ) : DomainEvent; public record PendingChangeApprovedEvent( Guid PendingChangeId, string ToolName, DiffPreview Diff, Guid ApprovedBy ) : DomainEvent; public record PendingChangeRejectedEvent( Guid PendingChangeId, string ToolName, string Reason, Guid RejectedBy ) : DomainEvent; public record PendingChangeExpiredEvent( Guid PendingChangeId, string ToolName ) : DomainEvent; // ApiKey events (from Story 5.2) public record ApiKeyCreatedEvent( Guid ApiKeyId, string Name ) : DomainEvent; public record ApiKeyRevokedEvent( Guid ApiKeyId, string Name ) : DomainEvent; ``` ## Tasks ### Task 1: Create Domain Base Classes (2 hours) - [ ] Verify `AggregateRoot` base class exists (from M1) - [ ] Verify `ValueObject` base class exists (from M1) - [ ] Verify `DomainEvent` base class exists (from M1) - [ ] Update if needed for MCP requirements **Files to Check/Update**: - `ColaFlow.Core/Domain/AggregateRoot.cs` - `ColaFlow.Core/Domain/ValueObject.cs` - `ColaFlow.Core/Domain/DomainEvent.cs` ### Task 2: PendingChange Aggregate Root (4 hours) - [ ] Create `PendingChange` entity class - [ ] Implement `Create()` factory method - [ ] Implement `Approve()` domain method - [ ] Implement `Reject()` domain method - [ ] Implement `Expire()` domain method - [ ] Add business rule validation **Files to Create**: - `ColaFlow.Modules.Mcp.Domain/Entities/PendingChange.cs` - `ColaFlow.Modules.Mcp.Domain/Enums/PendingChangeStatus.cs` ### Task 3: DiffPreview Value Object (3 hours) - [ ] Create `DiffPreview` value object - [ ] Create `DiffField` value object - [ ] Implement equality comparison - [ ] Add validation (operation, changed fields) **Files to Create**: - `ColaFlow.Modules.Mcp.Domain/ValueObjects/DiffPreview.cs` - `ColaFlow.Modules.Mcp.Domain/ValueObjects/DiffField.cs` ### Task 4: Domain Events (2 hours) - [ ] Create `PendingChangeCreatedEvent` - [ ] Create `PendingChangeApprovedEvent` - [ ] Create `PendingChangeRejectedEvent` - [ ] Create `PendingChangeExpiredEvent` - [ ] Ensure events inherit from `DomainEvent` **Files to Create**: - `ColaFlow.Modules.Mcp.Domain/Events/PendingChangeCreatedEvent.cs` - `ColaFlow.Modules.Mcp.Domain/Events/PendingChangeApprovedEvent.cs` - `ColaFlow.Modules.Mcp.Domain/Events/PendingChangeRejectedEvent.cs` - `ColaFlow.Modules.Mcp.Domain/Events/PendingChangeExpiredEvent.cs` ### Task 5: Unit Tests - PendingChange (4 hours) - [ ] Test `Create()` factory method - [ ] Test `Approve()` happy path - [ ] Test `Approve()` when already approved (throws exception) - [ ] Test `Approve()` when expired (throws exception) - [ ] Test `Reject()` happy path - [ ] Test `Reject()` when already approved (throws exception) - [ ] Test `Expire()` happy path - [ ] Test domain events are raised **Files to Create**: - `ColaFlow.Modules.Mcp.Tests/Domain/PendingChangeTests.cs` ### Task 6: Unit Tests - DiffPreview (3 hours) - [ ] Test `DiffPreview` creation - [ ] Test validation (operation required, entity type required) - [ ] Test UPDATE requires changed fields - [ ] Test equality comparison (value semantics) - [ ] Test `DiffField` creation and equality **Files to Create**: - `ColaFlow.Modules.Mcp.Tests/Domain/DiffPreviewTests.cs` ## Testing Strategy ### Unit Tests (Target: > 90% coverage) - All domain entity methods (Create, Approve, Reject, Expire) - All business rule validations - Domain events raised at correct times - Value object equality semantics - Edge cases (null values, invalid states) ### Test Cases for PendingChange ```csharp [Fact] public void Create_ValidInput_Success() { // Arrange var diff = CreateValidDiff(); // Act var pendingChange = PendingChange.Create( "create_issue", diff, tenantId, apiKeyId); // Assert Assert.NotEqual(Guid.Empty, pendingChange.Id); Assert.Equal(PendingChangeStatus.PendingApproval, pendingChange.Status); Assert.True(pendingChange.ExpiresAt > DateTime.UtcNow); Assert.Single(pendingChange.DomainEvents); // PendingChangeCreatedEvent } [Fact] public void Approve_PendingChange_Success() { // Arrange var pendingChange = CreatePendingChange(); var approverId = Guid.NewGuid(); // Act pendingChange.Approve(approverId); // Assert Assert.Equal(PendingChangeStatus.Approved, pendingChange.Status); Assert.Equal(approverId, pendingChange.ApprovedBy); Assert.NotNull(pendingChange.ApprovedAt); Assert.Contains(pendingChange.DomainEvents, e => e is PendingChangeApprovedEvent); } [Fact] public void Approve_AlreadyApproved_ThrowsException() { // Arrange var pendingChange = CreatePendingChange(); pendingChange.Approve(Guid.NewGuid()); // Act & Assert Assert.Throws( () => pendingChange.Approve(Guid.NewGuid())); } [Fact] public void Approve_ExpiredChange_ThrowsException() { // Arrange var pendingChange = CreateExpiredPendingChange(); // Act & Assert Assert.Throws( () => pendingChange.Approve(Guid.NewGuid())); } ``` ## Dependencies **Prerequisites**: - M1 domain base classes (AggregateRoot, ValueObject, DomainEvent) - .NET 9 **Used By**: - Story 5.9 (Diff Preview Service) - Uses DiffPreview value object - Story 5.10 (PendingChange Management) - Uses PendingChange aggregate - Story 5.11 (Core MCP Tools) - Creates PendingChange entities ## Risks & Mitigation | Risk | Impact | Probability | Mitigation | |------|--------|-------------|------------| | Business rules incomplete | Medium | Medium | Comprehensive unit tests, domain expert review | | Domain events not raised | Medium | Low | Unit tests verify events, event sourcing pattern | | Value object equality bugs | Low | Low | Thorough testing, use proven patterns | | Over-engineering domain | Medium | Medium | Keep it simple, add complexity only when needed | ## Definition of Done - [ ] Code compiles without warnings - [ ] All unit tests passing (> 90% coverage) - [ ] Code reviewed and approved - [ ] XML documentation for all public APIs - [ ] All aggregates follow DDD patterns - [ ] All value objects are immutable - [ ] All domain events inherit from DomainEvent - [ ] Business rules enforced in domain entities (not services) - [ ] No anemic domain model (rich behavior in entities) ## Notes ### Why This Story Matters - **Clean Architecture**: Solid domain foundation prevents future refactoring - **Business Rules**: Domain entities enforce invariants, reducing bugs - **Domain Events**: Enable loose coupling, audit trail, notifications - **Testability**: Rich domain model is easy to unit test ### Key Design Principles 1. **Aggregates**: Consistency boundaries (McpApiKey, PendingChange) 2. **Value Objects**: Immutable, equality by value (DiffPreview, DiffField) 3. **Domain Events**: Side effects handled outside aggregate 4. **Factory Methods**: Encapsulate creation logic, enforce invariants 5. **Encapsulation**: Private setters, public domain methods ### DDD Patterns Used - **Aggregate Root**: PendingChange, McpApiKey - **Value Object**: DiffPreview, DiffField - **Domain Event**: 6 events for audit and notifications - **Factory Method**: Create() methods with validation - **Invariant Protection**: Business rules in domain entities ### Reference Materials - Domain-Driven Design (Eric Evans) - Sprint 5 Plan: `docs/plans/sprint_5.md` - Architecture Design: `docs/M2-MCP-SERVER-ARCHITECTURE.md` - M1 Domain Layer: `ColaFlow.Core/Domain/`