---
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<DiffField> ChangedFields { get; private set; }

    public DiffPreview(
        string operation,
        string entityType,
        Guid? entityId,
        string? entityKey,
        object? beforeData,
        object? afterData,
        IReadOnlyList<DiffField> 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<DiffField>().AsReadOnly();
    }

    // Value object equality
    protected override IEnumerable<object?> 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<object?> 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<InvalidOperationException>(
        () => pendingChange.Approve(Guid.NewGuid()));
}

[Fact]
public void Approve_ExpiredChange_ThrowsException()
{
    // Arrange
    var pendingChange = CreateExpiredPendingChange();

    // Act & Assert
    Assert.Throws<InvalidOperationException>(
        () => 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/`
