--- story_id: story_5_10 sprint_id: sprint_5 phase: Phase 3 - Tools & Diff Preview status: not_started priority: P0 story_points: 5 assignee: backend estimated_days: 2 created_date: 2025-11-06 dependencies: [story_5_3, story_5_9] --- # Story 5.10: PendingChange Management **Phase**: Phase 3 - Tools & Diff Preview (Week 5-6) **Priority**: P0 CRITICAL **Estimated Effort**: 5 Story Points (2 days) ## User Story **As a** User **I want** to approve or reject AI-proposed changes **So that** I maintain control over my project data ## Business Value PendingChange management is the **approval workflow** for AI operations. It enables: - **Human-in-the-Loop**: AI proposes, human approves - **Audit Trail**: Complete history of all AI operations - **Safety**: Prevents unauthorized or erroneous changes - **Compliance**: Required for enterprise adoption ## Acceptance Criteria ### AC1: PendingChange CRUD - [ ] Create PendingChange with Diff Preview - [ ] Query PendingChanges (by tenant, by status, by user) - [ ] Get PendingChange by ID - [ ] Approve PendingChange (execute operation) - [ ] Reject PendingChange (log reason) ### AC2: Approval Workflow - [ ] Approve action executes actual operation (create/update/delete) - [ ] Approve action updates status to Approved - [ ] Approve action logs approver and timestamp - [ ] Reject action updates status to Rejected - [ ] Reject action logs reason and rejecter ### AC3: Auto-Expiration - [ ] 24-hour expiration timer - [ ] Background job checks expired changes - [ ] Expired changes marked as Expired status - [ ] Expired changes NOT executed ### AC4: REST API Endpoints - [ ] `GET /api/mcp/pending-changes` - List (filter by status) - [ ] `GET /api/mcp/pending-changes/{id}` - Get details - [ ] `POST /api/mcp/pending-changes/{id}/approve` - Approve - [ ] `POST /api/mcp/pending-changes/{id}/reject` - Reject ### AC5: Testing - [ ] Unit tests for PendingChange service - [ ] Integration tests for CRUD operations - [ ] Integration tests for approval/rejection workflow - [ ] Test auto-expiration mechanism ## Technical Design ### Service Interface ```csharp public interface IPendingChangeService { Task CreateAsync( string toolName, DiffPreview diff, CancellationToken cancellationToken); Task GetByIdAsync( Guid id, CancellationToken cancellationToken); Task> GetPendingChangesAsync( PendingChangeStatus? status, int page, int pageSize, CancellationToken cancellationToken); Task ApproveAsync( Guid pendingChangeId, Guid approvedBy, CancellationToken cancellationToken); Task RejectAsync( Guid pendingChangeId, Guid rejectedBy, string reason, CancellationToken cancellationToken); Task ExpireOldChangesAsync(CancellationToken cancellationToken); } ``` ### Service Implementation ```csharp public class PendingChangeService : IPendingChangeService { private readonly IPendingChangeRepository _repository; private readonly ITenantContext _tenantContext; private readonly IMediator _mediator; private readonly ILogger _logger; public async Task CreateAsync( string toolName, DiffPreview diff, CancellationToken ct) { var tenantId = _tenantContext.CurrentTenantId; var apiKeyId = (Guid)_httpContext.HttpContext.Items["ApiKeyId"]!; var pendingChange = PendingChange.Create( toolName, diff, tenantId, apiKeyId); await _repository.AddAsync(pendingChange, ct); await _repository.SaveChangesAsync(ct); _logger.LogInformation( "PendingChange created: {Id} - {ToolName} {Operation} {EntityType}", pendingChange.Id, toolName, diff.Operation, diff.EntityType); return pendingChange; } public async Task ApproveAsync( Guid pendingChangeId, Guid approvedBy, CancellationToken ct) { var pendingChange = await _repository.GetByIdAsync(pendingChangeId, ct); if (pendingChange == null) throw new McpNotFoundException("PendingChange", pendingChangeId.ToString()); // Domain method (raises PendingChangeApprovedEvent) pendingChange.Approve(approvedBy); await _repository.UpdateAsync(pendingChange, ct); await _repository.SaveChangesAsync(ct); // Publish domain events (will trigger operation execution) foreach (var domainEvent in pendingChange.DomainEvents) { await _mediator.Publish(domainEvent, ct); } _logger.LogInformation( "PendingChange approved: {Id} by {ApprovedBy}", pendingChangeId, approvedBy); } public async Task RejectAsync( Guid pendingChangeId, Guid rejectedBy, string reason, CancellationToken ct) { var pendingChange = await _repository.GetByIdAsync(pendingChangeId, ct); if (pendingChange == null) throw new McpNotFoundException("PendingChange", pendingChangeId.ToString()); pendingChange.Reject(rejectedBy, reason); await _repository.UpdateAsync(pendingChange, ct); await _repository.SaveChangesAsync(ct); _logger.LogInformation( "PendingChange rejected: {Id} by {RejectedBy} - Reason: {Reason}", pendingChangeId, rejectedBy, reason); } public async Task ExpireOldChangesAsync(CancellationToken ct) { var expiredChanges = await _repository.GetExpiredPendingChangesAsync(ct); foreach (var change in expiredChanges) { change.Expire(); await _repository.UpdateAsync(change, ct); _logger.LogWarning( "PendingChange expired: {Id} - {ToolName}", change.Id, change.ToolName); } await _repository.SaveChangesAsync(ct); _logger.LogInformation( "Expired {Count} pending changes", expiredChanges.Count); } } ``` ### Approval Event Handler (Executes Operation) ```csharp public class PendingChangeApprovedEventHandler : INotificationHandler { private readonly IMediator _mediator; private readonly ILogger _logger; public async Task Handle(PendingChangeApprovedEvent e, CancellationToken ct) { var diff = e.Diff; _logger.LogInformation( "Executing approved operation: {Operation} {EntityType}", diff.Operation, diff.EntityType); try { // Route to appropriate command handler based on operation + entity type object command = (diff.Operation, diff.EntityType) switch { ("CREATE", "Story") => MapToCreateStoryCommand(diff), ("UPDATE", "Story") => MapToUpdateStoryCommand(diff), ("DELETE", "Story") => MapToDeleteStoryCommand(diff), ("CREATE", "Epic") => MapToCreateEpicCommand(diff), ("UPDATE", "Epic") => MapToUpdateEpicCommand(diff), // ... more mappings _ => throw new NotSupportedException( $"Unsupported operation: {diff.Operation} {diff.EntityType}") }; await _mediator.Send(command, ct); _logger.LogInformation( "Operation executed successfully: {PendingChangeId}", e.PendingChangeId); } catch (Exception ex) { _logger.LogError(ex, "Failed to execute operation: {PendingChangeId}", e.PendingChangeId); // TODO: Update PendingChange status to ExecutionFailed throw; } } private CreateStoryCommand MapToCreateStoryCommand(DiffPreview diff) { var afterData = JsonSerializer.Deserialize( JsonSerializer.Serialize(diff.AfterData)); return new CreateStoryCommand { ProjectId = afterData.ProjectId, Title = afterData.Title, Description = afterData.Description, Priority = afterData.Priority, // ... map all fields }; } } ``` ### Background Job (Expiration Check) ```csharp public class PendingChangeExpirationJob : BackgroundService { private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; protected override async Task ExecuteAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { try { using var scope = _serviceProvider.CreateScope(); var pendingChangeService = scope.ServiceProvider .GetRequiredService(); await pendingChangeService.ExpireOldChangesAsync(ct); } catch (Exception ex) { _logger.LogError(ex, "Error in PendingChange expiration job"); } // Run every 5 minutes await Task.Delay(TimeSpan.FromMinutes(5), ct); } } } ``` ## Tasks ### Task 1: PendingChangeRepository (3 hours) - [ ] Create `IPendingChangeRepository` interface - [ ] Implement `PendingChangeRepository` (EF Core) - [ ] CRUD methods (Add, Update, GetById, Query) - [ ] GetExpiredPendingChangesAsync() method **Files to Create**: - `ColaFlow.Modules.Mcp.Infrastructure/Repositories/PendingChangeRepository.cs` ### Task 2: PendingChangeService (4 hours) - [ ] Implement `CreateAsync()` - [ ] Implement `ApproveAsync()` - [ ] Implement `RejectAsync()` - [ ] Implement `GetPendingChangesAsync()` with pagination - [ ] Implement `ExpireOldChangesAsync()` **Files to Create**: - `ColaFlow.Modules.Mcp.Application/Services/PendingChangeService.cs` ### Task 3: PendingChangeApprovedEventHandler (4 hours) - [ ] Create event handler - [ ] Map DiffPreview to CQRS commands - [ ] Execute command via MediatR - [ ] Error handling (log failures) **Files to Create**: - `ColaFlow.Modules.Mcp.Application/EventHandlers/PendingChangeApprovedEventHandler.cs` ### Task 4: REST API Controller (2 hours) - [ ] `GET /api/mcp/pending-changes` endpoint - [ ] `GET /api/mcp/pending-changes/{id}` endpoint - [ ] `POST /api/mcp/pending-changes/{id}/approve` endpoint - [ ] `POST /api/mcp/pending-changes/{id}/reject` endpoint **Files to Create**: - `ColaFlow.Modules.Mcp/Controllers/PendingChangesController.cs` ### Task 5: Background Job (2 hours) - [ ] Create `PendingChangeExpirationJob` (BackgroundService) - [ ] Run every 5 minutes - [ ] Call `ExpireOldChangesAsync()` - [ ] Register in DI container **Files to Create**: - `ColaFlow.Modules.Mcp.Infrastructure/Jobs/PendingChangeExpirationJob.cs` ### Task 6: Unit Tests (4 hours) - [ ] Test CreateAsync - [ ] Test ApproveAsync (happy path) - [ ] Test ApproveAsync (already approved throws exception) - [ ] Test RejectAsync - [ ] Test ExpireOldChangesAsync **Files to Create**: - `ColaFlow.Modules.Mcp.Tests/Services/PendingChangeServiceTests.cs` ### Task 7: Integration Tests (3 hours) - [ ] Test full approval workflow (create → approve → verify execution) - [ ] Test rejection workflow - [ ] Test expiration (create → wait 24 hours → expire) - [ ] Test multi-tenant isolation **Files to Create**: - `ColaFlow.Modules.Mcp.Tests/Integration/PendingChangeIntegrationTests.cs` ## Testing Strategy ### Integration Test Workflow ```csharp [Fact] public async Task ApprovalWorkflow_CreateStory_Success() { // Arrange var diff = new DiffPreview( operation: "CREATE", entityType: "Story", entityId: null, entityKey: null, beforeData: null, afterData: new { title = "New Story", priority = "High" }, changedFields: new[] { /* ... */ } ); // Act - Create PendingChange var pendingChange = await _service.CreateAsync("create_issue", diff, CancellationToken.None); Assert.Equal(PendingChangeStatus.PendingApproval, pendingChange.Status); // Act - Approve await _service.ApproveAsync(pendingChange.Id, _userId, CancellationToken.None); // Assert - Verify Story created var story = await _storyRepo.GetByIdAsync(/* ... */); Assert.NotNull(story); Assert.Equal("New Story", story.Title); } ``` ## Dependencies **Prerequisites**: - Story 5.3 (MCP Domain Layer) - PendingChange aggregate - Story 5.9 (Diff Preview Service) - DiffPreview value object **Used By**: - Story 5.11 (Core MCP Tools) - Creates PendingChange - Story 5.12 (SignalR Notifications) - Notifies on approval/rejection ## Risks & Mitigation | Risk | Impact | Probability | Mitigation | |------|--------|-------------|------------| | Operation execution fails | Medium | Medium | Transaction rollback, error logging, retry mechanism | | Expiration job stops | Medium | Low | Health checks, monitoring, alerting | | Race condition (concurrent approval) | Low | Low | Optimistic concurrency, database constraints | ## Definition of Done - [ ] All CRUD operations working - [ ] Approval executes operation correctly - [ ] Rejection logs reason - [ ] Expiration job running - [ ] API endpoints working - [ ] Unit tests passing (> 80%) - [ ] Integration tests passing - [ ] Code reviewed ## Notes ### Why This Story Matters - **Core M2 Feature**: Enables human-in-the-loop AI operations - **Safety**: Prevents AI mistakes - **Compliance**: Audit trail required - **User Trust**: Users control their data ### Key Design Decisions 1. **Domain Events**: Approval triggers execution (loose coupling) 2. **Background Job**: Auto-expiration runs every 5 minutes 3. **24-Hour TTL**: Balance between user convenience and system cleanup 4. **Status Enum**: Clear lifecycle (Pending → Approved/Rejected/Expired)