diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Entities/PendingChange.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Entities/PendingChange.cs
new file mode 100644
index 0000000..61a00d6
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Entities/PendingChange.cs
@@ -0,0 +1,260 @@
+using ColaFlow.Shared.Kernel.Common;
+using ColaFlow.Modules.Mcp.Domain.Events;
+using ColaFlow.Modules.Mcp.Domain.ValueObjects;
+
+namespace ColaFlow.Modules.Mcp.Domain.Entities;
+
+///
+/// PendingChange aggregate root - represents a change proposed by an AI agent
+/// that requires human approval before being applied to the system
+///
+public sealed class PendingChange : AggregateRoot
+{
+ // Multi-tenant isolation
+ public Guid TenantId { get; private set; }
+
+ // API Key that created this change
+ public Guid ApiKeyId { get; private set; }
+
+ // MCP Tool information
+ public string ToolName { get; private set; } = null!;
+
+ // The diff preview containing the proposed changes
+ public DiffPreview Diff { get; private set; } = null!;
+
+ // Status and lifecycle
+ public PendingChangeStatus Status { get; private set; }
+ public DateTime CreatedAt { get; private set; }
+ public DateTime ExpiresAt { get; private set; }
+
+ // Approval tracking
+ public Guid? ApprovedBy { get; private set; }
+ public DateTime? ApprovedAt { get; private set; }
+
+ // Rejection tracking
+ public Guid? RejectedBy { get; private set; }
+ public DateTime? RejectedAt { get; private set; }
+ public string? RejectionReason { get; private set; }
+
+ // Application tracking
+ public DateTime? AppliedAt { get; private set; }
+ public string? ApplicationResult { get; private set; }
+
+ ///
+ /// Private constructor for EF Core
+ ///
+ private PendingChange() : base()
+ {
+ }
+
+ ///
+ /// Factory method to create a new pending change
+ ///
+ /// The MCP tool name that created this change
+ /// The diff preview showing the proposed changes
+ /// Tenant ID for multi-tenant isolation
+ /// API Key ID that authorized this change
+ /// Hours until the change expires (default: 24)
+ /// A new PendingChange entity
+ public static PendingChange Create(
+ string toolName,
+ DiffPreview diff,
+ Guid tenantId,
+ Guid apiKeyId,
+ int expirationHours = 24)
+ {
+ // Validation
+ if (string.IsNullOrWhiteSpace(toolName))
+ throw new ArgumentException("Tool name cannot be empty", nameof(toolName));
+
+ if (diff == null)
+ throw new ArgumentNullException(nameof(diff));
+
+ if (tenantId == Guid.Empty)
+ throw new ArgumentException("Tenant ID cannot be empty", nameof(tenantId));
+
+ if (apiKeyId == Guid.Empty)
+ throw new ArgumentException("API Key ID cannot be empty", nameof(apiKeyId));
+
+ if (expirationHours <= 0 || expirationHours > 168) // Max 7 days
+ throw new ArgumentException(
+ "Expiration hours must be between 1 and 168 (7 days)",
+ nameof(expirationHours));
+
+ var pendingChange = new PendingChange
+ {
+ Id = Guid.NewGuid(),
+ TenantId = tenantId,
+ ApiKeyId = apiKeyId,
+ ToolName = toolName,
+ Diff = diff,
+ Status = PendingChangeStatus.PendingApproval,
+ CreatedAt = DateTime.UtcNow,
+ ExpiresAt = DateTime.UtcNow.AddHours(expirationHours)
+ };
+
+ // Raise domain event
+ pendingChange.AddDomainEvent(new PendingChangeCreatedEvent(
+ pendingChange.Id,
+ toolName,
+ diff.EntityType,
+ diff.Operation,
+ tenantId
+ ));
+
+ return pendingChange;
+ }
+
+ ///
+ /// Approve the pending change
+ ///
+ /// User ID who approved the change
+ public void Approve(Guid approvedBy)
+ {
+ // Business rule: Can only approve changes that are pending
+ if (Status != PendingChangeStatus.PendingApproval)
+ throw new InvalidOperationException(
+ $"Cannot approve change with status {Status}. Only PendingApproval changes can be approved.");
+
+ // Business rule: Cannot approve expired changes
+ if (IsExpired())
+ throw new InvalidOperationException(
+ "Cannot approve an expired change. The change has exceeded its expiration time.");
+
+ if (approvedBy == Guid.Empty)
+ throw new ArgumentException("Approved by user ID cannot be empty", nameof(approvedBy));
+
+ Status = PendingChangeStatus.Approved;
+ ApprovedBy = approvedBy;
+ ApprovedAt = DateTime.UtcNow;
+
+ // Raise domain event
+ AddDomainEvent(new PendingChangeApprovedEvent(
+ Id,
+ ToolName,
+ Diff,
+ approvedBy,
+ TenantId
+ ));
+ }
+
+ ///
+ /// Reject the pending change
+ ///
+ /// User ID who rejected the change
+ /// Reason for rejection
+ public void Reject(Guid rejectedBy, string reason)
+ {
+ // Business rule: Can only reject changes that are pending
+ if (Status != PendingChangeStatus.PendingApproval)
+ throw new InvalidOperationException(
+ $"Cannot reject change with status {Status}. Only PendingApproval changes can be rejected.");
+
+ if (rejectedBy == Guid.Empty)
+ throw new ArgumentException("Rejected by user ID cannot be empty", nameof(rejectedBy));
+
+ if (string.IsNullOrWhiteSpace(reason))
+ throw new ArgumentException("Rejection reason cannot be empty", nameof(reason));
+
+ Status = PendingChangeStatus.Rejected;
+ RejectedBy = rejectedBy;
+ RejectedAt = DateTime.UtcNow;
+ RejectionReason = reason;
+
+ // Raise domain event
+ AddDomainEvent(new PendingChangeRejectedEvent(
+ Id,
+ ToolName,
+ reason,
+ rejectedBy,
+ TenantId
+ ));
+ }
+
+ ///
+ /// Mark the change as expired
+ /// This is typically called by a background job that checks for expired changes
+ ///
+ public void Expire()
+ {
+ // Business rule: Can only expire changes that are pending
+ if (Status != PendingChangeStatus.PendingApproval)
+ return; // Already processed, nothing to do
+
+ // Business rule: Cannot expire before expiration time
+ if (!IsExpired())
+ throw new InvalidOperationException(
+ "Cannot expire a change before its expiration time. " +
+ $"Expiration time: {ExpiresAt:yyyy-MM-dd HH:mm:ss} UTC");
+
+ Status = PendingChangeStatus.Expired;
+
+ // Raise domain event
+ AddDomainEvent(new PendingChangeExpiredEvent(
+ Id,
+ ToolName,
+ TenantId
+ ));
+ }
+
+ ///
+ /// Mark the change as applied after successful execution
+ ///
+ /// Description of the application result
+ public void MarkAsApplied(string result)
+ {
+ // Business rule: Can only apply approved changes
+ if (Status != PendingChangeStatus.Approved)
+ throw new InvalidOperationException(
+ $"Cannot apply change with status {Status}. Only Approved changes can be applied.");
+
+ if (string.IsNullOrWhiteSpace(result))
+ throw new ArgumentException("Application result cannot be empty", nameof(result));
+
+ Status = PendingChangeStatus.Applied;
+ AppliedAt = DateTime.UtcNow;
+ ApplicationResult = result;
+
+ // Raise domain event
+ AddDomainEvent(new PendingChangeAppliedEvent(
+ Id,
+ ToolName,
+ Diff.EntityType,
+ Diff.EntityId,
+ result,
+ TenantId
+ ));
+ }
+
+ ///
+ /// Check if the change has expired
+ ///
+ public bool IsExpired()
+ {
+ return DateTime.UtcNow > ExpiresAt;
+ }
+
+ ///
+ /// Check if the change can be approved
+ ///
+ public bool CanBeApproved()
+ {
+ return Status == PendingChangeStatus.PendingApproval && !IsExpired();
+ }
+
+ ///
+ /// Check if the change can be rejected
+ ///
+ public bool CanBeRejected()
+ {
+ return Status == PendingChangeStatus.PendingApproval;
+ }
+
+ ///
+ /// Get a human-readable summary of the change
+ ///
+ public string GetSummary()
+ {
+ return $"{ToolName}: {Diff.GetSummary()} - {Status}";
+ }
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Entities/TaskLock.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Entities/TaskLock.cs
new file mode 100644
index 0000000..715d0f7
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Entities/TaskLock.cs
@@ -0,0 +1,261 @@
+using ColaFlow.Shared.Kernel.Common;
+using ColaFlow.Modules.Mcp.Domain.Events;
+using ColaFlow.Modules.Mcp.Domain.ValueObjects;
+
+namespace ColaFlow.Modules.Mcp.Domain.Entities;
+
+///
+/// TaskLock aggregate root - prevents concurrent modifications to the same resource
+/// Used to ensure AI agents don't conflict when making changes
+///
+public sealed class TaskLock : AggregateRoot
+{
+ // Multi-tenant isolation
+ public Guid TenantId { get; private set; }
+
+ // Resource being locked
+ public string ResourceType { get; private set; } = null!;
+ public Guid ResourceId { get; private set; }
+
+ // Lock holder information
+ public string LockHolderType { get; private set; } = null!; // "AI_AGENT" or "USER"
+ public Guid LockHolderId { get; private set; } // ApiKeyId for AI agents, UserId for users
+ public string? LockHolderName { get; private set; } // Friendly name for display
+
+ // Lock lifecycle
+ public TaskLockStatus Status { get; private set; }
+ public DateTime AcquiredAt { get; private set; }
+ public DateTime ExpiresAt { get; private set; }
+ public DateTime? ReleasedAt { get; private set; }
+
+ // Additional context
+ public string? Purpose { get; private set; } // Optional: why is the lock held?
+
+ ///
+ /// Private constructor for EF Core
+ ///
+ private TaskLock() : base()
+ {
+ }
+
+ ///
+ /// Factory method to acquire a new task lock
+ ///
+ /// Type of resource being locked (e.g., "Epic", "Story", "Task")
+ /// ID of the specific resource
+ /// Type of lock holder: AI_AGENT or USER
+ /// ID of the lock holder (ApiKeyId or UserId)
+ /// Tenant ID for multi-tenant isolation
+ /// Friendly name of the lock holder
+ /// Optional purpose description
+ /// Minutes until lock expires (default: 5)
+ /// A new TaskLock entity
+ public static TaskLock Acquire(
+ string resourceType,
+ Guid resourceId,
+ string lockHolderType,
+ Guid lockHolderId,
+ Guid tenantId,
+ string? lockHolderName = null,
+ string? purpose = null,
+ int expirationMinutes = 5)
+ {
+ // Validation
+ if (string.IsNullOrWhiteSpace(resourceType))
+ throw new ArgumentException("Resource type cannot be empty", nameof(resourceType));
+
+ if (resourceId == Guid.Empty)
+ throw new ArgumentException("Resource ID cannot be empty", nameof(resourceId));
+
+ if (string.IsNullOrWhiteSpace(lockHolderType))
+ throw new ArgumentException("Lock holder type cannot be empty", nameof(lockHolderType));
+
+ lockHolderType = lockHolderType.ToUpperInvariant();
+ if (lockHolderType != "AI_AGENT" && lockHolderType != "USER")
+ throw new ArgumentException(
+ "Lock holder type must be AI_AGENT or USER",
+ nameof(lockHolderType));
+
+ if (lockHolderId == Guid.Empty)
+ throw new ArgumentException("Lock holder ID cannot be empty", nameof(lockHolderId));
+
+ if (tenantId == Guid.Empty)
+ throw new ArgumentException("Tenant ID cannot be empty", nameof(tenantId));
+
+ if (expirationMinutes <= 0 || expirationMinutes > 60) // Max 1 hour
+ throw new ArgumentException(
+ "Expiration minutes must be between 1 and 60",
+ nameof(expirationMinutes));
+
+ var taskLock = new TaskLock
+ {
+ Id = Guid.NewGuid(),
+ TenantId = tenantId,
+ ResourceType = resourceType,
+ ResourceId = resourceId,
+ LockHolderType = lockHolderType,
+ LockHolderId = lockHolderId,
+ LockHolderName = lockHolderName,
+ Purpose = purpose,
+ Status = TaskLockStatus.Active,
+ AcquiredAt = DateTime.UtcNow,
+ ExpiresAt = DateTime.UtcNow.AddMinutes(expirationMinutes)
+ };
+
+ // Raise domain event
+ taskLock.AddDomainEvent(new TaskLockAcquiredEvent(
+ taskLock.Id,
+ resourceType,
+ resourceId,
+ lockHolderType,
+ lockHolderId,
+ tenantId
+ ));
+
+ return taskLock;
+ }
+
+ ///
+ /// Release the lock explicitly
+ ///
+ public void Release()
+ {
+ // Business rule: Can only release active locks
+ if (Status != TaskLockStatus.Active)
+ throw new InvalidOperationException(
+ $"Cannot release lock with status {Status}. Only Active locks can be released.");
+
+ Status = TaskLockStatus.Released;
+ ReleasedAt = DateTime.UtcNow;
+
+ // Raise domain event
+ AddDomainEvent(new TaskLockReleasedEvent(
+ Id,
+ ResourceType,
+ ResourceId,
+ LockHolderType,
+ LockHolderId,
+ TenantId
+ ));
+ }
+
+ ///
+ /// Mark the lock as expired
+ /// This is typically called by a background job or when checking lock validity
+ ///
+ public void MarkAsExpired()
+ {
+ // Business rule: Can only expire active locks
+ if (Status != TaskLockStatus.Active)
+ return; // Already processed, nothing to do
+
+ // Business rule: Cannot expire before expiration time
+ if (!IsExpired())
+ throw new InvalidOperationException(
+ "Cannot mark lock as expired before its expiration time. " +
+ $"Expiration time: {ExpiresAt:yyyy-MM-dd HH:mm:ss} UTC");
+
+ Status = TaskLockStatus.Expired;
+
+ // Raise domain event
+ AddDomainEvent(new TaskLockExpiredEvent(
+ Id,
+ ResourceType,
+ ResourceId,
+ LockHolderId,
+ TenantId
+ ));
+ }
+
+ ///
+ /// Extend the lock expiration time
+ /// Useful when an operation is taking longer than expected
+ ///
+ /// Additional minutes to add to expiration (max 60)
+ public void ExtendExpiration(int additionalMinutes)
+ {
+ // Business rule: Can only extend active locks
+ if (Status != TaskLockStatus.Active)
+ throw new InvalidOperationException(
+ $"Cannot extend lock with status {Status}. Only Active locks can be extended.");
+
+ if (additionalMinutes <= 0 || additionalMinutes > 60)
+ throw new ArgumentException(
+ "Additional minutes must be between 1 and 60",
+ nameof(additionalMinutes));
+
+ // Don't allow extending beyond 2 hours from acquisition
+ var maxExpiration = AcquiredAt.AddHours(2);
+ var newExpiration = ExpiresAt.AddMinutes(additionalMinutes);
+
+ if (newExpiration > maxExpiration)
+ throw new InvalidOperationException(
+ "Cannot extend lock beyond 2 hours from acquisition time. " +
+ "Please release and re-acquire if needed.");
+
+ ExpiresAt = newExpiration;
+ }
+
+ ///
+ /// Check if the lock has expired
+ ///
+ public bool IsExpired()
+ {
+ return DateTime.UtcNow > ExpiresAt;
+ }
+
+ ///
+ /// Check if the lock is currently valid (active and not expired)
+ ///
+ public bool IsValid()
+ {
+ return Status == TaskLockStatus.Active && !IsExpired();
+ }
+
+ ///
+ /// Check if the lock is held by the specified holder
+ ///
+ public bool IsHeldBy(Guid holderId)
+ {
+ return LockHolderId == holderId && IsValid();
+ }
+
+ ///
+ /// Check if the lock is held by an AI agent
+ ///
+ public bool IsHeldByAiAgent()
+ {
+ return LockHolderType == "AI_AGENT" && IsValid();
+ }
+
+ ///
+ /// Check if the lock is held by a user
+ ///
+ public bool IsHeldByUser()
+ {
+ return LockHolderType == "USER" && IsValid();
+ }
+
+ ///
+ /// Get remaining time before lock expiration
+ ///
+ public TimeSpan GetRemainingTime()
+ {
+ if (!IsValid())
+ return TimeSpan.Zero;
+
+ var remaining = ExpiresAt - DateTime.UtcNow;
+ return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
+ }
+
+ ///
+ /// Get a human-readable summary of the lock
+ ///
+ public string GetSummary()
+ {
+ var holderName = LockHolderName ?? LockHolderId.ToString();
+ var remaining = GetRemainingTime();
+ return $"{ResourceType} {ResourceId} locked by {holderName} ({LockHolderType}) - " +
+ $"{Status} - Remaining: {remaining.TotalMinutes:F1}m";
+ }
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeAppliedEvent.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeAppliedEvent.cs
new file mode 100644
index 0000000..345151e
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeAppliedEvent.cs
@@ -0,0 +1,15 @@
+using ColaFlow.Shared.Kernel.Events;
+
+namespace ColaFlow.Modules.Mcp.Domain.Events;
+
+///
+/// Domain event raised when a pending change is successfully applied
+///
+public sealed record PendingChangeAppliedEvent(
+ Guid PendingChangeId,
+ string ToolName,
+ string EntityType,
+ Guid? EntityId,
+ string Result,
+ Guid TenantId
+) : DomainEvent;
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeApprovedEvent.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeApprovedEvent.cs
new file mode 100644
index 0000000..c90ad81
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeApprovedEvent.cs
@@ -0,0 +1,15 @@
+using ColaFlow.Shared.Kernel.Events;
+using ColaFlow.Modules.Mcp.Domain.ValueObjects;
+
+namespace ColaFlow.Modules.Mcp.Domain.Events;
+
+///
+/// Domain event raised when a pending change is approved
+///
+public sealed record PendingChangeApprovedEvent(
+ Guid PendingChangeId,
+ string ToolName,
+ DiffPreview Diff,
+ Guid ApprovedBy,
+ Guid TenantId
+) : DomainEvent;
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeCreatedEvent.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeCreatedEvent.cs
new file mode 100644
index 0000000..596b909
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeCreatedEvent.cs
@@ -0,0 +1,14 @@
+using ColaFlow.Shared.Kernel.Events;
+
+namespace ColaFlow.Modules.Mcp.Domain.Events;
+
+///
+/// Domain event raised when a pending change is created
+///
+public sealed record PendingChangeCreatedEvent(
+ Guid PendingChangeId,
+ string ToolName,
+ string EntityType,
+ string Operation,
+ Guid TenantId
+) : DomainEvent;
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeExpiredEvent.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeExpiredEvent.cs
new file mode 100644
index 0000000..a8e37e7
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeExpiredEvent.cs
@@ -0,0 +1,12 @@
+using ColaFlow.Shared.Kernel.Events;
+
+namespace ColaFlow.Modules.Mcp.Domain.Events;
+
+///
+/// Domain event raised when a pending change expires
+///
+public sealed record PendingChangeExpiredEvent(
+ Guid PendingChangeId,
+ string ToolName,
+ Guid TenantId
+) : DomainEvent;
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeRejectedEvent.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeRejectedEvent.cs
new file mode 100644
index 0000000..c0e6b56
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/PendingChangeRejectedEvent.cs
@@ -0,0 +1,14 @@
+using ColaFlow.Shared.Kernel.Events;
+
+namespace ColaFlow.Modules.Mcp.Domain.Events;
+
+///
+/// Domain event raised when a pending change is rejected
+///
+public sealed record PendingChangeRejectedEvent(
+ Guid PendingChangeId,
+ string ToolName,
+ string Reason,
+ Guid RejectedBy,
+ Guid TenantId
+) : DomainEvent;
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/TaskLockAcquiredEvent.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/TaskLockAcquiredEvent.cs
new file mode 100644
index 0000000..0c02b5d
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/TaskLockAcquiredEvent.cs
@@ -0,0 +1,15 @@
+using ColaFlow.Shared.Kernel.Events;
+
+namespace ColaFlow.Modules.Mcp.Domain.Events;
+
+///
+/// Domain event raised when a task lock is acquired
+///
+public sealed record TaskLockAcquiredEvent(
+ Guid LockId,
+ string ResourceType,
+ Guid ResourceId,
+ string LockHolderType,
+ Guid LockHolderId,
+ Guid TenantId
+) : DomainEvent;
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/TaskLockExpiredEvent.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/TaskLockExpiredEvent.cs
new file mode 100644
index 0000000..e23adfd
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/TaskLockExpiredEvent.cs
@@ -0,0 +1,14 @@
+using ColaFlow.Shared.Kernel.Events;
+
+namespace ColaFlow.Modules.Mcp.Domain.Events;
+
+///
+/// Domain event raised when a task lock expires
+///
+public sealed record TaskLockExpiredEvent(
+ Guid LockId,
+ string ResourceType,
+ Guid ResourceId,
+ Guid LockHolderId,
+ Guid TenantId
+) : DomainEvent;
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/TaskLockReleasedEvent.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/TaskLockReleasedEvent.cs
new file mode 100644
index 0000000..8a463e2
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Events/TaskLockReleasedEvent.cs
@@ -0,0 +1,15 @@
+using ColaFlow.Shared.Kernel.Events;
+
+namespace ColaFlow.Modules.Mcp.Domain.Events;
+
+///
+/// Domain event raised when a task lock is released
+///
+public sealed record TaskLockReleasedEvent(
+ Guid LockId,
+ string ResourceType,
+ Guid ResourceId,
+ string LockHolderType,
+ Guid LockHolderId,
+ Guid TenantId
+) : DomainEvent;
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Repositories/IPendingChangeRepository.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Repositories/IPendingChangeRepository.cs
new file mode 100644
index 0000000..6801f8a
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Repositories/IPendingChangeRepository.cs
@@ -0,0 +1,81 @@
+using ColaFlow.Modules.Mcp.Domain.Entities;
+using ColaFlow.Modules.Mcp.Domain.ValueObjects;
+
+namespace ColaFlow.Modules.Mcp.Domain.Repositories;
+
+///
+/// Repository interface for PendingChange aggregate root
+///
+public interface IPendingChangeRepository
+{
+ ///
+ /// Get a pending change by ID
+ ///
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
+
+ ///
+ /// Get all pending changes for a tenant
+ ///
+ Task> GetByTenantAsync(
+ Guid tenantId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Get pending changes by status
+ ///
+ Task> GetByStatusAsync(
+ Guid tenantId,
+ PendingChangeStatus status,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Get expired pending changes (still in PendingApproval status but past expiration time)
+ ///
+ Task> GetExpiredAsync(
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Get pending changes by API key
+ ///
+ Task> GetByApiKeyAsync(
+ Guid apiKeyId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Get pending changes for a specific entity
+ ///
+ Task> GetByEntityAsync(
+ Guid tenantId,
+ string entityType,
+ Guid entityId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Check if there are any pending changes for a specific entity
+ ///
+ Task HasPendingChangesForEntityAsync(
+ Guid tenantId,
+ string entityType,
+ Guid entityId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Add a new pending change
+ ///
+ Task AddAsync(PendingChange pendingChange, CancellationToken cancellationToken = default);
+
+ ///
+ /// Update an existing pending change
+ ///
+ Task UpdateAsync(PendingChange pendingChange, CancellationToken cancellationToken = default);
+
+ ///
+ /// Delete a pending change
+ ///
+ Task DeleteAsync(PendingChange pendingChange, CancellationToken cancellationToken = default);
+
+ ///
+ /// Save changes to the database
+ ///
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Repositories/ITaskLockRepository.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Repositories/ITaskLockRepository.cs
new file mode 100644
index 0000000..a815415
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Repositories/ITaskLockRepository.cs
@@ -0,0 +1,92 @@
+using ColaFlow.Modules.Mcp.Domain.Entities;
+using ColaFlow.Modules.Mcp.Domain.ValueObjects;
+
+namespace ColaFlow.Modules.Mcp.Domain.Repositories;
+
+///
+/// Repository interface for TaskLock aggregate root
+///
+public interface ITaskLockRepository
+{
+ ///
+ /// Get a task lock by ID
+ ///
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
+
+ ///
+ /// Get all task locks for a tenant
+ ///
+ Task> GetByTenantAsync(
+ Guid tenantId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Get active lock for a specific resource (if any)
+ ///
+ Task GetActiveLockForResourceAsync(
+ Guid tenantId,
+ string resourceType,
+ Guid resourceId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Get all locks held by a specific holder
+ ///
+ Task> GetByLockHolderAsync(
+ Guid tenantId,
+ Guid lockHolderId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Get locks by status
+ ///
+ Task> GetByStatusAsync(
+ Guid tenantId,
+ TaskLockStatus status,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Get expired locks (still in Active status but past expiration time)
+ ///
+ Task> GetExpiredAsync(
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Check if a resource is currently locked
+ ///
+ Task IsResourceLockedAsync(
+ Guid tenantId,
+ string resourceType,
+ Guid resourceId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Check if a resource is locked by a specific holder
+ ///
+ Task IsResourceLockedByAsync(
+ Guid tenantId,
+ string resourceType,
+ Guid resourceId,
+ Guid lockHolderId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Add a new task lock
+ ///
+ Task AddAsync(TaskLock taskLock, CancellationToken cancellationToken = default);
+
+ ///
+ /// Update an existing task lock
+ ///
+ Task UpdateAsync(TaskLock taskLock, CancellationToken cancellationToken = default);
+
+ ///
+ /// Delete a task lock
+ ///
+ Task DeleteAsync(TaskLock taskLock, CancellationToken cancellationToken = default);
+
+ ///
+ /// Save changes to the database
+ ///
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Services/DiffPreviewService.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Services/DiffPreviewService.cs
new file mode 100644
index 0000000..fa7c097
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Services/DiffPreviewService.cs
@@ -0,0 +1,211 @@
+using System.Text.Json;
+using ColaFlow.Modules.Mcp.Domain.ValueObjects;
+
+namespace ColaFlow.Modules.Mcp.Domain.Services;
+
+///
+/// Domain service for creating and comparing diff previews
+///
+public sealed class DiffPreviewService
+{
+ ///
+ /// Generate a diff preview for a CREATE operation
+ ///
+ public DiffPreview GenerateCreateDiff(
+ string entityType,
+ T afterEntity,
+ string? entityKey = null) where T : class
+ {
+ if (afterEntity == null)
+ throw new ArgumentNullException(nameof(afterEntity));
+
+ var afterData = JsonSerializer.Serialize(afterEntity, new JsonSerializerOptions
+ {
+ WriteIndented = true
+ });
+
+ return DiffPreview.ForCreate(
+ entityType: entityType,
+ afterData: afterData,
+ entityKey: entityKey
+ );
+ }
+
+ ///
+ /// Generate a diff preview for a DELETE operation
+ ///
+ public DiffPreview GenerateDeleteDiff(
+ string entityType,
+ Guid entityId,
+ T beforeEntity,
+ string? entityKey = null) where T : class
+ {
+ if (beforeEntity == null)
+ throw new ArgumentNullException(nameof(beforeEntity));
+
+ var beforeData = JsonSerializer.Serialize(beforeEntity, new JsonSerializerOptions
+ {
+ WriteIndented = true
+ });
+
+ return DiffPreview.ForDelete(
+ entityType: entityType,
+ entityId: entityId,
+ beforeData: beforeData,
+ entityKey: entityKey
+ );
+ }
+
+ ///
+ /// Generate a diff preview for an UPDATE operation by comparing two objects
+ ///
+ public DiffPreview GenerateUpdateDiff(
+ string entityType,
+ Guid entityId,
+ T beforeEntity,
+ T afterEntity,
+ string? entityKey = null) where T : class
+ {
+ if (beforeEntity == null)
+ throw new ArgumentNullException(nameof(beforeEntity));
+
+ if (afterEntity == null)
+ throw new ArgumentNullException(nameof(afterEntity));
+
+ // Serialize both entities
+ var beforeData = JsonSerializer.Serialize(beforeEntity, new JsonSerializerOptions
+ {
+ WriteIndented = true
+ });
+
+ var afterData = JsonSerializer.Serialize(afterEntity, new JsonSerializerOptions
+ {
+ WriteIndented = true
+ });
+
+ // Compare and find changed fields
+ var changedFields = CompareObjects(beforeEntity, afterEntity);
+
+ if (changedFields.Count == 0)
+ throw new InvalidOperationException(
+ "No fields have changed. UPDATE operation requires at least one changed field.");
+
+ return DiffPreview.ForUpdate(
+ entityType: entityType,
+ entityId: entityId,
+ beforeData: beforeData,
+ afterData: afterData,
+ changedFields: changedFields.AsReadOnly(),
+ entityKey: entityKey
+ );
+ }
+
+ ///
+ /// Compare two objects and return list of changed fields
+ /// Uses reflection to compare public properties
+ ///
+ private List CompareObjects(T before, T after) where T : class
+ {
+ var changedFields = new List();
+ var type = typeof(T);
+ var properties = type.GetProperties();
+
+ foreach (var property in properties)
+ {
+ // Skip non-readable properties
+ if (!property.CanRead)
+ continue;
+
+ // Skip indexed properties
+ if (property.GetIndexParameters().Length > 0)
+ continue;
+
+ var oldValue = property.GetValue(before);
+ var newValue = property.GetValue(after);
+
+ // Check if values are different
+ if (!AreValuesEqual(oldValue, newValue))
+ {
+ changedFields.Add(new DiffField(
+ fieldName: property.Name,
+ displayName: FormatDisplayName(property.Name),
+ oldValue: oldValue,
+ newValue: newValue
+ ));
+ }
+ }
+
+ return changedFields;
+ }
+
+ ///
+ /// Compare two values for equality
+ /// Handles nulls and uses Equals method
+ ///
+ private bool AreValuesEqual(object? oldValue, object? newValue)
+ {
+ if (oldValue == null && newValue == null)
+ return true;
+
+ if (oldValue == null || newValue == null)
+ return false;
+
+ return oldValue.Equals(newValue);
+ }
+
+ ///
+ /// Format property name to display name
+ /// Example: "FirstName" -> "First Name"
+ ///
+ private string FormatDisplayName(string propertyName)
+ {
+ if (string.IsNullOrWhiteSpace(propertyName))
+ return propertyName;
+
+ // Insert space before uppercase letters (except first letter)
+ var result = new System.Text.StringBuilder();
+ for (int i = 0; i < propertyName.Length; i++)
+ {
+ if (i > 0 && char.IsUpper(propertyName[i]))
+ result.Append(' ');
+
+ result.Append(propertyName[i]);
+ }
+
+ return result.ToString();
+ }
+
+ ///
+ /// Validate that a diff preview is valid for the operation
+ ///
+ public bool ValidateDiff(DiffPreview diff)
+ {
+ if (diff == null)
+ return false;
+
+ try
+ {
+ // Operation-specific validation
+ if (diff.IsCreate() && string.IsNullOrWhiteSpace(diff.AfterData))
+ return false;
+
+ if (diff.IsUpdate() && diff.ChangedFields.Count == 0)
+ return false;
+
+ if (diff.IsUpdate() && diff.EntityId == null)
+ return false;
+
+ if (diff.IsDelete() && diff.EntityId == null)
+ return false;
+
+ if (diff.IsDelete() && string.IsNullOrWhiteSpace(diff.BeforeData))
+ return false;
+
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Services/TaskLockService.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Services/TaskLockService.cs
new file mode 100644
index 0000000..f9195d4
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/Services/TaskLockService.cs
@@ -0,0 +1,302 @@
+using ColaFlow.Modules.Mcp.Domain.Entities;
+using ColaFlow.Modules.Mcp.Domain.Repositories;
+
+namespace ColaFlow.Modules.Mcp.Domain.Services;
+
+///
+/// Domain service for managing task locks and concurrency control
+///
+public sealed class TaskLockService
+{
+ private readonly ITaskLockRepository _taskLockRepository;
+
+ public TaskLockService(ITaskLockRepository taskLockRepository)
+ {
+ _taskLockRepository = taskLockRepository ?? throw new ArgumentNullException(nameof(taskLockRepository));
+ }
+
+ ///
+ /// Try to acquire a lock for a resource
+ /// Returns the lock if successful, or null if the resource is already locked
+ ///
+ public async Task TryAcquireLockAsync(
+ string resourceType,
+ Guid resourceId,
+ string lockHolderType,
+ Guid lockHolderId,
+ Guid tenantId,
+ string? lockHolderName = null,
+ string? purpose = null,
+ int expirationMinutes = 5,
+ CancellationToken cancellationToken = default)
+ {
+ // Check if resource is already locked
+ var existingLock = await _taskLockRepository.GetActiveLockForResourceAsync(
+ tenantId,
+ resourceType,
+ resourceId,
+ cancellationToken);
+
+ if (existingLock != null)
+ {
+ // Check if the lock has expired
+ if (existingLock.IsExpired())
+ {
+ // Mark as expired and allow new lock
+ existingLock.MarkAsExpired();
+ await _taskLockRepository.UpdateAsync(existingLock, cancellationToken);
+ await _taskLockRepository.SaveChangesAsync(cancellationToken);
+ }
+ else
+ {
+ // Resource is locked by someone else
+ return null;
+ }
+ }
+
+ // Acquire new lock
+ var newLock = TaskLock.Acquire(
+ resourceType: resourceType,
+ resourceId: resourceId,
+ lockHolderType: lockHolderType,
+ lockHolderId: lockHolderId,
+ tenantId: tenantId,
+ lockHolderName: lockHolderName,
+ purpose: purpose,
+ expirationMinutes: expirationMinutes
+ );
+
+ await _taskLockRepository.AddAsync(newLock, cancellationToken);
+ await _taskLockRepository.SaveChangesAsync(cancellationToken);
+
+ return newLock;
+ }
+
+ ///
+ /// Release a lock by ID
+ ///
+ public async Task ReleaseLockAsync(
+ Guid lockId,
+ Guid lockHolderId,
+ CancellationToken cancellationToken = default)
+ {
+ var taskLock = await _taskLockRepository.GetByIdAsync(lockId, cancellationToken);
+
+ if (taskLock == null)
+ return false;
+
+ // Verify that the caller is the lock holder
+ if (taskLock.LockHolderId != lockHolderId)
+ throw new InvalidOperationException(
+ "Cannot release lock held by another user/agent");
+
+ if (!taskLock.IsValid())
+ return false; // Lock already released or expired
+
+ taskLock.Release();
+ await _taskLockRepository.UpdateAsync(taskLock, cancellationToken);
+ await _taskLockRepository.SaveChangesAsync(cancellationToken);
+
+ return true;
+ }
+
+ ///
+ /// Release a lock for a specific resource
+ ///
+ public async Task ReleaseLockForResourceAsync(
+ string resourceType,
+ Guid resourceId,
+ Guid lockHolderId,
+ Guid tenantId,
+ CancellationToken cancellationToken = default)
+ {
+ var taskLock = await _taskLockRepository.GetActiveLockForResourceAsync(
+ tenantId,
+ resourceType,
+ resourceId,
+ cancellationToken);
+
+ if (taskLock == null)
+ return false;
+
+ // Verify that the caller is the lock holder
+ if (taskLock.LockHolderId != lockHolderId)
+ throw new InvalidOperationException(
+ "Cannot release lock held by another user/agent");
+
+ if (!taskLock.IsValid())
+ return false; // Lock already released or expired
+
+ taskLock.Release();
+ await _taskLockRepository.UpdateAsync(taskLock, cancellationToken);
+ await _taskLockRepository.SaveChangesAsync(cancellationToken);
+
+ return true;
+ }
+
+ ///
+ /// Check if a resource is currently locked
+ ///
+ public async Task IsResourceLockedAsync(
+ Guid tenantId,
+ string resourceType,
+ Guid resourceId,
+ CancellationToken cancellationToken = default)
+ {
+ var activeLock = await _taskLockRepository.GetActiveLockForResourceAsync(
+ tenantId,
+ resourceType,
+ resourceId,
+ cancellationToken);
+
+ if (activeLock == null)
+ return false;
+
+ // Check if lock has expired
+ if (activeLock.IsExpired())
+ {
+ // Mark as expired
+ activeLock.MarkAsExpired();
+ await _taskLockRepository.UpdateAsync(activeLock, cancellationToken);
+ await _taskLockRepository.SaveChangesAsync(cancellationToken);
+ return false;
+ }
+
+ return activeLock.IsValid();
+ }
+
+ ///
+ /// Check if a resource is locked by a specific holder
+ ///
+ public async Task IsResourceLockedByAsync(
+ Guid tenantId,
+ string resourceType,
+ Guid resourceId,
+ Guid lockHolderId,
+ CancellationToken cancellationToken = default)
+ {
+ var activeLock = await _taskLockRepository.GetActiveLockForResourceAsync(
+ tenantId,
+ resourceType,
+ resourceId,
+ cancellationToken);
+
+ if (activeLock == null)
+ return false;
+
+ return activeLock.IsHeldBy(lockHolderId);
+ }
+
+ ///
+ /// Get the current lock for a resource (if any)
+ ///
+ public async Task GetActiveLockAsync(
+ Guid tenantId,
+ string resourceType,
+ Guid resourceId,
+ CancellationToken cancellationToken = default)
+ {
+ var activeLock = await _taskLockRepository.GetActiveLockForResourceAsync(
+ tenantId,
+ resourceType,
+ resourceId,
+ cancellationToken);
+
+ if (activeLock == null)
+ return null;
+
+ // Check if lock has expired
+ if (activeLock.IsExpired())
+ {
+ activeLock.MarkAsExpired();
+ await _taskLockRepository.UpdateAsync(activeLock, cancellationToken);
+ await _taskLockRepository.SaveChangesAsync(cancellationToken);
+ return null;
+ }
+
+ return activeLock.IsValid() ? activeLock : null;
+ }
+
+ ///
+ /// Extend the expiration time of a lock
+ ///
+ public async Task ExtendLockAsync(
+ Guid lockId,
+ Guid lockHolderId,
+ int additionalMinutes,
+ CancellationToken cancellationToken = default)
+ {
+ var taskLock = await _taskLockRepository.GetByIdAsync(lockId, cancellationToken);
+
+ if (taskLock == null)
+ return false;
+
+ // Verify that the caller is the lock holder
+ if (taskLock.LockHolderId != lockHolderId)
+ throw new InvalidOperationException(
+ "Cannot extend lock held by another user/agent");
+
+ if (!taskLock.IsValid())
+ return false;
+
+ taskLock.ExtendExpiration(additionalMinutes);
+ await _taskLockRepository.UpdateAsync(taskLock, cancellationToken);
+ await _taskLockRepository.SaveChangesAsync(cancellationToken);
+
+ return true;
+ }
+
+ ///
+ /// Process expired locks - marks them as expired
+ /// This should be called by a background job periodically
+ ///
+ public async Task ProcessExpiredLocksAsync(CancellationToken cancellationToken = default)
+ {
+ var expiredLocks = await _taskLockRepository.GetExpiredAsync(cancellationToken);
+
+ var count = 0;
+ foreach (var taskLock in expiredLocks)
+ {
+ taskLock.MarkAsExpired();
+ await _taskLockRepository.UpdateAsync(taskLock, cancellationToken);
+ count++;
+ }
+
+ if (count > 0)
+ {
+ await _taskLockRepository.SaveChangesAsync(cancellationToken);
+ }
+
+ return count;
+ }
+
+ ///
+ /// Release all locks held by a specific holder
+ /// Useful when an AI agent disconnects or a user logs out
+ ///
+ public async Task ReleaseAllLocksForHolderAsync(
+ Guid tenantId,
+ Guid lockHolderId,
+ CancellationToken cancellationToken = default)
+ {
+ var locks = await _taskLockRepository.GetByLockHolderAsync(
+ tenantId,
+ lockHolderId,
+ cancellationToken);
+
+ var count = 0;
+ foreach (var taskLock in locks.Where(l => l.IsValid()))
+ {
+ taskLock.Release();
+ await _taskLockRepository.UpdateAsync(taskLock, cancellationToken);
+ count++;
+ }
+
+ if (count > 0)
+ {
+ await _taskLockRepository.SaveChangesAsync(cancellationToken);
+ }
+
+ return count;
+ }
+}
diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ValueObjects/DiffField.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ValueObjects/DiffField.cs
new file mode 100644
index 0000000..146419c
--- /dev/null
+++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Domain/ValueObjects/DiffField.cs
@@ -0,0 +1,109 @@
+using ColaFlow.Shared.Kernel.Common;
+
+namespace ColaFlow.Modules.Mcp.Domain.ValueObjects;
+
+///
+/// Value object representing a single field difference in a change preview
+///
+public sealed class DiffField : ValueObject
+{
+ ///
+ /// The name of the field that changed (e.g., "Title", "Status")
+ ///
+ public string FieldName { get; private set; }
+
+ ///
+ /// Human-readable display name for the field
+ ///
+ public string DisplayName { get; private set; }
+
+ ///
+ /// The old value before the change
+ ///
+ public object? OldValue { get; private set; }
+
+ ///
+ /// The new value after the change
+ ///
+ public object? NewValue { get; private set; }
+
+ ///
+ /// Optional HTML diff markup for rich text fields
+ ///
+ public string? DiffHtml { get; private set; }
+
+ ///
+ /// Private constructor for EF Core
+ ///
+ private DiffField()
+ {
+ FieldName = string.Empty;
+ DisplayName = string.Empty;
+ }
+
+ ///
+ /// Create a new DiffField
+ ///
+ public DiffField(
+ string fieldName,
+ string displayName,
+ object? oldValue,
+ object? newValue,
+ string? diffHtml = null)
+ {
+ if (string.IsNullOrWhiteSpace(fieldName))
+ throw new ArgumentException("Field name cannot be empty", nameof(fieldName));
+
+ if (string.IsNullOrWhiteSpace(displayName))
+ throw new ArgumentException("Display name cannot be empty", nameof(displayName));
+
+ FieldName = fieldName;
+ DisplayName = displayName;
+ OldValue = oldValue;
+ NewValue = newValue;
+ DiffHtml = diffHtml;
+ }
+
+ ///
+ /// Value object equality - compare all atomic values
+ ///
+ protected override IEnumerable