diff --git a/backend/src/Taskdeck.Api/Controllers/ArchiveController.cs b/backend/src/Taskdeck.Api/Controllers/ArchiveController.cs
new file mode 100644
index 000000000..55c0d1b28
--- /dev/null
+++ b/backend/src/Taskdeck.Api/Controllers/ArchiveController.cs
@@ -0,0 +1,215 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Taskdeck.Application.DTOs;
+using Taskdeck.Application.Interfaces;
+using Taskdeck.Application.Services;
+using Taskdeck.Domain.Entities;
+using Taskdeck.Domain.Exceptions;
+
+namespace Taskdeck.Api.Controllers;
+
+///
+/// API endpoints for managing archived items and restoring them.
+///
+[ApiController]
+[Authorize]
+[Route("api/archive")]
+public class ArchiveController : ControllerBase
+{
+ private readonly IArchiveRecoveryService _archiveService;
+ private readonly IUserContext _userContext;
+
+ public ArchiveController(
+ IArchiveRecoveryService archiveService,
+ IUserContext userContext)
+ {
+ _archiveService = archiveService;
+ _userContext = userContext;
+ }
+
+ ///
+ /// Gets a list of archived items with optional filters.
+ ///
+ /// Filter by entity type (board, column, card)
+ /// Filter by board ID
+ /// Filter by restore status
+ /// Maximum number of results (default: 100)
+ /// Cancellation token
+ /// List of archive items
+ [HttpGet("items")]
+ public async Task GetArchiveItems(
+ [FromQuery] string? entityType,
+ [FromQuery] Guid? boardId,
+ [FromQuery] RestoreStatus? status,
+ [FromQuery] int limit = 100,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _archiveService.GetArchiveItemsAsync(
+ entityType,
+ boardId,
+ status,
+ limit,
+ cancellationToken);
+
+ if (result.IsSuccess)
+ return Ok(result.Value);
+
+ return result.ErrorCode switch
+ {
+ "NotFound" => NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "ValidationError" => BadRequest(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ _ => Problem(result.ErrorMessage, statusCode: 500)
+ };
+ }
+
+ ///
+ /// Gets a specific archive item by ID.
+ ///
+ /// Archive item ID
+ /// Cancellation token
+ /// Archive item details
+ [HttpGet("items/{id}")]
+ public async Task GetArchiveItem(Guid id, CancellationToken cancellationToken = default)
+ {
+ var result = await _archiveService.GetArchiveItemByIdAsync(id, cancellationToken);
+
+ if (!result.IsSuccess)
+ {
+ return result.ErrorCode == "NotFound"
+ ? NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage })
+ : Problem(result.ErrorMessage, statusCode: 500);
+ }
+
+ return Ok(result.Value);
+ }
+
+ ///
+ /// Restores an archived item.
+ ///
+ /// Entity type (board, column, card)
+ /// Entity ID to restore
+ /// Restore options
+ /// Cancellation token
+ /// Restore result
+ [HttpPost("{entityType}/{entityId}/restore")]
+ public async Task RestoreArchivedItem(
+ string entityType,
+ Guid entityId,
+ [FromBody] RestoreArchiveItemDto dto,
+ CancellationToken cancellationToken = default)
+ {
+ if (!TryNormalizeEntityType(entityType, out var normalizedEntityType, out var invalidTypeResult))
+ return invalidTypeResult!;
+
+ if (!TryGetCurrentUserId(out var restoredByUserId, out var userErrorResult))
+ return userErrorResult!;
+
+ var archiveItemResult = await _archiveService.GetArchiveItemByEntityAsync(
+ normalizedEntityType,
+ entityId,
+ cancellationToken);
+
+ if (!archiveItemResult.IsSuccess)
+ {
+ return archiveItemResult.ErrorCode switch
+ {
+ "NotFound" => NotFound(new { errorCode = archiveItemResult.ErrorCode, message = archiveItemResult.ErrorMessage }),
+ "ValidationError" => BadRequest(new { errorCode = archiveItemResult.ErrorCode, message = archiveItemResult.ErrorMessage }),
+ "AuthenticationFailed" => Unauthorized(new { errorCode = archiveItemResult.ErrorCode, message = archiveItemResult.ErrorMessage }),
+ "Unauthorized" => Unauthorized(new { errorCode = archiveItemResult.ErrorCode, message = archiveItemResult.ErrorMessage }),
+ "Forbidden" => StatusCode(403, new { errorCode = archiveItemResult.ErrorCode, message = archiveItemResult.ErrorMessage }),
+ _ => Problem(archiveItemResult.ErrorMessage, statusCode: 500)
+ };
+ }
+
+ var archiveItem = archiveItemResult.Value;
+ if (archiveItem.RestoreStatus != RestoreStatus.Available)
+ {
+ return Conflict(new
+ {
+ errorCode = ErrorCodes.InvalidOperation,
+ message = $"Archive item for {normalizedEntityType} with ID {entityId} is in status {archiveItem.RestoreStatus}"
+ });
+ }
+
+ var result = await _archiveService.RestoreArchiveItemAsync(
+ archiveItem.Id,
+ dto,
+ restoredByUserId,
+ cancellationToken);
+
+ if (!result.IsSuccess)
+ {
+ return result.ErrorCode switch
+ {
+ "NotFound" => NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "ValidationError" => BadRequest(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "Conflict" => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ ErrorCodes.InvalidOperation => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "AuthenticationFailed" => Unauthorized(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "Unauthorized" => Unauthorized(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "Forbidden" => StatusCode(403, new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ _ => Problem(result.ErrorMessage, statusCode: 500)
+ };
+ }
+
+ return Ok(result.Value);
+ }
+
+ private static bool TryNormalizeEntityType(string entityType, out string normalizedEntityType, out IActionResult? errorResult)
+ {
+ normalizedEntityType = string.Empty;
+ errorResult = null;
+
+ if (string.IsNullOrWhiteSpace(entityType))
+ {
+ errorResult = new BadRequestObjectResult(new
+ {
+ errorCode = ErrorCodes.ValidationError,
+ message = "EntityType is required"
+ });
+ return false;
+ }
+
+ normalizedEntityType = entityType.Trim().ToLowerInvariant();
+ if (normalizedEntityType != "board" && normalizedEntityType != "column" && normalizedEntityType != "card")
+ {
+ errorResult = new BadRequestObjectResult(new
+ {
+ errorCode = ErrorCodes.ValidationError,
+ message = "EntityType must be 'board', 'column', or 'card'"
+ });
+ return false;
+ }
+
+ return true;
+ }
+
+ private bool TryGetCurrentUserId(out Guid userId, out IActionResult? errorResult)
+ {
+ userId = Guid.Empty;
+ errorResult = null;
+
+ if (!_userContext.IsAuthenticated || string.IsNullOrWhiteSpace(_userContext.UserId))
+ {
+ errorResult = Unauthorized(new
+ {
+ errorCode = ErrorCodes.AuthenticationFailed,
+ message = "Authenticated user context is required"
+ });
+ return false;
+ }
+
+ if (!Guid.TryParse(_userContext.UserId, out userId))
+ {
+ errorResult = Unauthorized(new
+ {
+ errorCode = ErrorCodes.AuthenticationFailed,
+ message = "Authenticated user id claim is invalid"
+ });
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs b/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs
new file mode 100644
index 000000000..622983e1d
--- /dev/null
+++ b/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs
@@ -0,0 +1,295 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Taskdeck.Application.DTOs;
+using Taskdeck.Application.Interfaces;
+using Taskdeck.Application.Services;
+using Taskdeck.Domain.Entities;
+using Taskdeck.Domain.Exceptions;
+
+namespace Taskdeck.Api.Controllers;
+
+///
+/// API endpoints for managing automation proposals and their lifecycle.
+///
+[ApiController]
+[Authorize]
+[Route("api/automation/proposals")]
+public class AutomationProposalsController : ControllerBase
+{
+ private readonly IAutomationProposalService _proposalService;
+ private readonly IAutomationExecutorService _executorService;
+ private readonly IUserContext _userContext;
+
+ public AutomationProposalsController(
+ IAutomationProposalService proposalService,
+ IAutomationExecutorService executorService,
+ IUserContext userContext)
+ {
+ _proposalService = proposalService;
+ _executorService = executorService;
+ _userContext = userContext;
+ }
+
+ ///
+ /// Gets a list of automation proposals with optional filters.
+ ///
+ /// Filter by proposal status
+ /// Filter by board ID
+ /// Filter by user ID
+ /// Filter by risk level
+ /// Maximum number of results (default: 100)
+ /// Cancellation token
+ /// List of proposals
+ [HttpGet]
+ public async Task GetProposals(
+ [FromQuery] ProposalStatus? status,
+ [FromQuery] Guid? boardId,
+ [FromQuery] Guid? userId,
+ [FromQuery] RiskLevel? riskLevel,
+ [FromQuery] int limit = 100,
+ CancellationToken cancellationToken = default)
+ {
+ var filter = new ProposalFilterDto(status, boardId, userId, riskLevel, limit);
+ var result = await _proposalService.GetProposalsAsync(filter, cancellationToken);
+
+ if (result.IsSuccess)
+ return Ok(result.Value);
+
+ return result.ErrorCode switch
+ {
+ "NotFound" => NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "ValidationError" => BadRequest(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ _ => Problem(result.ErrorMessage, statusCode: 500)
+ };
+ }
+
+ ///
+ /// Gets a specific automation proposal by ID with all operations.
+ ///
+ /// Proposal ID
+ /// Cancellation token
+ /// Proposal details
+ [HttpGet("{id}")]
+ public async Task GetProposal(Guid id, CancellationToken cancellationToken = default)
+ {
+ var result = await _proposalService.GetProposalByIdAsync(id, cancellationToken);
+
+ if (!result.IsSuccess)
+ {
+ return result.ErrorCode == "NotFound"
+ ? NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage })
+ : Problem(result.ErrorMessage, statusCode: 500);
+ }
+
+ return Ok(result.Value);
+ }
+
+ ///
+ /// Creates a new automation proposal with operations.
+ ///
+ /// Proposal creation request
+ /// Cancellation token
+ /// Created proposal
+ [HttpPost]
+ public async Task CreateProposal([FromBody] CreateProposalDto dto, CancellationToken cancellationToken = default)
+ {
+ if (!TryGetCurrentUserId(out var requestedByUserId, out var errorResult))
+ return errorResult!;
+
+ var createDto = dto with
+ {
+ RequestedByUserId = requestedByUserId
+ };
+
+ var result = await _proposalService.CreateProposalAsync(createDto, cancellationToken);
+
+ if (!result.IsSuccess)
+ {
+ return result.ErrorCode switch
+ {
+ "ValidationError" => BadRequest(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "NotFound" => NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "AuthenticationFailed" => Unauthorized(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "Unauthorized" => Unauthorized(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "Forbidden" => StatusCode(403, new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ _ => Problem(result.ErrorMessage, statusCode: 500)
+ };
+ }
+
+ return CreatedAtAction(nameof(GetProposal), new { id = result.Value.Id }, result.Value);
+ }
+
+ ///
+ /// Approves a pending automation proposal.
+ ///
+ /// Proposal ID
+ /// Cancellation token
+ /// Updated proposal
+ [HttpPost("{id}/approve")]
+ public async Task ApproveProposal(
+ Guid id,
+ CancellationToken cancellationToken = default)
+ {
+ if (!TryGetCurrentUserId(out var decidedByUserId, out var errorResult))
+ return errorResult!;
+
+ var result = await _proposalService.ApproveProposalAsync(id, decidedByUserId, cancellationToken);
+
+ if (!result.IsSuccess)
+ {
+ return result.ErrorCode switch
+ {
+ "NotFound" => NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "ValidationError" => BadRequest(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "Conflict" => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ ErrorCodes.InvalidOperation => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "AuthenticationFailed" => Unauthorized(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "Unauthorized" => Unauthorized(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "Forbidden" => StatusCode(403, new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ _ => Problem(result.ErrorMessage, statusCode: 500)
+ };
+ }
+
+ return Ok(result.Value);
+ }
+
+ ///
+ /// Rejects a pending automation proposal.
+ ///
+ /// Proposal ID
+ /// Rejection details (reason required for High/Critical risk)
+ /// Cancellation token
+ /// Updated proposal
+ [HttpPost("{id}/reject")]
+ public async Task RejectProposal(
+ Guid id,
+ [FromBody] UpdateProposalStatusDto dto,
+ CancellationToken cancellationToken = default)
+ {
+ if (!TryGetCurrentUserId(out var decidedByUserId, out var errorResult))
+ return errorResult!;
+
+ var result = await _proposalService.RejectProposalAsync(id, decidedByUserId, dto, cancellationToken);
+
+ if (!result.IsSuccess)
+ {
+ return result.ErrorCode switch
+ {
+ "NotFound" => NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "ValidationError" => BadRequest(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "Conflict" => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ ErrorCodes.InvalidOperation => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "AuthenticationFailed" => Unauthorized(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "Unauthorized" => Unauthorized(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "Forbidden" => StatusCode(403, new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ _ => Problem(result.ErrorMessage, statusCode: 500)
+ };
+ }
+
+ return Ok(result.Value);
+ }
+
+ ///
+ /// Executes an approved automation proposal through the automation executor.
+ ///
+ /// Proposal ID
+ /// Cancellation token
+ /// Updated proposal
+ [HttpPost("{id}/execute")]
+ public async Task ExecuteProposal(Guid id, CancellationToken cancellationToken = default)
+ {
+ if (!Request.Headers.TryGetValue("Idempotency-Key", out var idempotencyHeader) ||
+ string.IsNullOrWhiteSpace(idempotencyHeader))
+ {
+ return BadRequest(new
+ {
+ errorCode = ErrorCodes.ValidationError,
+ message = "Idempotency-Key header is required"
+ });
+ }
+
+ var executionResult = await _executorService.ExecuteProposalAsync(id, idempotencyHeader.ToString(), cancellationToken);
+ if (!executionResult.IsSuccess)
+ {
+ return executionResult.ErrorCode switch
+ {
+ "NotFound" => NotFound(new { errorCode = executionResult.ErrorCode, message = executionResult.ErrorMessage }),
+ "ValidationError" => BadRequest(new { errorCode = executionResult.ErrorCode, message = executionResult.ErrorMessage }),
+ "Conflict" => Conflict(new { errorCode = executionResult.ErrorCode, message = executionResult.ErrorMessage }),
+ ErrorCodes.InvalidOperation => Conflict(new { errorCode = executionResult.ErrorCode, message = executionResult.ErrorMessage }),
+ "AuthenticationFailed" => Unauthorized(new { errorCode = executionResult.ErrorCode, message = executionResult.ErrorMessage }),
+ "Unauthorized" => Unauthorized(new { errorCode = executionResult.ErrorCode, message = executionResult.ErrorMessage }),
+ "Forbidden" => StatusCode(403, new { errorCode = executionResult.ErrorCode, message = executionResult.ErrorMessage }),
+ _ => Problem(executionResult.ErrorMessage, statusCode: 500)
+ };
+ }
+
+ var result = await _proposalService.GetProposalByIdAsync(id, cancellationToken);
+
+ if (!result.IsSuccess)
+ {
+ return result.ErrorCode switch
+ {
+ "NotFound" => NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "ValidationError" => BadRequest(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "Conflict" => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ ErrorCodes.InvalidOperation => Conflict(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "AuthenticationFailed" => Unauthorized(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "Unauthorized" => Unauthorized(new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ "Forbidden" => StatusCode(403, new { errorCode = result.ErrorCode, message = result.ErrorMessage }),
+ _ => Problem(result.ErrorMessage, statusCode: 500)
+ };
+ }
+
+ return Ok(result.Value);
+ }
+
+ ///
+ /// Gets a diff preview for a proposal showing what changes will be made.
+ ///
+ /// Proposal ID
+ /// Cancellation token
+ /// Diff preview text
+ [HttpGet("{id}/diff")]
+ public async Task GetProposalDiff(Guid id, CancellationToken cancellationToken = default)
+ {
+ var result = await _proposalService.GetProposalDiffAsync(id, cancellationToken);
+
+ if (!result.IsSuccess)
+ {
+ return result.ErrorCode == "NotFound"
+ ? NotFound(new { errorCode = result.ErrorCode, message = result.ErrorMessage })
+ : Problem(result.ErrorMessage, statusCode: 500);
+ }
+
+ return Ok(new { diff = result.Value });
+ }
+
+ private bool TryGetCurrentUserId(out Guid userId, out IActionResult? errorResult)
+ {
+ userId = Guid.Empty;
+ errorResult = null;
+
+ if (!_userContext.IsAuthenticated || string.IsNullOrWhiteSpace(_userContext.UserId))
+ {
+ errorResult = Unauthorized(new
+ {
+ errorCode = ErrorCodes.AuthenticationFailed,
+ message = "Authenticated user context is required"
+ });
+ return false;
+ }
+
+ if (!Guid.TryParse(_userContext.UserId, out userId))
+ {
+ errorResult = Unauthorized(new
+ {
+ errorCode = ErrorCodes.AuthenticationFailed,
+ message = "Authenticated user id claim is invalid"
+ });
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/backend/src/Taskdeck.Api/Program.cs b/backend/src/Taskdeck.Api/Program.cs
index 6bfb64534..b18b58637 100644
--- a/backend/src/Taskdeck.Api/Program.cs
+++ b/backend/src/Taskdeck.Api/Program.cs
@@ -23,11 +23,21 @@
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped(sp => sp.GetRequiredService());
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
+// Add IUserContext for claim-based identity
+builder.Services.AddHttpContextAccessor();
+builder.Services.AddScoped();
// Add JwtSettings (required by AuthenticationService)
var jwtSettings = builder.Configuration.GetSection("Jwt").Get() ?? new JwtSettings();
diff --git a/backend/src/Taskdeck.Application/DTOs/ArchiveRecoveryDtos.cs b/backend/src/Taskdeck.Application/DTOs/ArchiveRecoveryDtos.cs
new file mode 100644
index 000000000..7982dd28c
--- /dev/null
+++ b/backend/src/Taskdeck.Application/DTOs/ArchiveRecoveryDtos.cs
@@ -0,0 +1,55 @@
+using Taskdeck.Domain.Entities;
+
+namespace Taskdeck.Application.DTOs;
+
+public record CreateArchiveItemDto(
+ string EntityType,
+ Guid EntityId,
+ Guid BoardId,
+ string Name,
+ Guid ArchivedByUserId,
+ string SnapshotJson,
+ string? Reason
+);
+
+public record ArchiveItemDto(
+ Guid Id,
+ string EntityType,
+ Guid EntityId,
+ Guid BoardId,
+ string Name,
+ Guid ArchivedByUserId,
+ DateTime ArchivedAt,
+ string? Reason,
+ RestoreStatus RestoreStatus,
+ DateTime? RestoredAt,
+ Guid? RestoredByUserId,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset UpdatedAt
+);
+
+public record RestoreArchiveItemDto(
+ Guid? TargetBoardId,
+ RestoreMode RestoreMode,
+ ConflictStrategy ConflictStrategy
+);
+
+public record RestoreResult(
+ bool Success,
+ Guid? RestoredEntityId,
+ string? ErrorMessage,
+ string? ResolvedName
+);
+
+public enum RestoreMode
+{
+ InPlace,
+ Copy
+}
+
+public enum ConflictStrategy
+{
+ Fail,
+ Rename,
+ AppendSuffix
+}
diff --git a/backend/src/Taskdeck.Application/DTOs/AutomationProposalDtos.cs b/backend/src/Taskdeck.Application/DTOs/AutomationProposalDtos.cs
new file mode 100644
index 000000000..947cb42b6
--- /dev/null
+++ b/backend/src/Taskdeck.Application/DTOs/AutomationProposalDtos.cs
@@ -0,0 +1,71 @@
+using Taskdeck.Domain.Entities;
+
+namespace Taskdeck.Application.DTOs;
+
+public record ProposalDto(
+ Guid Id,
+ ProposalSourceType SourceType,
+ string? SourceReferenceId,
+ Guid? BoardId,
+ Guid RequestedByUserId,
+ ProposalStatus Status,
+ RiskLevel RiskLevel,
+ string Summary,
+ string? DiffPreview,
+ string? ValidationIssues,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset UpdatedAt,
+ DateTime ExpiresAt,
+ DateTime? DecidedAt,
+ Guid? DecidedByUserId,
+ DateTime? AppliedAt,
+ string? FailureReason,
+ string CorrelationId,
+ List Operations
+);
+
+public record ProposalOperationDto(
+ Guid Id,
+ Guid ProposalId,
+ int Sequence,
+ string ActionType,
+ string TargetType,
+ string? TargetId,
+ string Parameters,
+ string IdempotencyKey,
+ string? ExpectedVersion
+);
+
+public record CreateProposalDto(
+ ProposalSourceType SourceType,
+ Guid RequestedByUserId,
+ string Summary,
+ RiskLevel RiskLevel,
+ string CorrelationId,
+ Guid? BoardId = null,
+ string? SourceReferenceId = null,
+ int ExpiryMinutes = 1440,
+ List? Operations = null
+);
+
+public record CreateProposalOperationDto(
+ int Sequence,
+ string ActionType,
+ string TargetType,
+ string Parameters,
+ string IdempotencyKey,
+ string? TargetId = null,
+ string? ExpectedVersion = null
+);
+
+public record UpdateProposalStatusDto(
+ string? Reason = null
+);
+
+public record ProposalFilterDto(
+ ProposalStatus? Status = null,
+ Guid? BoardId = null,
+ Guid? UserId = null,
+ RiskLevel? RiskLevel = null,
+ int Limit = 100
+);
diff --git a/backend/src/Taskdeck.Application/Interfaces/IArchiveItemRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IArchiveItemRepository.cs
new file mode 100644
index 000000000..83109cac8
--- /dev/null
+++ b/backend/src/Taskdeck.Application/Interfaces/IArchiveItemRepository.cs
@@ -0,0 +1,12 @@
+using Taskdeck.Domain.Entities;
+
+namespace Taskdeck.Application.Interfaces;
+
+public interface IArchiveItemRepository : IRepository
+{
+ Task> GetByBoardIdAsync(Guid boardId, int limit = 100, CancellationToken cancellationToken = default);
+ Task> GetByEntityTypeAsync(string entityType, int limit = 100, CancellationToken cancellationToken = default);
+ Task> GetByStatusAsync(RestoreStatus status, int limit = 100, CancellationToken cancellationToken = default);
+ Task GetByEntityAsync(string entityType, Guid entityId, CancellationToken cancellationToken = default);
+ Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default);
+}
diff --git a/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs
new file mode 100644
index 000000000..4794910a4
--- /dev/null
+++ b/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs
@@ -0,0 +1,14 @@
+using Taskdeck.Domain.Entities;
+
+namespace Taskdeck.Application.Interfaces;
+
+public interface IAutomationProposalRepository : IRepository
+{
+ Task> GetByStatusAsync(ProposalStatus status, int limit = 100, CancellationToken cancellationToken = default);
+ Task> GetByBoardIdAsync(Guid boardId, int limit = 100, CancellationToken cancellationToken = default);
+ Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default);
+ Task> GetByRiskLevelAsync(RiskLevel riskLevel, int limit = 100, CancellationToken cancellationToken = default);
+ Task GetBySourceReferenceAsync(ProposalSourceType sourceType, string referenceId, CancellationToken cancellationToken = default);
+ Task GetByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default);
+ Task> GetExpiredAsync(CancellationToken cancellationToken = default);
+}
diff --git a/backend/src/Taskdeck.Application/Interfaces/IChatMessageRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IChatMessageRepository.cs
new file mode 100644
index 000000000..66154c103
--- /dev/null
+++ b/backend/src/Taskdeck.Application/Interfaces/IChatMessageRepository.cs
@@ -0,0 +1,9 @@
+using Taskdeck.Domain.Entities;
+
+namespace Taskdeck.Application.Interfaces;
+
+public interface IChatMessageRepository : IRepository
+{
+ Task> GetBySessionIdAsync(Guid sessionId, int limit = 100, CancellationToken cancellationToken = default);
+ Task> GetByProposalIdAsync(Guid proposalId, CancellationToken cancellationToken = default);
+}
diff --git a/backend/src/Taskdeck.Application/Interfaces/IChatSessionRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IChatSessionRepository.cs
new file mode 100644
index 000000000..e490cb499
--- /dev/null
+++ b/backend/src/Taskdeck.Application/Interfaces/IChatSessionRepository.cs
@@ -0,0 +1,11 @@
+using Taskdeck.Domain.Entities;
+
+namespace Taskdeck.Application.Interfaces;
+
+public interface IChatSessionRepository : IRepository
+{
+ Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default);
+ Task> GetByBoardIdAsync(Guid boardId, int limit = 100, CancellationToken cancellationToken = default);
+ Task> GetByStatusAsync(ChatSessionStatus status, int limit = 100, CancellationToken cancellationToken = default);
+ Task GetByIdWithMessagesAsync(Guid id, CancellationToken cancellationToken = default);
+}
diff --git a/backend/src/Taskdeck.Application/Interfaces/ICommandRunRepository.cs b/backend/src/Taskdeck.Application/Interfaces/ICommandRunRepository.cs
new file mode 100644
index 000000000..a6785ced4
--- /dev/null
+++ b/backend/src/Taskdeck.Application/Interfaces/ICommandRunRepository.cs
@@ -0,0 +1,12 @@
+using Taskdeck.Domain.Entities;
+
+namespace Taskdeck.Application.Interfaces;
+
+public interface ICommandRunRepository : IRepository
+{
+ Task> GetByUserIdAsync(Guid userId, int limit = 100, CancellationToken cancellationToken = default);
+ Task> GetByStatusAsync(CommandRunStatus status, int limit = 100, CancellationToken cancellationToken = default);
+ Task> GetByTemplateNameAsync(string templateName, int limit = 100, CancellationToken cancellationToken = default);
+ Task GetByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default);
+ Task GetByIdWithLogsAsync(Guid id, CancellationToken cancellationToken = default);
+}
diff --git a/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs b/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs
index 0dcf90fe7..32ca3d45f 100644
--- a/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs
+++ b/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs
@@ -10,6 +10,11 @@ public interface IUnitOfWork
IBoardAccessRepository BoardAccesses { get; }
IAuditLogRepository AuditLogs { get; }
ILlmQueueRepository LlmQueue { get; }
+ IAutomationProposalRepository AutomationProposals { get; }
+ IArchiveItemRepository ArchiveItems { get; }
+ IChatSessionRepository ChatSessions { get; }
+ IChatMessageRepository ChatMessages { get; }
+ ICommandRunRepository CommandRuns { get; }
Task SaveChangesAsync(CancellationToken cancellationToken = default);
Task BeginTransactionAsync(CancellationToken cancellationToken = default);
diff --git a/backend/src/Taskdeck.Application/Interfaces/IUserContext.cs b/backend/src/Taskdeck.Application/Interfaces/IUserContext.cs
new file mode 100644
index 000000000..13c2def2b
--- /dev/null
+++ b/backend/src/Taskdeck.Application/Interfaces/IUserContext.cs
@@ -0,0 +1,25 @@
+namespace Taskdeck.Application.Interfaces;
+
+///
+/// Provides access to the current authenticated user's identity from JWT claims.
+/// This is the single source of truth for actor identity - never trust client-supplied user IDs.
+///
+public interface IUserContext
+{
+ ///
+ /// Gets the current authenticated user's ID from JWT claims.
+ /// Returns null if no user is authenticated.
+ ///
+ string? UserId { get; }
+
+ ///
+ /// Gets whether a user is currently authenticated.
+ ///
+ bool IsAuthenticated { get; }
+
+ ///
+ /// Gets the current user's role from JWT claims.
+ /// Returns null if no role is present.
+ ///
+ string? Role { get; }
+}
diff --git a/backend/src/Taskdeck.Application/Services/ArchiveRecoveryService.cs b/backend/src/Taskdeck.Application/Services/ArchiveRecoveryService.cs
new file mode 100644
index 000000000..9f6a590d4
--- /dev/null
+++ b/backend/src/Taskdeck.Application/Services/ArchiveRecoveryService.cs
@@ -0,0 +1,553 @@
+using System.Text.Json;
+using Taskdeck.Application.DTOs;
+using Taskdeck.Application.Interfaces;
+using Taskdeck.Domain.Common;
+using Taskdeck.Domain.Entities;
+using Taskdeck.Domain.Enums;
+using Taskdeck.Domain.Exceptions;
+
+namespace Taskdeck.Application.Services;
+
+public class ArchiveRecoveryService : IArchiveRecoveryService
+{
+ private readonly IUnitOfWork _unitOfWork;
+ private readonly IAuthorizationService? _authorizationService;
+
+ public ArchiveRecoveryService(
+ IUnitOfWork unitOfWork,
+ IAuthorizationService? authorizationService = null)
+ {
+ _unitOfWork = unitOfWork;
+ _authorizationService = authorizationService;
+ }
+
+ public async Task> CreateArchiveItemAsync(
+ CreateArchiveItemDto dto,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var archiveItem = new ArchiveItem(
+ dto.EntityType,
+ dto.EntityId,
+ dto.BoardId,
+ dto.Name,
+ dto.ArchivedByUserId,
+ dto.SnapshotJson,
+ dto.Reason);
+
+ await _unitOfWork.ArchiveItems.AddAsync(archiveItem, cancellationToken);
+
+ // Create audit log
+ var auditLog = new AuditLog(
+ "ArchiveItem",
+ archiveItem.Id,
+ AuditAction.Created,
+ dto.ArchivedByUserId,
+ $"Archived {dto.EntityType} '{dto.Name}' (ID: {dto.EntityId})");
+ await _unitOfWork.AuditLogs.AddAsync(auditLog, cancellationToken);
+
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(MapToDto(archiveItem));
+ }
+ catch (DomainException ex)
+ {
+ return Result.Failure(ex.ErrorCode, ex.Message);
+ }
+ }
+
+ public async Task>> GetArchiveItemsAsync(
+ string? entityType = null,
+ Guid? boardId = null,
+ RestoreStatus? status = null,
+ int limit = 100,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ if (limit <= 0 || limit > 1000)
+ {
+ return Result.Failure>(
+ ErrorCodes.ValidationError,
+ "Limit must be between 1 and 1000");
+ }
+
+ if (!string.IsNullOrWhiteSpace(entityType))
+ {
+ entityType = entityType.Trim().ToLowerInvariant();
+ if (entityType != "board" && entityType != "column" && entityType != "card")
+ {
+ return Result.Failure>(
+ ErrorCodes.ValidationError,
+ "EntityType must be 'board', 'column', or 'card'");
+ }
+ }
+
+ IEnumerable items;
+
+ if (entityType != null && boardId != null && status != null)
+ {
+ // Combined filter - need to implement custom query
+ var allItems = await _unitOfWork.ArchiveItems.GetAllAsync(cancellationToken);
+ items = allItems
+ .Where(i => i.EntityType == entityType
+ && i.BoardId == boardId
+ && i.RestoreStatus == status.Value)
+ .Take(limit);
+ }
+ else if (entityType != null)
+ {
+ items = await _unitOfWork.ArchiveItems.GetByEntityTypeAsync(entityType, limit, cancellationToken);
+ }
+ else if (boardId != null)
+ {
+ items = await _unitOfWork.ArchiveItems.GetByBoardIdAsync(boardId.Value, limit, cancellationToken);
+ }
+ else if (status != null)
+ {
+ items = await _unitOfWork.ArchiveItems.GetByStatusAsync(status.Value, limit, cancellationToken);
+ }
+ else
+ {
+ var allItems = await _unitOfWork.ArchiveItems.GetAllAsync(cancellationToken);
+ items = allItems.Take(limit);
+ }
+
+ // Apply additional filters if needed
+ if (entityType != null && boardId == null && status != null)
+ {
+ items = items.Where(i => i.RestoreStatus == status.Value);
+ }
+ else if (entityType == null && boardId != null && status != null)
+ {
+ items = items.Where(i => i.RestoreStatus == status.Value);
+ }
+ else if (entityType != null && boardId != null && status == null)
+ {
+ items = items.Where(i => i.BoardId == boardId.Value);
+ }
+
+ return Result.Success(items.Select(MapToDto));
+ }
+ catch (Exception ex)
+ {
+ return Result.Failure>(
+ ErrorCodes.UnexpectedError,
+ $"Failed to retrieve archive items: {ex.Message}");
+ }
+ }
+
+ public async Task> GetArchiveItemByIdAsync(
+ Guid id,
+ CancellationToken cancellationToken = default)
+ {
+ var archiveItem = await _unitOfWork.ArchiveItems.GetByIdAsync(id, cancellationToken);
+ if (archiveItem == null)
+ return Result.Failure(ErrorCodes.NotFound, $"Archive item with ID {id} not found");
+
+ return Result.Success(MapToDto(archiveItem));
+ }
+
+ public async Task> GetArchiveItemByEntityAsync(
+ string entityType,
+ Guid entityId,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(entityType))
+ return Result.Failure(ErrorCodes.ValidationError, "EntityType cannot be empty");
+ if (entityId == Guid.Empty)
+ return Result.Failure(ErrorCodes.ValidationError, "EntityId cannot be empty");
+
+ var normalizedType = entityType.Trim().ToLowerInvariant();
+ if (normalizedType != "board" && normalizedType != "column" && normalizedType != "card")
+ return Result.Failure(ErrorCodes.ValidationError, "EntityType must be 'board', 'column', or 'card'");
+
+ var archiveItem = await _unitOfWork.ArchiveItems.GetByEntityAsync(normalizedType, entityId, cancellationToken);
+ if (archiveItem == null)
+ return Result.Failure(ErrorCodes.NotFound, $"Archive item for {normalizedType} with entity ID {entityId} not found");
+
+ return Result.Success(MapToDto(archiveItem));
+ }
+
+ public async Task> RestoreArchiveItemAsync(
+ Guid id,
+ RestoreArchiveItemDto dto,
+ Guid restoredByUserId,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ // 1. Get archive item
+ var archiveItem = await _unitOfWork.ArchiveItems.GetByIdAsync(id, cancellationToken);
+ if (archiveItem == null)
+ return Result.Failure(ErrorCodes.NotFound, $"Archive item with ID {id} not found");
+
+ if (archiveItem.RestoreStatus != RestoreStatus.Available)
+ return Result.Failure(
+ ErrorCodes.InvalidOperation,
+ $"Cannot restore archive item with status {archiveItem.RestoreStatus}");
+
+ // 2. Determine target board
+ var targetBoardId = dto.TargetBoardId ?? archiveItem.BoardId;
+
+ // 3. Check permissions
+ if (_authorizationService != null)
+ {
+ var canWriteResult = await _authorizationService.CanWriteBoardAsync(restoredByUserId, targetBoardId);
+ if (!canWriteResult.IsSuccess)
+ {
+ return Result.Failure(
+ canWriteResult.ErrorCode,
+ canWriteResult.ErrorMessage);
+ }
+
+ if (!canWriteResult.Value)
+ {
+ return Result.Failure(
+ ErrorCodes.Forbidden,
+ "User does not have permission to restore to target board");
+ }
+ }
+
+ // 4. Validate and restore based on entity type
+ Result restoreResult;
+ switch (archiveItem.EntityType)
+ {
+ case "board":
+ restoreResult = await RestoreBoardAsync(archiveItem, dto, restoredByUserId, cancellationToken);
+ break;
+ case "column":
+ {
+ var targetBoard = await _unitOfWork.Boards.GetByIdAsync(targetBoardId, cancellationToken);
+ if (targetBoard == null)
+ return Result.Failure(ErrorCodes.NotFound, $"Target board with ID {targetBoardId} not found");
+ if (targetBoard.IsArchived)
+ return Result.Failure(ErrorCodes.InvalidOperation, "Cannot restore to an archived board");
+
+ restoreResult = await RestoreColumnAsync(archiveItem, targetBoardId, dto, restoredByUserId, cancellationToken);
+ break;
+ }
+ case "card":
+ {
+ var targetBoard = await _unitOfWork.Boards.GetByIdAsync(targetBoardId, cancellationToken);
+ if (targetBoard == null)
+ return Result.Failure(ErrorCodes.NotFound, $"Target board with ID {targetBoardId} not found");
+ if (targetBoard.IsArchived)
+ return Result.Failure(ErrorCodes.InvalidOperation, "Cannot restore to an archived board");
+
+ restoreResult = await RestoreCardAsync(archiveItem, targetBoardId, dto, restoredByUserId, cancellationToken);
+ break;
+ }
+ default:
+ return Result.Failure(
+ ErrorCodes.ValidationError,
+ $"Unknown entity type: {archiveItem.EntityType}");
+ }
+
+ if (!restoreResult.IsSuccess)
+ return restoreResult;
+
+ // 6. Mark archive item as restored
+ archiveItem.MarkAsRestored(restoredByUserId);
+
+ // 7. Create audit log
+ var auditLog = new AuditLog(
+ "ArchiveItem",
+ archiveItem.Id,
+ AuditAction.Updated,
+ restoredByUserId,
+ $"Restored {archiveItem.EntityType} '{restoreResult.Value.ResolvedName ?? archiveItem.Name}' " +
+ $"(Original ID: {archiveItem.EntityId}, Restored ID: {restoreResult.Value.RestoredEntityId})");
+ await _unitOfWork.AuditLogs.AddAsync(auditLog, cancellationToken);
+
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return restoreResult;
+ }
+ catch (DomainException ex)
+ {
+ return Result.Failure(ex.ErrorCode, ex.Message);
+ }
+ catch (Exception ex)
+ {
+ return Result.Failure(
+ ErrorCodes.UnexpectedError,
+ $"Failed to restore archive item: {ex.Message}");
+ }
+ }
+
+ private async Task> RestoreBoardAsync(
+ ArchiveItem archiveItem,
+ RestoreArchiveItemDto dto,
+ Guid restoredByUserId,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ // Deserialize snapshot
+ var snapshot = JsonSerializer.Deserialize(archiveItem.SnapshotJson);
+ if (snapshot == null)
+ return Result.Failure(
+ ErrorCodes.ValidationError,
+ "Failed to deserialize board snapshot");
+
+ // Check for naming conflicts
+ var existingBoards = await _unitOfWork.Boards.SearchAsync(snapshot.Name, includeArchived: false, cancellationToken);
+ var conflictExists = existingBoards.Any(b => b.Name == snapshot.Name);
+
+ string resolvedName = snapshot.Name;
+ if (conflictExists)
+ {
+ if (dto.ConflictStrategy == ConflictStrategy.Fail)
+ {
+ return Result.Failure(
+ ErrorCodes.Conflict,
+ $"A board with name '{snapshot.Name}' already exists");
+ }
+ else if (dto.ConflictStrategy == ConflictStrategy.Rename)
+ {
+ resolvedName = $"{snapshot.Name} (Restored)";
+ }
+ else if (dto.ConflictStrategy == ConflictStrategy.AppendSuffix)
+ {
+ resolvedName = $"{snapshot.Name} - {DateTime.UtcNow:yyyyMMdd-HHmmss}";
+ }
+ }
+
+ // For InPlace mode, unarchive existing board if it's archived
+ if (dto.RestoreMode == RestoreMode.InPlace)
+ {
+ var existingBoard = await _unitOfWork.Boards.GetByIdAsync(archiveItem.EntityId, cancellationToken);
+ if (existingBoard != null && existingBoard.IsArchived)
+ {
+ existingBoard.Unarchive();
+ existingBoard.Update(resolvedName, snapshot.Description);
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(new RestoreResult(
+ true,
+ existingBoard.Id,
+ null,
+ resolvedName));
+ }
+ }
+
+ // Create new board (Copy mode or InPlace when original doesn't exist)
+ var newBoard = new Board(resolvedName, snapshot.Description, restoredByUserId);
+ await _unitOfWork.Boards.AddAsync(newBoard, cancellationToken);
+
+ return Result.Success(new RestoreResult(
+ true,
+ newBoard.Id,
+ null,
+ resolvedName));
+ }
+ catch (JsonException ex)
+ {
+ return Result.Failure(
+ ErrorCodes.ValidationError,
+ $"Invalid snapshot format: {ex.Message}");
+ }
+ }
+
+ private async Task> RestoreColumnAsync(
+ ArchiveItem archiveItem,
+ Guid targetBoardId,
+ RestoreArchiveItemDto dto,
+ Guid restoredByUserId,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ // Deserialize snapshot
+ var snapshot = JsonSerializer.Deserialize(archiveItem.SnapshotJson);
+ if (snapshot == null)
+ return Result.Failure(
+ ErrorCodes.ValidationError,
+ "Failed to deserialize column snapshot");
+
+ // Get board and existing columns
+ var board = await _unitOfWork.Boards.GetByIdWithDetailsAsync(targetBoardId, cancellationToken);
+ if (board == null)
+ return Result.Failure(ErrorCodes.NotFound, $"Board with ID {targetBoardId} not found");
+
+ // Check for naming conflicts
+ var conflictExists = board.Columns.Any(c => c.Name == snapshot.Name);
+
+ string resolvedName = snapshot.Name;
+ if (conflictExists)
+ {
+ if (dto.ConflictStrategy == ConflictStrategy.Fail)
+ {
+ return Result.Failure(
+ ErrorCodes.Conflict,
+ $"A column with name '{snapshot.Name}' already exists");
+ }
+ else if (dto.ConflictStrategy == ConflictStrategy.Rename)
+ {
+ resolvedName = $"{snapshot.Name} (Restored)";
+ }
+ else if (dto.ConflictStrategy == ConflictStrategy.AppendSuffix)
+ {
+ resolvedName = $"{snapshot.Name} - {DateTime.UtcNow:yyyyMMdd-HHmmss}";
+ }
+ }
+
+ // Determine position (add to end)
+ var maxPosition = board.Columns.Any() ? board.Columns.Max(c => c.Position) : -1;
+ var newPosition = maxPosition + 1;
+
+ // Create new column
+ var newColumn = new Column(targetBoardId, resolvedName, newPosition, snapshot.WipLimit);
+ await _unitOfWork.Columns.AddAsync(newColumn, cancellationToken);
+
+ return Result.Success(new RestoreResult(
+ true,
+ newColumn.Id,
+ null,
+ resolvedName));
+ }
+ catch (JsonException ex)
+ {
+ return Result.Failure(
+ ErrorCodes.ValidationError,
+ $"Invalid snapshot format: {ex.Message}");
+ }
+ }
+
+ private async Task> RestoreCardAsync(
+ ArchiveItem archiveItem,
+ Guid targetBoardId,
+ RestoreArchiveItemDto dto,
+ Guid restoredByUserId,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ // Deserialize snapshot
+ var snapshot = JsonSerializer.Deserialize(archiveItem.SnapshotJson);
+ if (snapshot == null)
+ return Result.Failure(
+ ErrorCodes.ValidationError,
+ "Failed to deserialize card snapshot");
+
+ // Get board with details
+ var board = await _unitOfWork.Boards.GetByIdWithDetailsAsync(targetBoardId, cancellationToken);
+ if (board == null)
+ return Result.Failure(ErrorCodes.NotFound, $"Board with ID {targetBoardId} not found");
+
+ // Find target column
+ Column? targetColumn = null;
+ if (snapshot.ColumnId != Guid.Empty)
+ {
+ targetColumn = board.Columns.FirstOrDefault(c => c.Id == snapshot.ColumnId);
+ }
+
+ // If original column doesn't exist, use first available column
+ if (targetColumn == null)
+ {
+ targetColumn = board.Columns.OrderBy(c => c.Position).FirstOrDefault();
+ if (targetColumn == null)
+ return Result.Failure(
+ ErrorCodes.InvalidOperation,
+ "Target board has no columns to restore card to");
+ }
+
+ // Get column with cards to check WIP limit and position
+ var columnWithCards = await _unitOfWork.Columns.GetByIdWithCardsAsync(targetColumn.Id, cancellationToken);
+ if (columnWithCards == null)
+ return Result.Failure(ErrorCodes.NotFound, $"Column with ID {targetColumn.Id} not found");
+
+ // Check WIP limit
+ if (columnWithCards.WouldExceedWipLimitIfAdded())
+ return Result.Failure(
+ ErrorCodes.WipLimitExceeded,
+ $"Cannot restore card, column '{columnWithCards.Name}' has reached its WIP limit");
+
+ // Check for title conflicts
+ var existingCards = columnWithCards.Cards.ToList();
+ var conflictExists = existingCards.Any(c => c.Title == snapshot.Title);
+
+ string resolvedTitle = snapshot.Title;
+ if (conflictExists)
+ {
+ if (dto.ConflictStrategy == ConflictStrategy.Fail)
+ {
+ return Result.Failure(
+ ErrorCodes.Conflict,
+ $"A card with title '{snapshot.Title}' already exists in the target column");
+ }
+ else if (dto.ConflictStrategy == ConflictStrategy.Rename)
+ {
+ resolvedTitle = $"{snapshot.Title} (Restored)";
+ }
+ else if (dto.ConflictStrategy == ConflictStrategy.AppendSuffix)
+ {
+ resolvedTitle = $"{snapshot.Title} - {DateTime.UtcNow:yyyyMMdd-HHmmss}";
+ }
+ }
+
+ // Determine position (add to bottom)
+ var maxPosition = existingCards.Any() ? existingCards.Max(c => c.Position) : -1;
+ var newPosition = maxPosition + 1;
+
+ // Create new card
+ var newCard = new Card(
+ targetBoardId,
+ columnWithCards.Id,
+ resolvedTitle,
+ snapshot.Description,
+ snapshot.DueDate,
+ newPosition);
+
+ if (snapshot.IsBlocked && !string.IsNullOrEmpty(snapshot.BlockReason))
+ {
+ newCard.Block(snapshot.BlockReason);
+ }
+
+ await _unitOfWork.Cards.AddAsync(newCard, cancellationToken);
+
+ return Result.Success(new RestoreResult(
+ true,
+ newCard.Id,
+ null,
+ resolvedTitle));
+ }
+ catch (JsonException ex)
+ {
+ return Result.Failure(
+ ErrorCodes.ValidationError,
+ $"Invalid snapshot format: {ex.Message}");
+ }
+ }
+
+ private static ArchiveItemDto MapToDto(ArchiveItem item)
+ {
+ return new ArchiveItemDto(
+ item.Id,
+ item.EntityType,
+ item.EntityId,
+ item.BoardId,
+ item.Name,
+ item.ArchivedByUserId,
+ item.ArchivedAt,
+ item.Reason,
+ item.RestoreStatus,
+ item.RestoredAt,
+ item.RestoredByUserId,
+ item.CreatedAt,
+ item.UpdatedAt);
+ }
+}
+
+// Snapshot DTOs for deserialization
+internal record BoardSnapshot(string Name, string? Description);
+internal record ColumnSnapshot(string Name, int Position, int? WipLimit);
+internal record CardSnapshot(
+ string Title,
+ string? Description,
+ DateTimeOffset? DueDate,
+ bool IsBlocked,
+ string? BlockReason,
+ Guid ColumnId);
diff --git a/backend/src/Taskdeck.Application/Services/AutomationExecutorService.cs b/backend/src/Taskdeck.Application/Services/AutomationExecutorService.cs
new file mode 100644
index 000000000..31118e98a
--- /dev/null
+++ b/backend/src/Taskdeck.Application/Services/AutomationExecutorService.cs
@@ -0,0 +1,361 @@
+using System.Text.Json;
+using Taskdeck.Application.DTOs;
+using Taskdeck.Application.Interfaces;
+using Taskdeck.Domain.Common;
+using Taskdeck.Domain.Entities;
+using Taskdeck.Domain.Enums;
+using Taskdeck.Domain.Exceptions;
+
+namespace Taskdeck.Application.Services;
+
+public class AutomationExecutorService : IAutomationExecutorService
+{
+ private readonly IUnitOfWork _unitOfWork;
+ private readonly IAutomationProposalService _proposalService;
+ private readonly IAutomationPolicyEngine _policyEngine;
+ private readonly CardService _cardService;
+ private readonly BoardService _boardService;
+ private readonly ColumnService _columnService;
+
+ public AutomationExecutorService(
+ IUnitOfWork unitOfWork,
+ IAutomationProposalService proposalService,
+ IAutomationPolicyEngine policyEngine,
+ CardService cardService,
+ BoardService boardService,
+ ColumnService columnService)
+ {
+ _unitOfWork = unitOfWork;
+ _proposalService = proposalService;
+ _policyEngine = policyEngine;
+ _cardService = cardService;
+ _boardService = boardService;
+ _columnService = columnService;
+ }
+
+ public async Task ExecuteProposalAsync(Guid proposalId, string idempotencyKey, CancellationToken cancellationToken = default)
+ {
+ if (proposalId == Guid.Empty)
+ return Result.Failure(ErrorCodes.ValidationError, "ProposalId cannot be empty");
+
+ if (string.IsNullOrWhiteSpace(idempotencyKey))
+ return Result.Failure(ErrorCodes.ValidationError, "IdempotencyKey cannot be empty");
+
+ // Get proposal
+ var proposalResult = await _proposalService.GetProposalByIdAsync(proposalId, cancellationToken);
+ if (!proposalResult.IsSuccess)
+ return Result.Failure(proposalResult.ErrorCode, proposalResult.ErrorMessage);
+
+ var proposal = proposalResult.Value;
+
+ // Idempotent behavior across requests/processes: already-applied proposals are treated as success.
+ if (proposal.Status == ProposalStatus.Applied)
+ return Result.Success();
+
+ // Verify proposal is approved
+ if (proposal.Status != ProposalStatus.Approved)
+ return Result.Failure(ErrorCodes.InvalidOperation, $"Cannot execute proposal in status {proposal.Status}");
+
+ // Revalidate policy before execution
+ var policyResult = _policyEngine.ValidatePolicy(proposal);
+ if (!policyResult.IsSuccess)
+ return Result.Failure(policyResult.ErrorCode, policyResult.ErrorMessage);
+
+ // Revalidate permissions
+ var permissionResult = await _policyEngine.ValidatePermissionsAsync(
+ proposal.RequestedByUserId,
+ proposal.BoardId,
+ proposal.Operations,
+ cancellationToken);
+ if (!permissionResult.IsSuccess)
+ return Result.Failure(permissionResult.ErrorCode, permissionResult.ErrorMessage);
+
+ try
+ {
+ await _unitOfWork.BeginTransactionAsync(cancellationToken);
+
+ // Execute operations in sequence order
+ var orderedOperations = proposal.Operations.OrderBy(o => o.Sequence).ToList();
+ var failedOperation = -1;
+ var failureReason = "";
+
+ foreach (var operation in orderedOperations)
+ {
+ var executionResult = await ExecuteOperationAsync(operation, proposal.RequestedByUserId, cancellationToken);
+ if (!executionResult.IsSuccess)
+ {
+ failedOperation = operation.Sequence;
+ failureReason = $"Operation {operation.Sequence} failed: {executionResult.ErrorMessage}";
+ break;
+ }
+
+ // Create audit log for the operation
+ await CreateAuditLogAsync(operation, proposal, cancellationToken);
+ }
+
+ if (failedOperation >= 0)
+ {
+ // Mark proposal as failed and rollback transaction
+ await _unitOfWork.RollbackTransactionAsync(cancellationToken);
+
+ // Update proposal status
+ var updateResult = await UpdateProposalStatusAsync(proposalId, ProposalStatus.Failed, failureReason, cancellationToken);
+ if (!updateResult.IsSuccess)
+ return Result.Failure(updateResult.ErrorCode, updateResult.ErrorMessage);
+
+ return Result.Failure(ErrorCodes.UnexpectedError, failureReason);
+ }
+
+ // Mark proposal as applied
+ await _unitOfWork.CommitTransactionAsync(cancellationToken);
+
+ var markResult = await UpdateProposalStatusAsync(proposalId, ProposalStatus.Applied, null, cancellationToken);
+ if (!markResult.IsSuccess)
+ return Result.Failure(markResult.ErrorCode, markResult.ErrorMessage);
+
+ return Result.Success();
+ }
+ catch (Exception ex)
+ {
+ await _unitOfWork.RollbackTransactionAsync(cancellationToken);
+ await UpdateProposalStatusAsync(proposalId, ProposalStatus.Failed, ex.Message, cancellationToken);
+ return Result.Failure(ErrorCodes.UnexpectedError, $"Failed to execute proposal: {ex.Message}");
+ }
+ }
+
+ private async Task ExecuteOperationAsync(ProposalOperationDto operation, Guid userId, CancellationToken cancellationToken)
+ {
+ var actionType = operation.ActionType.ToLowerInvariant();
+ var targetType = operation.TargetType.ToLowerInvariant();
+
+ try
+ {
+ if (targetType == "card")
+ {
+ return await ExecuteCardOperationAsync(actionType, operation, cancellationToken);
+ }
+ else if (targetType == "board")
+ {
+ return await ExecuteBoardOperationAsync(actionType, operation, cancellationToken);
+ }
+ else if (targetType == "column")
+ {
+ return await ExecuteColumnOperationAsync(actionType, operation, cancellationToken);
+ }
+ else
+ {
+ return Result.Failure(ErrorCodes.ValidationError, $"Unsupported target type: {targetType}");
+ }
+ }
+ catch (Exception ex)
+ {
+ return Result.Failure(ErrorCodes.UnexpectedError, $"Operation execution failed: {ex.Message}");
+ }
+ }
+
+ private async Task ExecuteCardOperationAsync(string actionType, ProposalOperationDto operation, CancellationToken cancellationToken)
+ {
+ var parameters = JsonSerializer.Deserialize(operation.Parameters);
+
+ switch (actionType)
+ {
+ case "create":
+ return await CreateCardAsync(parameters, cancellationToken);
+
+ case "update":
+ return await UpdateCardAsync(parameters, cancellationToken);
+
+ case "move":
+ return await MoveCardAsync(parameters, cancellationToken);
+
+ case "archive":
+ return await ArchiveCardAsync(parameters, cancellationToken);
+
+ default:
+ return Result.Failure(ErrorCodes.ValidationError, $"Unsupported card action: {actionType}");
+ }
+ }
+
+ private async Task CreateCardAsync(JsonElement parameters, CancellationToken cancellationToken)
+ {
+ var title = parameters.GetProperty("title").GetString();
+ var description = parameters.TryGetProperty("description", out var descProp) ? descProp.GetString() : null;
+ var columnIdStr = parameters.GetProperty("columnId").GetString();
+ var boardIdStr = parameters.GetProperty("boardId").GetString();
+
+ if (!Guid.TryParse(columnIdStr, out var columnId))
+ return Result.Failure(ErrorCodes.ValidationError, "Invalid columnId");
+
+ if (!Guid.TryParse(boardIdStr, out var boardId))
+ return Result.Failure(ErrorCodes.ValidationError, "Invalid boardId");
+
+ var dto = new CreateCardDto(boardId, columnId, title!, description, null, null);
+ var result = await _cardService.CreateCardAsync(dto, cancellationToken);
+
+ return result.IsSuccess ? Result.Success() : Result.Failure(result.ErrorCode, result.ErrorMessage);
+ }
+
+ private async Task UpdateCardAsync(JsonElement parameters, CancellationToken cancellationToken)
+ {
+ var cardIdStr = parameters.GetProperty("cardId").GetString();
+ if (!Guid.TryParse(cardIdStr, out var cardId))
+ return Result.Failure(ErrorCodes.ValidationError, "Invalid cardId");
+
+ var title = parameters.TryGetProperty("title", out var titleProp) ? titleProp.GetString() : null;
+ var description = parameters.TryGetProperty("description", out var descProp) ? descProp.GetString() : null;
+
+ var dto = new UpdateCardDto(title, description, null, null, null, null);
+ var result = await _cardService.UpdateCardAsync(cardId, dto, cancellationToken);
+
+ return result.IsSuccess ? Result.Success() : Result.Failure(result.ErrorCode, result.ErrorMessage);
+ }
+
+ private async Task MoveCardAsync(JsonElement parameters, CancellationToken cancellationToken)
+ {
+ var cardIdStr = parameters.GetProperty("cardId").GetString();
+ var columnIdStr = parameters.GetProperty("columnId").GetString();
+
+ if (!Guid.TryParse(cardIdStr, out var cardId))
+ return Result.Failure(ErrorCodes.ValidationError, "Invalid cardId");
+
+ if (!Guid.TryParse(columnIdStr, out var columnId))
+ return Result.Failure(ErrorCodes.ValidationError, "Invalid columnId");
+
+ // Get current cards in target column to determine position
+ var targetColumn = await _unitOfWork.Columns.GetByIdWithCardsAsync(columnId, cancellationToken);
+ if (targetColumn == null)
+ return Result.Failure(ErrorCodes.NotFound, $"Column {columnId} not found");
+
+ var position = targetColumn.Cards.Any() ? targetColumn.Cards.Max(c => c.Position) + 1 : 0;
+ var dto = new MoveCardDto(columnId, position);
+ var result = await _cardService.MoveCardAsync(cardId, dto, cancellationToken);
+
+ return result.IsSuccess ? Result.Success() : Result.Failure(result.ErrorCode, result.ErrorMessage);
+ }
+
+ private async Task ArchiveCardAsync(JsonElement parameters, CancellationToken cancellationToken)
+ {
+ var cardIdStr = parameters.GetProperty("cardId").GetString();
+ if (!Guid.TryParse(cardIdStr, out var cardId))
+ return Result.Failure(ErrorCodes.ValidationError, "Invalid cardId");
+
+ var dto = new UpdateCardDto(null, null, null, true, null, null);
+ var result = await _cardService.UpdateCardAsync(cardId, dto, cancellationToken);
+
+ return result.IsSuccess ? Result.Success() : Result.Failure(result.ErrorCode, result.ErrorMessage);
+ }
+
+ private async Task ExecuteBoardOperationAsync(string actionType, ProposalOperationDto operation, CancellationToken cancellationToken)
+ {
+ var parameters = JsonSerializer.Deserialize(operation.Parameters);
+
+ switch (actionType)
+ {
+ case "update":
+ return await UpdateBoardAsync(parameters, cancellationToken);
+
+ default:
+ return Result.Failure(ErrorCodes.ValidationError, $"Unsupported board action: {actionType}");
+ }
+ }
+
+ private async Task UpdateBoardAsync(JsonElement parameters, CancellationToken cancellationToken)
+ {
+ var boardIdStr = parameters.GetProperty("boardId").GetString();
+ if (!Guid.TryParse(boardIdStr, out var boardId))
+ return Result.Failure(ErrorCodes.ValidationError, "Invalid boardId");
+
+ var name = parameters.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null;
+ var description = parameters.TryGetProperty("description", out var descProp) ? descProp.GetString() : null;
+
+ var dto = new UpdateBoardDto(name, description, null);
+ var result = await _boardService.UpdateBoardAsync(boardId, dto, cancellationToken);
+
+ return result.IsSuccess ? Result.Success() : Result.Failure(result.ErrorCode, result.ErrorMessage);
+ }
+
+ private async Task ExecuteColumnOperationAsync(string actionType, ProposalOperationDto operation, CancellationToken cancellationToken)
+ {
+ var parameters = JsonSerializer.Deserialize(operation.Parameters);
+
+ switch (actionType)
+ {
+ case "reorder":
+ return await ReorderColumnAsync(parameters, cancellationToken);
+
+ default:
+ return Result.Failure(ErrorCodes.ValidationError, $"Unsupported column action: {actionType}");
+ }
+ }
+
+ private async Task ReorderColumnAsync(JsonElement parameters, CancellationToken cancellationToken)
+ {
+ var columnIdStr = parameters.GetProperty("columnId").GetString();
+ var newPosition = parameters.GetProperty("position").GetInt32();
+
+ if (!Guid.TryParse(columnIdStr, out var columnId))
+ return Result.Failure(ErrorCodes.ValidationError, "Invalid columnId");
+
+ var dto = new UpdateColumnDto(null, newPosition, null);
+ var result = await _columnService.UpdateColumnAsync(columnId, dto, cancellationToken);
+
+ return result.IsSuccess ? Result.Success() : Result.Failure(result.ErrorCode, result.ErrorMessage);
+ }
+
+ private async Task CreateAuditLogAsync(ProposalOperationDto operation, ProposalDto proposal, CancellationToken cancellationToken)
+ {
+ var actionMap = new Dictionary
+ {
+ { "create", AuditAction.Created },
+ { "update", AuditAction.Updated },
+ { "archive", AuditAction.Archived },
+ { "move", AuditAction.Moved }
+ };
+
+ var auditAction = actionMap.ContainsKey(operation.ActionType.ToLowerInvariant())
+ ? actionMap[operation.ActionType.ToLowerInvariant()]
+ : AuditAction.Updated;
+
+ var entityId = !string.IsNullOrEmpty(operation.TargetId) && Guid.TryParse(operation.TargetId, out var id)
+ ? id
+ : Guid.NewGuid(); // For creates, we'd need to capture the created ID
+
+ var changes = $"Automation Proposal {proposal.Id}: {operation.ActionType} {operation.TargetType}. Parameters: {operation.Parameters}";
+
+ var auditLog = new AuditLog(
+ operation.TargetType,
+ entityId,
+ auditAction,
+ proposal.RequestedByUserId,
+ changes
+ );
+
+ await _unitOfWork.AuditLogs.AddAsync(auditLog, cancellationToken);
+ }
+
+ private async Task UpdateProposalStatusAsync(Guid proposalId, ProposalStatus status, string? failureReason, CancellationToken cancellationToken)
+ {
+ var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(proposalId, cancellationToken);
+ if (proposal == null)
+ return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {proposalId} not found");
+
+ try
+ {
+ if (status == ProposalStatus.Applied)
+ {
+ proposal.MarkAsApplied();
+ }
+ else if (status == ProposalStatus.Failed)
+ {
+ proposal.MarkAsFailed(failureReason ?? "Unknown error");
+ }
+
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+ return Result.Success();
+ }
+ catch (DomainException ex)
+ {
+ return Result.Failure(ex.ErrorCode, ex.Message);
+ }
+ }
+}
diff --git a/backend/src/Taskdeck.Application/Services/AutomationPlannerService.cs b/backend/src/Taskdeck.Application/Services/AutomationPlannerService.cs
new file mode 100644
index 000000000..30fe0e38a
--- /dev/null
+++ b/backend/src/Taskdeck.Application/Services/AutomationPlannerService.cs
@@ -0,0 +1,252 @@
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using Taskdeck.Application.DTOs;
+using Taskdeck.Application.Interfaces;
+using Taskdeck.Domain.Common;
+using Taskdeck.Domain.Entities;
+using Taskdeck.Domain.Exceptions;
+
+namespace Taskdeck.Application.Services;
+
+public class AutomationPlannerService : IAutomationPlannerService
+{
+ private readonly IAutomationProposalService _proposalService;
+ private readonly IAutomationPolicyEngine _policyEngine;
+ private readonly IUnitOfWork _unitOfWork;
+
+ public AutomationPlannerService(
+ IAutomationProposalService proposalService,
+ IAutomationPolicyEngine policyEngine,
+ IUnitOfWork unitOfWork)
+ {
+ _proposalService = proposalService;
+ _policyEngine = policyEngine;
+ _unitOfWork = unitOfWork;
+ }
+
+ public async Task> ParseInstructionAsync(string instruction, Guid userId, Guid? boardId = null, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(instruction))
+ return Result.Failure(ErrorCodes.ValidationError, "Instruction cannot be empty");
+
+ if (userId == Guid.Empty)
+ return Result.Failure(ErrorCodes.ValidationError, "UserId cannot be empty");
+
+ try
+ {
+ var operations = new List();
+ var instructionLower = instruction.ToLowerInvariant();
+ var sequence = 0;
+
+ // Pattern: "create card 'title' in column 'column name'" or "create card 'title'"
+ var createCardMatch = Regex.Match(instruction, @"create card ['""]([^'""]+)['""](?:\s+in column ['""]([^'""]+)['""])?(?:\s+with description ['""]([^'""]+)['""])?", RegexOptions.IgnoreCase);
+ if (createCardMatch.Success)
+ {
+ var title = createCardMatch.Groups[1].Value;
+ var columnName = createCardMatch.Groups.Count > 2 ? createCardMatch.Groups[2].Value : null;
+ var description = createCardMatch.Groups.Count > 3 ? createCardMatch.Groups[3].Value : null;
+
+ if (!boardId.HasValue)
+ return Result.Failure(ErrorCodes.ValidationError, "Board ID is required for card operations");
+
+ // Find column ID if column name is specified
+ Guid? columnId = null;
+ if (!string.IsNullOrEmpty(columnName))
+ {
+ var columns = await _unitOfWork.Columns.GetByBoardIdAsync(boardId.Value, cancellationToken);
+ var column = columns.FirstOrDefault(c => c.Name.Equals(columnName, StringComparison.OrdinalIgnoreCase));
+ if (column == null)
+ return Result.Failure(ErrorCodes.NotFound, $"Column '{columnName}' not found in board");
+
+ columnId = column.Id;
+ }
+ else
+ {
+ // Use first column as default
+ var columns = await _unitOfWork.Columns.GetByBoardIdAsync(boardId.Value, cancellationToken);
+ var firstColumn = columns.OrderBy(c => c.Position).FirstOrDefault();
+ if (firstColumn == null)
+ return Result.Failure(ErrorCodes.NotFound, "No columns found in board");
+
+ columnId = firstColumn.Id;
+ }
+
+ var parameters = JsonSerializer.Serialize(new
+ {
+ title,
+ description,
+ columnId,
+ boardId
+ });
+
+ operations.Add(new CreateProposalOperationDto(
+ sequence++,
+ "create",
+ "card",
+ parameters,
+ Guid.NewGuid().ToString()
+ ));
+ }
+
+ // Pattern: "move card {id} to column 'column name'"
+ var moveCardMatch = Regex.Match(instruction, @"move card ([a-f0-9-]+) to column ['""]([^'""]+)['""]", RegexOptions.IgnoreCase);
+ if (moveCardMatch.Success)
+ {
+ var cardIdStr = moveCardMatch.Groups[1].Value;
+ var columnName = moveCardMatch.Groups[2].Value;
+
+ if (!Guid.TryParse(cardIdStr, out var cardId))
+ return Result.Failure(ErrorCodes.ValidationError, $"Invalid card ID: {cardIdStr}");
+
+ if (!boardId.HasValue)
+ return Result.Failure(ErrorCodes.ValidationError, "Board ID is required for card operations");
+
+ // Find column
+ var columns = await _unitOfWork.Columns.GetByBoardIdAsync(boardId.Value, cancellationToken);
+ var column = columns.FirstOrDefault(c => c.Name.Equals(columnName, StringComparison.OrdinalIgnoreCase));
+ if (column == null)
+ return Result.Failure(ErrorCodes.NotFound, $"Column '{columnName}' not found in board");
+
+ var parameters = JsonSerializer.Serialize(new
+ {
+ cardId,
+ columnId = column.Id
+ });
+
+ operations.Add(new CreateProposalOperationDto(
+ sequence++,
+ "move",
+ "card",
+ parameters,
+ Guid.NewGuid().ToString(),
+ TargetId: cardId.ToString()
+ ));
+ }
+
+ // Pattern: "archive card {id}" or "archive cards matching 'pattern'"
+ var archiveCardMatch = Regex.Match(instruction, @"archive card ([a-f0-9-]+)", RegexOptions.IgnoreCase);
+ if (archiveCardMatch.Success)
+ {
+ var cardIdStr = archiveCardMatch.Groups[1].Value;
+ if (!Guid.TryParse(cardIdStr, out var cardId))
+ return Result.Failure(ErrorCodes.ValidationError, $"Invalid card ID: {cardIdStr}");
+
+ var parameters = JsonSerializer.Serialize(new { cardId });
+
+ operations.Add(new CreateProposalOperationDto(
+ sequence++,
+ "archive",
+ "card",
+ parameters,
+ Guid.NewGuid().ToString(),
+ TargetId: cardId.ToString()
+ ));
+ }
+
+ // Pattern: "archive cards matching 'pattern'"
+ var archiveCardsMatch = Regex.Match(instruction, @"archive cards matching ['""]([^'""]+)['""]", RegexOptions.IgnoreCase);
+ if (archiveCardsMatch.Success)
+ {
+ var pattern = archiveCardsMatch.Groups[1].Value;
+
+ if (!boardId.HasValue)
+ return Result.Failure(ErrorCodes.ValidationError, "Board ID is required for card operations");
+
+ // Find matching cards
+ var cards = await _unitOfWork.Cards.GetByBoardIdAsync(boardId.Value, cancellationToken);
+ var matchingCards = cards.Where(c =>
+ c.Title.Contains(pattern, StringComparison.OrdinalIgnoreCase)).ToList();
+
+ if (!matchingCards.Any())
+ return Result.Failure(ErrorCodes.NotFound, $"No cards matching '{pattern}' found");
+
+ foreach (var card in matchingCards)
+ {
+ var parameters = JsonSerializer.Serialize(new { cardId = card.Id });
+ operations.Add(new CreateProposalOperationDto(
+ sequence++,
+ "archive",
+ "card",
+ parameters,
+ Guid.NewGuid().ToString(),
+ TargetId: card.Id.ToString()
+ ));
+ }
+ }
+
+ // Pattern: "update card {id} title 'new title'" or "update card {id} description 'new desc'"
+ var updateCardMatch = Regex.Match(instruction, @"update card ([a-f0-9-]+)\s+(title|description) ['""]([^'""]+)['""]", RegexOptions.IgnoreCase);
+ if (updateCardMatch.Success)
+ {
+ var cardIdStr = updateCardMatch.Groups[1].Value;
+ var field = updateCardMatch.Groups[2].Value.ToLower();
+ var value = updateCardMatch.Groups[3].Value;
+
+ if (!Guid.TryParse(cardIdStr, out var cardId))
+ return Result.Failure(ErrorCodes.ValidationError, $"Invalid card ID: {cardIdStr}");
+
+ var parameters = field == "title"
+ ? JsonSerializer.Serialize(new { cardId, title = value })
+ : JsonSerializer.Serialize(new { cardId, description = value });
+
+ operations.Add(new CreateProposalOperationDto(
+ sequence++,
+ "update",
+ "card",
+ parameters,
+ Guid.NewGuid().ToString(),
+ TargetId: cardId.ToString()
+ ));
+ }
+
+ if (!operations.Any())
+ return Result.Failure(ErrorCodes.ValidationError,
+ "Could not parse instruction. Supported patterns: 'create card \"title\"', 'move card {id} to column \"name\"', 'archive card {id}', 'archive cards matching \"pattern\"', 'update card {id} title/description \"value\"'");
+
+ // Classify risk
+ var operationDtos = operations.Select(o => new ProposalOperationDto(
+ Guid.NewGuid(),
+ Guid.Empty,
+ o.Sequence,
+ o.ActionType,
+ o.TargetType,
+ o.TargetId,
+ o.Parameters,
+ o.IdempotencyKey,
+ o.ExpectedVersion
+ )).ToList();
+
+ var riskLevel = _policyEngine.ClassifyRisk(operationDtos);
+
+ // Create proposal
+ var createDto = new CreateProposalDto(
+ ProposalSourceType.Manual,
+ userId,
+ instruction.Length > 500 ? instruction.Substring(0, 497) + "..." : instruction,
+ riskLevel,
+ Guid.NewGuid().ToString(),
+ boardId,
+ null,
+ 1440,
+ operations
+ );
+
+ var result = await _proposalService.CreateProposalAsync(createDto, cancellationToken);
+ if (!result.IsSuccess)
+ return Result.Failure(result.ErrorCode, result.ErrorMessage);
+
+ // Validate permissions
+ var permissionResult = await _policyEngine.ValidatePermissionsAsync(userId, boardId, operationDtos, cancellationToken);
+ if (!permissionResult.IsSuccess)
+ {
+ return Result.Failure(permissionResult.ErrorCode, permissionResult.ErrorMessage);
+ }
+
+ return Result.Success(result.Value);
+ }
+ catch (Exception ex)
+ {
+ return Result.Failure(ErrorCodes.UnexpectedError, $"Failed to parse instruction: {ex.Message}");
+ }
+ }
+}
diff --git a/backend/src/Taskdeck.Application/Services/AutomationPolicyEngine.cs b/backend/src/Taskdeck.Application/Services/AutomationPolicyEngine.cs
new file mode 100644
index 000000000..ac9f87343
--- /dev/null
+++ b/backend/src/Taskdeck.Application/Services/AutomationPolicyEngine.cs
@@ -0,0 +1,133 @@
+using Taskdeck.Application.DTOs;
+using Taskdeck.Application.Interfaces;
+using Taskdeck.Domain.Common;
+using Taskdeck.Domain.Entities;
+using Taskdeck.Domain.Exceptions;
+
+namespace Taskdeck.Application.Services;
+
+public class AutomationPolicyEngine : IAutomationPolicyEngine
+{
+ private readonly IUnitOfWork _unitOfWork;
+ private const int MaxOperationCount = 50;
+ private const int MaxParametersLength = 10000;
+
+ public AutomationPolicyEngine(IUnitOfWork unitOfWork)
+ {
+ _unitOfWork = unitOfWork;
+ }
+
+ public RiskLevel ClassifyRisk(IEnumerable operations)
+ {
+ var opList = operations.ToList();
+
+ if (!opList.Any())
+ return RiskLevel.Low;
+
+ var hasDelete = opList.Any(o => o.ActionType.Contains("delete", StringComparison.OrdinalIgnoreCase));
+ var hasArchive = opList.Any(o => o.ActionType.Contains("archive", StringComparison.OrdinalIgnoreCase));
+ var hasUpdate = opList.Any(o => o.ActionType.Contains("update", StringComparison.OrdinalIgnoreCase));
+ var hasBoardOperation = opList.Any(o => o.TargetType.Equals("board", StringComparison.OrdinalIgnoreCase));
+ var operationCount = opList.Count;
+
+ // Critical: Delete board or many operations
+ if (hasBoardOperation && hasDelete)
+ return RiskLevel.Critical;
+
+ if (operationCount > 20)
+ return RiskLevel.Critical;
+
+ // High: Delete operations, board updates, or many operations
+ if (hasDelete || (hasBoardOperation && hasUpdate))
+ return RiskLevel.High;
+
+ if (operationCount > 10)
+ return RiskLevel.High;
+
+ // Medium: Archive operations or moderate operation count
+ if (hasArchive)
+ return RiskLevel.Medium;
+
+ if (operationCount > 5)
+ return RiskLevel.Medium;
+
+ // Low: Simple creates and updates with few operations
+ return RiskLevel.Low;
+ }
+
+ public async Task ValidatePermissionsAsync(Guid userId, Guid? boardId, IEnumerable operations, CancellationToken cancellationToken = default)
+ {
+ if (userId == Guid.Empty)
+ return Result.Failure(ErrorCodes.ValidationError, "UserId cannot be empty");
+
+ // Verify user exists
+ var user = await _unitOfWork.Users.GetByIdAsync(userId, cancellationToken);
+ if (user == null)
+ return Result.Failure(ErrorCodes.NotFound, $"User with ID {userId} not found");
+
+ var opList = operations.ToList();
+ if (!opList.Any())
+ return Result.Success();
+
+ // If board-scoped, verify board exists and user has access
+ if (boardId.HasValue)
+ {
+ var board = await _unitOfWork.Boards.GetByIdAsync(boardId.Value, cancellationToken);
+ if (board == null)
+ return Result.Failure(ErrorCodes.NotFound, $"Board with ID {boardId} not found");
+
+ var hasAccess = await _unitOfWork.BoardAccesses.HasAccessAsync(boardId.Value, userId, null, cancellationToken);
+ if (!hasAccess)
+ return Result.Failure(ErrorCodes.Forbidden, $"User does not have access to board {boardId}");
+ }
+
+ // Validate each operation targets entities within the board scope
+ foreach (var operation in opList)
+ {
+ if (boardId.HasValue && operation.TargetType.Equals("card", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(operation.TargetId))
+ {
+ if (Guid.TryParse(operation.TargetId, out var cardId))
+ {
+ var card = await _unitOfWork.Cards.GetByIdAsync(cardId, cancellationToken);
+ if (card != null && card.BoardId != boardId.Value)
+ return Result.Failure(ErrorCodes.Forbidden, $"Card {cardId} does not belong to board {boardId}");
+ }
+ }
+ }
+
+ return Result.Success();
+ }
+
+ public Result ValidatePolicy(ProposalDto proposal)
+ {
+ if (proposal == null)
+ return Result.Failure(ErrorCodes.ValidationError, "Proposal cannot be null");
+
+ if (proposal.Operations == null || !proposal.Operations.Any())
+ return Result.Failure(ErrorCodes.ValidationError, "Proposal must contain at least one operation");
+
+ if (proposal.Operations.Count > MaxOperationCount)
+ return Result.Failure(ErrorCodes.ValidationError, $"Proposal exceeds maximum operation count of {MaxOperationCount}");
+
+ // Validate operation sequences are unique and non-negative
+ var sequences = proposal.Operations.Select(o => o.Sequence).ToList();
+ if (sequences.Distinct().Count() != sequences.Count)
+ return Result.Failure(ErrorCodes.ValidationError, "Operation sequences must be unique");
+
+ if (sequences.Any(s => s < 0))
+ return Result.Failure(ErrorCodes.ValidationError, "Operation sequences must be non-negative");
+
+ // Validate parameters size
+ foreach (var operation in proposal.Operations)
+ {
+ if (operation.Parameters.Length > MaxParametersLength)
+ return Result.Failure(ErrorCodes.ValidationError, $"Operation parameters exceed maximum length of {MaxParametersLength}");
+ }
+
+ // Validate proposal hasn't expired
+ if (DateTime.UtcNow > proposal.ExpiresAt)
+ return Result.Failure(ErrorCodes.ValidationError, "Proposal has expired");
+
+ return Result.Success();
+ }
+}
diff --git a/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs b/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs
new file mode 100644
index 000000000..788942a9f
--- /dev/null
+++ b/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs
@@ -0,0 +1,284 @@
+using Taskdeck.Application.DTOs;
+using Taskdeck.Application.Interfaces;
+using Taskdeck.Domain.Common;
+using Taskdeck.Domain.Entities;
+using Taskdeck.Domain.Exceptions;
+
+namespace Taskdeck.Application.Services;
+
+public class AutomationProposalService : IAutomationProposalService
+{
+ private readonly IUnitOfWork _unitOfWork;
+
+ public AutomationProposalService(IUnitOfWork unitOfWork)
+ {
+ _unitOfWork = unitOfWork;
+ }
+
+ public async Task> CreateProposalAsync(CreateProposalDto dto, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var proposal = new AutomationProposal(
+ dto.SourceType,
+ dto.RequestedByUserId,
+ dto.Summary,
+ dto.RiskLevel,
+ dto.CorrelationId,
+ dto.BoardId,
+ dto.SourceReferenceId,
+ dto.ExpiryMinutes);
+
+ await _unitOfWork.AutomationProposals.AddAsync(proposal, cancellationToken);
+
+ // Add operations if provided
+ if (dto.Operations != null)
+ {
+ foreach (var opDto in dto.Operations)
+ {
+ var operation = new AutomationProposalOperation(
+ proposal.Id,
+ opDto.Sequence,
+ opDto.ActionType,
+ opDto.TargetType,
+ opDto.Parameters,
+ opDto.IdempotencyKey,
+ opDto.TargetId,
+ opDto.ExpectedVersion);
+
+ proposal.AddOperation(operation);
+ }
+ }
+
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(MapToDto(proposal));
+ }
+ catch (DomainException ex)
+ {
+ return Result.Failure(ex.ErrorCode, ex.Message);
+ }
+ }
+
+ public async Task> GetProposalByIdAsync(Guid id, CancellationToken cancellationToken = default)
+ {
+ var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(id, cancellationToken);
+ if (proposal == null)
+ return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found");
+
+ return Result.Success(MapToDto(proposal));
+ }
+
+ public async Task>> GetProposalsAsync(ProposalFilterDto? filter = null, CancellationToken cancellationToken = default)
+ {
+ filter ??= new ProposalFilterDto();
+ var limit = filter.Limit <= 0 ? 100 : filter.Limit;
+
+ IEnumerable proposals;
+
+ // Apply filters in order of specificity
+ if (filter.Status.HasValue)
+ {
+ proposals = await _unitOfWork.AutomationProposals.GetByStatusAsync(filter.Status.Value, limit, cancellationToken);
+ }
+ else if (filter.BoardId.HasValue)
+ {
+ proposals = await _unitOfWork.AutomationProposals.GetByBoardIdAsync(filter.BoardId.Value, limit, cancellationToken);
+ }
+ else if (filter.UserId.HasValue)
+ {
+ proposals = await _unitOfWork.AutomationProposals.GetByUserIdAsync(filter.UserId.Value, limit, cancellationToken);
+ }
+ else if (filter.RiskLevel.HasValue)
+ {
+ proposals = await _unitOfWork.AutomationProposals.GetByRiskLevelAsync(filter.RiskLevel.Value, limit, cancellationToken);
+ }
+ else
+ {
+ // Get all by status Pending if no filters provided
+ proposals = await _unitOfWork.AutomationProposals.GetByStatusAsync(ProposalStatus.PendingReview, limit, cancellationToken);
+ }
+
+ // Apply remaining filters in-memory when multiple filters are specified.
+ if (filter.Status.HasValue)
+ proposals = proposals.Where(p => p.Status == filter.Status.Value);
+
+ if (filter.BoardId.HasValue)
+ proposals = proposals.Where(p => p.BoardId == filter.BoardId.Value);
+
+ if (filter.UserId.HasValue)
+ proposals = proposals.Where(p => p.RequestedByUserId == filter.UserId.Value);
+
+ if (filter.RiskLevel.HasValue)
+ proposals = proposals.Where(p => p.RiskLevel == filter.RiskLevel.Value);
+
+ proposals = proposals.Take(limit);
+
+ return Result.Success(proposals.Select(MapToDto));
+ }
+
+ public async Task> ApproveProposalAsync(Guid id, Guid decidedByUserId, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(id, cancellationToken);
+ if (proposal == null)
+ return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found");
+
+ proposal.Approve(decidedByUserId);
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(MapToDto(proposal));
+ }
+ catch (DomainException ex)
+ {
+ return Result.Failure(ex.ErrorCode, ex.Message);
+ }
+ }
+
+ public async Task> RejectProposalAsync(Guid id, Guid decidedByUserId, UpdateProposalStatusDto dto, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(id, cancellationToken);
+ if (proposal == null)
+ return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found");
+
+ proposal.Reject(decidedByUserId, dto.Reason);
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(MapToDto(proposal));
+ }
+ catch (DomainException ex)
+ {
+ return Result.Failure(ex.ErrorCode, ex.Message);
+ }
+ }
+
+ public async Task> MarkAsAppliedAsync(Guid id, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(id, cancellationToken);
+ if (proposal == null)
+ return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found");
+
+ proposal.MarkAsApplied();
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(MapToDto(proposal));
+ }
+ catch (DomainException ex)
+ {
+ return Result.Failure(ex.ErrorCode, ex.Message);
+ }
+ }
+
+ public async Task> MarkAsFailedAsync(Guid id, string failureReason, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(id, cancellationToken);
+ if (proposal == null)
+ return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found");
+
+ proposal.MarkAsFailed(failureReason);
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(MapToDto(proposal));
+ }
+ catch (DomainException ex)
+ {
+ return Result.Failure(ex.ErrorCode, ex.Message);
+ }
+ }
+
+ public async Task> ExpireProposalsAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var expiredProposals = await _unitOfWork.AutomationProposals.GetExpiredAsync(cancellationToken);
+ int count = 0;
+
+ foreach (var proposal in expiredProposals)
+ {
+ proposal.Expire();
+ count++;
+ }
+
+ if (count > 0)
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(count);
+ }
+ catch (DomainException ex)
+ {
+ return Result.Failure(ex.ErrorCode, ex.Message);
+ }
+ }
+
+ public async Task> GetProposalDiffAsync(Guid id, CancellationToken cancellationToken = default)
+ {
+ var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(id, cancellationToken);
+ if (proposal == null)
+ return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found");
+
+ if (!string.IsNullOrWhiteSpace(proposal.DiffPreview))
+ return Result.Success(proposal.DiffPreview);
+
+ if (proposal.Operations.Count == 0)
+ return Result.Failure(ErrorCodes.NotFound, "Diff preview not available for this proposal");
+
+ var generatedDiff = string.Join(
+ Environment.NewLine,
+ proposal.Operations
+ .OrderBy(o => o.Sequence)
+ .Select(o =>
+ {
+ var target = string.IsNullOrWhiteSpace(o.TargetId) ? o.TargetType : $"{o.TargetType}:{o.TargetId}";
+ return $"{o.Sequence}. {o.ActionType} {target}";
+ }));
+
+ return Result.Success(generatedDiff);
+ }
+
+ private static ProposalDto MapToDto(AutomationProposal proposal)
+ {
+ return new ProposalDto(
+ proposal.Id,
+ proposal.SourceType,
+ proposal.SourceReferenceId,
+ proposal.BoardId,
+ proposal.RequestedByUserId,
+ proposal.Status,
+ proposal.RiskLevel,
+ proposal.Summary,
+ proposal.DiffPreview,
+ proposal.ValidationIssues,
+ proposal.CreatedAt,
+ proposal.UpdatedAt,
+ proposal.ExpiresAt,
+ proposal.DecidedAt,
+ proposal.DecidedByUserId,
+ proposal.AppliedAt,
+ proposal.FailureReason,
+ proposal.CorrelationId,
+ proposal.Operations.Select(MapOperationToDto).ToList()
+ );
+ }
+
+ private static ProposalOperationDto MapOperationToDto(AutomationProposalOperation operation)
+ {
+ return new ProposalOperationDto(
+ operation.Id,
+ operation.ProposalId,
+ operation.Sequence,
+ operation.ActionType,
+ operation.TargetType,
+ operation.TargetId,
+ operation.Parameters,
+ operation.IdempotencyKey,
+ operation.ExpectedVersion
+ );
+ }
+}
diff --git a/backend/src/Taskdeck.Application/Services/IArchiveRecoveryService.cs b/backend/src/Taskdeck.Application/Services/IArchiveRecoveryService.cs
new file mode 100644
index 000000000..37b406855
--- /dev/null
+++ b/backend/src/Taskdeck.Application/Services/IArchiveRecoveryService.cs
@@ -0,0 +1,34 @@
+using Taskdeck.Application.DTOs;
+using Taskdeck.Domain.Common;
+using Taskdeck.Domain.Entities;
+
+namespace Taskdeck.Application.Services;
+
+public interface IArchiveRecoveryService
+{
+ Task> CreateArchiveItemAsync(
+ CreateArchiveItemDto dto,
+ CancellationToken cancellationToken = default);
+
+ Task>> GetArchiveItemsAsync(
+ string? entityType = null,
+ Guid? boardId = null,
+ RestoreStatus? status = null,
+ int limit = 100,
+ CancellationToken cancellationToken = default);
+
+ Task> GetArchiveItemByIdAsync(
+ Guid id,
+ CancellationToken cancellationToken = default);
+
+ Task> GetArchiveItemByEntityAsync(
+ string entityType,
+ Guid entityId,
+ CancellationToken cancellationToken = default);
+
+ Task> RestoreArchiveItemAsync(
+ Guid id,
+ RestoreArchiveItemDto dto,
+ Guid restoredByUserId,
+ CancellationToken cancellationToken = default);
+}
diff --git a/backend/src/Taskdeck.Application/Services/IAutomationExecutorService.cs b/backend/src/Taskdeck.Application/Services/IAutomationExecutorService.cs
new file mode 100644
index 000000000..3ef5150df
--- /dev/null
+++ b/backend/src/Taskdeck.Application/Services/IAutomationExecutorService.cs
@@ -0,0 +1,8 @@
+using Taskdeck.Domain.Common;
+
+namespace Taskdeck.Application.Services;
+
+public interface IAutomationExecutorService
+{
+ Task ExecuteProposalAsync(Guid proposalId, string idempotencyKey, CancellationToken cancellationToken = default);
+}
diff --git a/backend/src/Taskdeck.Application/Services/IAutomationPlannerService.cs b/backend/src/Taskdeck.Application/Services/IAutomationPlannerService.cs
new file mode 100644
index 000000000..4a6d60dd4
--- /dev/null
+++ b/backend/src/Taskdeck.Application/Services/IAutomationPlannerService.cs
@@ -0,0 +1,9 @@
+using Taskdeck.Application.DTOs;
+using Taskdeck.Domain.Common;
+
+namespace Taskdeck.Application.Services;
+
+public interface IAutomationPlannerService
+{
+ Task> ParseInstructionAsync(string instruction, Guid userId, Guid? boardId = null, CancellationToken cancellationToken = default);
+}
diff --git a/backend/src/Taskdeck.Application/Services/IAutomationPolicyEngine.cs b/backend/src/Taskdeck.Application/Services/IAutomationPolicyEngine.cs
new file mode 100644
index 000000000..3b44ce646
--- /dev/null
+++ b/backend/src/Taskdeck.Application/Services/IAutomationPolicyEngine.cs
@@ -0,0 +1,12 @@
+using Taskdeck.Application.DTOs;
+using Taskdeck.Domain.Common;
+using Taskdeck.Domain.Entities;
+
+namespace Taskdeck.Application.Services;
+
+public interface IAutomationPolicyEngine
+{
+ RiskLevel ClassifyRisk(IEnumerable operations);
+ Task ValidatePermissionsAsync(Guid userId, Guid? boardId, IEnumerable operations, CancellationToken cancellationToken = default);
+ Result ValidatePolicy(ProposalDto proposal);
+}
diff --git a/backend/src/Taskdeck.Application/Services/IAutomationProposalService.cs b/backend/src/Taskdeck.Application/Services/IAutomationProposalService.cs
new file mode 100644
index 000000000..b3691d489
--- /dev/null
+++ b/backend/src/Taskdeck.Application/Services/IAutomationProposalService.cs
@@ -0,0 +1,53 @@
+using Taskdeck.Application.DTOs;
+using Taskdeck.Domain.Common;
+using Taskdeck.Domain.Entities;
+
+namespace Taskdeck.Application.Services;
+
+public interface IAutomationProposalService
+{
+ ///
+ /// Creates a new automation proposal with operations.
+ ///
+ Task> CreateProposalAsync(CreateProposalDto dto, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets a proposal by ID with all operations.
+ ///
+ Task> GetProposalByIdAsync(Guid id, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets proposals with optional filters.
+ ///
+ Task>> GetProposalsAsync(ProposalFilterDto? filter = null, CancellationToken cancellationToken = default);
+
+ ///
+ /// Approves a pending proposal.
+ ///
+ Task> ApproveProposalAsync(Guid id, Guid decidedByUserId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Rejects a pending proposal with optional reason (required for High/Critical risk).
+ ///
+ Task> RejectProposalAsync(Guid id, Guid decidedByUserId, UpdateProposalStatusDto dto, CancellationToken cancellationToken = default);
+
+ ///
+ /// Marks an approved proposal as applied.
+ ///
+ Task> MarkAsAppliedAsync(Guid id, CancellationToken cancellationToken = default);
+
+ ///
+ /// Marks an approved proposal as failed with reason.
+ ///
+ Task> MarkAsFailedAsync(Guid id, string failureReason, CancellationToken cancellationToken = default);
+
+ ///
+ /// Expires all stale pending proposals.
+ ///
+ Task> ExpireProposalsAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets the diff preview for a proposal.
+ ///
+ Task> GetProposalDiffAsync(Guid id, CancellationToken cancellationToken = default);
+}
diff --git a/backend/src/Taskdeck.Domain/Entities/ArchiveItem.cs b/backend/src/Taskdeck.Domain/Entities/ArchiveItem.cs
new file mode 100644
index 000000000..85dfa947c
--- /dev/null
+++ b/backend/src/Taskdeck.Domain/Entities/ArchiveItem.cs
@@ -0,0 +1,106 @@
+using Taskdeck.Domain.Common;
+using Taskdeck.Domain.Exceptions;
+
+namespace Taskdeck.Domain.Entities;
+
+public class ArchiveItem : Entity
+{
+ public string EntityType { get; private set; }
+ public Guid EntityId { get; private set; }
+ public Guid BoardId { get; private set; }
+ public string Name { get; private set; }
+ public Guid ArchivedByUserId { get; private set; }
+ public DateTime ArchivedAt { get; private set; }
+ public string? Reason { get; private set; }
+ public string SnapshotJson { get; private set; }
+ public RestoreStatus RestoreStatus { get; private set; }
+ public DateTime? RestoredAt { get; private set; }
+ public Guid? RestoredByUserId { get; private set; }
+
+ private ArchiveItem() { } // EF Core
+
+ public ArchiveItem(
+ string entityType,
+ Guid entityId,
+ Guid boardId,
+ string name,
+ Guid archivedByUserId,
+ string snapshotJson,
+ string? reason = null)
+ {
+ if (string.IsNullOrWhiteSpace(entityType))
+ throw new DomainException(ErrorCodes.ValidationError, "EntityType cannot be empty");
+ if (entityType != "board" && entityType != "column" && entityType != "card")
+ throw new DomainException(ErrorCodes.ValidationError, "EntityType must be 'board', 'column', or 'card'");
+ if (entityId == Guid.Empty)
+ throw new DomainException(ErrorCodes.ValidationError, "EntityId cannot be empty");
+ if (boardId == Guid.Empty)
+ throw new DomainException(ErrorCodes.ValidationError, "BoardId cannot be empty");
+ if (string.IsNullOrWhiteSpace(name))
+ throw new DomainException(ErrorCodes.ValidationError, "Name cannot be empty");
+ if (name.Length > 200)
+ throw new DomainException(ErrorCodes.ValidationError, "Name cannot exceed 200 characters");
+ if (archivedByUserId == Guid.Empty)
+ throw new DomainException(ErrorCodes.ValidationError, "ArchivedByUserId cannot be empty");
+ if (string.IsNullOrWhiteSpace(snapshotJson))
+ throw new DomainException(ErrorCodes.ValidationError, "SnapshotJson cannot be empty");
+
+ EntityType = entityType;
+ EntityId = entityId;
+ BoardId = boardId;
+ Name = name;
+ ArchivedByUserId = archivedByUserId;
+ ArchivedAt = DateTime.UtcNow;
+ Reason = reason;
+ SnapshotJson = snapshotJson;
+ RestoreStatus = RestoreStatus.Available;
+ }
+
+ public void MarkAsRestored(Guid restoredByUserId)
+ {
+ if (restoredByUserId == Guid.Empty)
+ throw new DomainException(ErrorCodes.ValidationError, "RestoredByUserId cannot be empty");
+ if (RestoreStatus != RestoreStatus.Available)
+ throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot restore archive item with status {RestoreStatus}");
+
+ RestoreStatus = RestoreStatus.Restored;
+ RestoredAt = DateTime.UtcNow;
+ RestoredByUserId = restoredByUserId;
+ Touch();
+ }
+
+ public void MarkAsExpired()
+ {
+ if (RestoreStatus != RestoreStatus.Available)
+ throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot expire archive item with status {RestoreStatus}");
+
+ RestoreStatus = RestoreStatus.Expired;
+ Touch();
+ }
+
+ public void MarkAsConflict()
+ {
+ if (RestoreStatus != RestoreStatus.Available)
+ throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot mark conflict for archive item with status {RestoreStatus}");
+
+ RestoreStatus = RestoreStatus.Conflict;
+ Touch();
+ }
+
+ public void ResetToAvailable()
+ {
+ if (RestoreStatus == RestoreStatus.Restored)
+ throw new DomainException(ErrorCodes.InvalidOperation, "Cannot reset already restored archive item");
+
+ RestoreStatus = RestoreStatus.Available;
+ Touch();
+ }
+}
+
+public enum RestoreStatus
+{
+ Available,
+ Restored,
+ Expired,
+ Conflict
+}
diff --git a/backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs b/backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs
new file mode 100644
index 000000000..301a6e58d
--- /dev/null
+++ b/backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs
@@ -0,0 +1,176 @@
+using Taskdeck.Domain.Common;
+using Taskdeck.Domain.Exceptions;
+
+namespace Taskdeck.Domain.Entities;
+
+public class AutomationProposal : Entity
+{
+ public ProposalSourceType SourceType { get; private set; }
+ public string? SourceReferenceId { get; private set; }
+ public Guid? BoardId { get; private set; }
+ public Guid RequestedByUserId { get; private set; }
+ public ProposalStatus Status { get; private set; }
+ public RiskLevel RiskLevel { get; private set; }
+ public string Summary { get; private set; }
+ public string? DiffPreview { get; private set; }
+ public string? ValidationIssues { get; private set; }
+ public DateTime ExpiresAt { get; private set; }
+ public DateTime? DecidedAt { get; private set; }
+ public Guid? DecidedByUserId { get; private set; }
+ public DateTime? AppliedAt { get; private set; }
+ public string? FailureReason { get; private set; }
+ public string CorrelationId { get; private set; }
+
+ private readonly List _operations = new();
+ public IReadOnlyList Operations => _operations.AsReadOnly();
+
+ private AutomationProposal() { } // EF Core
+
+ public AutomationProposal(
+ ProposalSourceType sourceType,
+ Guid requestedByUserId,
+ string summary,
+ RiskLevel riskLevel,
+ string correlationId,
+ Guid? boardId = null,
+ string? sourceReferenceId = null,
+ int expiryMinutes = 1440)
+ {
+ if (requestedByUserId == Guid.Empty)
+ throw new DomainException(ErrorCodes.ValidationError, "RequestedByUserId cannot be empty");
+ if (string.IsNullOrWhiteSpace(summary))
+ throw new DomainException(ErrorCodes.ValidationError, "Summary cannot be empty");
+ if (summary.Length > 500)
+ throw new DomainException(ErrorCodes.ValidationError, "Summary cannot exceed 500 characters");
+ if (string.IsNullOrWhiteSpace(correlationId))
+ throw new DomainException(ErrorCodes.ValidationError, "CorrelationId cannot be empty");
+ if (expiryMinutes <= 0)
+ throw new DomainException(ErrorCodes.ValidationError, "ExpiryMinutes must be positive");
+
+ SourceType = sourceType;
+ SourceReferenceId = sourceReferenceId;
+ BoardId = boardId;
+ RequestedByUserId = requestedByUserId;
+ Status = ProposalStatus.PendingReview;
+ RiskLevel = riskLevel;
+ Summary = summary;
+ CorrelationId = correlationId;
+ ExpiresAt = DateTime.UtcNow.AddMinutes(expiryMinutes);
+ }
+
+ public void AddOperation(AutomationProposalOperation operation)
+ {
+ if (Status != ProposalStatus.PendingReview)
+ throw new DomainException(ErrorCodes.InvalidOperation, "Cannot add operations after proposal has been decided");
+
+ _operations.Add(operation);
+ Touch();
+ }
+
+ public void Approve(Guid decidedByUserId)
+ {
+ if (decidedByUserId == Guid.Empty)
+ throw new DomainException(ErrorCodes.ValidationError, "DecidedByUserId cannot be empty");
+ if (Status != ProposalStatus.PendingReview)
+ throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot approve proposal in status {Status}");
+ if (DateTime.UtcNow > ExpiresAt)
+ throw new DomainException(ErrorCodes.InvalidOperation, "Cannot approve expired proposal");
+
+ Status = ProposalStatus.Approved;
+ DecidedByUserId = decidedByUserId;
+ DecidedAt = DateTime.UtcNow;
+ Touch();
+ }
+
+ public void Reject(Guid decidedByUserId, string? reason = null)
+ {
+ if (decidedByUserId == Guid.Empty)
+ throw new DomainException(ErrorCodes.ValidationError, "DecidedByUserId cannot be empty");
+ if (Status != ProposalStatus.PendingReview)
+ throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot reject proposal in status {Status}");
+
+ // High and Critical risk proposals require a reason for rejection
+ if ((RiskLevel == RiskLevel.High || RiskLevel == RiskLevel.Critical) && string.IsNullOrWhiteSpace(reason))
+ throw new DomainException(ErrorCodes.ValidationError, "Rejection reason is required for High and Critical risk proposals");
+
+ Status = ProposalStatus.Rejected;
+ DecidedByUserId = decidedByUserId;
+ DecidedAt = DateTime.UtcNow;
+ FailureReason = reason;
+ Touch();
+ }
+
+ public void MarkAsApplied()
+ {
+ if (Status != ProposalStatus.Approved)
+ throw new DomainException(ErrorCodes.InvalidOperation, "Only approved proposals can be marked as applied");
+
+ Status = ProposalStatus.Applied;
+ AppliedAt = DateTime.UtcNow;
+ Touch();
+ }
+
+ public void MarkAsFailed(string failureReason)
+ {
+ if (string.IsNullOrWhiteSpace(failureReason))
+ throw new DomainException(ErrorCodes.ValidationError, "FailureReason cannot be empty");
+ if (Status != ProposalStatus.Approved)
+ throw new DomainException(ErrorCodes.InvalidOperation, "Only approved proposals can be marked as failed");
+
+ Status = ProposalStatus.Failed;
+ FailureReason = failureReason;
+ Touch();
+ }
+
+ public void Expire()
+ {
+ if (Status != ProposalStatus.PendingReview)
+ throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot expire proposal in status {Status}");
+
+ Status = ProposalStatus.Expired;
+ Touch();
+ }
+
+ public void SetDiffPreview(string diffPreview)
+ {
+ if (Status != ProposalStatus.PendingReview)
+ throw new DomainException(ErrorCodes.InvalidOperation, "Cannot update diff preview after proposal has been decided");
+
+ DiffPreview = diffPreview;
+ Touch();
+ }
+
+ public void SetValidationIssues(string validationIssues)
+ {
+ if (Status != ProposalStatus.PendingReview)
+ throw new DomainException(ErrorCodes.InvalidOperation, "Cannot update validation issues after proposal has been decided");
+
+ ValidationIssues = validationIssues;
+ Touch();
+ }
+}
+
+public enum ProposalSourceType
+{
+ Queue,
+ Chat,
+ Manual
+}
+
+public enum ProposalStatus
+{
+ PendingReview,
+ Approved,
+ Rejected,
+ Applied,
+ Failed,
+ Expired
+}
+
+public enum RiskLevel
+{
+ Low,
+ Medium,
+ High,
+ Critical
+}
diff --git a/backend/src/Taskdeck.Domain/Entities/AutomationProposalOperation.cs b/backend/src/Taskdeck.Domain/Entities/AutomationProposalOperation.cs
new file mode 100644
index 000000000..871b01f66
--- /dev/null
+++ b/backend/src/Taskdeck.Domain/Entities/AutomationProposalOperation.cs
@@ -0,0 +1,54 @@
+using Taskdeck.Domain.Common;
+using Taskdeck.Domain.Exceptions;
+
+namespace Taskdeck.Domain.Entities;
+
+public class AutomationProposalOperation : Entity
+{
+ public Guid ProposalId { get; private set; }
+ public int Sequence { get; private set; }
+ public string ActionType { get; private set; }
+ public string TargetType { get; private set; }
+ public string? TargetId { get; private set; }
+ public string Parameters { get; private set; } // JSON payload
+ public string IdempotencyKey { get; private set; }
+ public string? ExpectedVersion { get; private set; }
+
+ // Navigation
+ public AutomationProposal Proposal { get; private set; } = null!;
+
+ private AutomationProposalOperation() { } // EF Core
+
+ public AutomationProposalOperation(
+ Guid proposalId,
+ int sequence,
+ string actionType,
+ string targetType,
+ string parameters,
+ string idempotencyKey,
+ string? targetId = null,
+ string? expectedVersion = null)
+ {
+ if (proposalId == Guid.Empty)
+ throw new DomainException(ErrorCodes.ValidationError, "ProposalId cannot be empty");
+ if (sequence < 0)
+ throw new DomainException(ErrorCodes.ValidationError, "Sequence must be non-negative");
+ if (string.IsNullOrWhiteSpace(actionType))
+ throw new DomainException(ErrorCodes.ValidationError, "ActionType cannot be empty");
+ if (string.IsNullOrWhiteSpace(targetType))
+ throw new DomainException(ErrorCodes.ValidationError, "TargetType cannot be empty");
+ if (string.IsNullOrWhiteSpace(parameters))
+ throw new DomainException(ErrorCodes.ValidationError, "Parameters cannot be empty");
+ if (string.IsNullOrWhiteSpace(idempotencyKey))
+ throw new DomainException(ErrorCodes.ValidationError, "IdempotencyKey cannot be empty");
+
+ ProposalId = proposalId;
+ Sequence = sequence;
+ ActionType = actionType;
+ TargetType = targetType;
+ TargetId = targetId;
+ Parameters = parameters;
+ IdempotencyKey = idempotencyKey;
+ ExpectedVersion = expectedVersion;
+ }
+}
diff --git a/backend/src/Taskdeck.Domain/Entities/ChatMessage.cs b/backend/src/Taskdeck.Domain/Entities/ChatMessage.cs
new file mode 100644
index 000000000..bac4f0079
--- /dev/null
+++ b/backend/src/Taskdeck.Domain/Entities/ChatMessage.cs
@@ -0,0 +1,71 @@
+using Taskdeck.Domain.Common;
+using Taskdeck.Domain.Exceptions;
+
+namespace Taskdeck.Domain.Entities;
+
+public class ChatMessage : Entity
+{
+ public Guid SessionId { get; private set; }
+ public ChatMessageRole Role { get; private set; }
+ public string Content { get; private set; }
+ public string MessageType { get; private set; }
+ public Guid? ProposalId { get; private set; }
+ public int? TokenUsage { get; private set; }
+
+ // Navigation
+ public ChatSession Session { get; private set; } = null!;
+
+ private ChatMessage() { } // EF Core
+
+ public ChatMessage(
+ Guid sessionId,
+ ChatMessageRole role,
+ string content,
+ string messageType = "text",
+ Guid? proposalId = null,
+ int? tokenUsage = null)
+ {
+ if (sessionId == Guid.Empty)
+ throw new DomainException(ErrorCodes.ValidationError, "SessionId cannot be empty");
+ if (string.IsNullOrWhiteSpace(content))
+ throw new DomainException(ErrorCodes.ValidationError, "Content cannot be empty");
+ if (string.IsNullOrWhiteSpace(messageType))
+ throw new DomainException(ErrorCodes.ValidationError, "MessageType cannot be empty");
+ if (messageType != "text" && messageType != "proposal-reference" && messageType != "error" && messageType != "status")
+ throw new DomainException(ErrorCodes.ValidationError, "MessageType must be 'text', 'proposal-reference', 'error', or 'status'");
+ if (tokenUsage.HasValue && tokenUsage.Value < 0)
+ throw new DomainException(ErrorCodes.ValidationError, "TokenUsage must be non-negative");
+
+ SessionId = sessionId;
+ Role = role;
+ Content = content;
+ MessageType = messageType;
+ ProposalId = proposalId;
+ TokenUsage = tokenUsage;
+ }
+
+ public void SetTokenUsage(int tokenUsage)
+ {
+ if (tokenUsage < 0)
+ throw new DomainException(ErrorCodes.ValidationError, "TokenUsage must be non-negative");
+
+ TokenUsage = tokenUsage;
+ Touch();
+ }
+
+ public void SetProposalId(Guid proposalId)
+ {
+ if (proposalId == Guid.Empty)
+ throw new DomainException(ErrorCodes.ValidationError, "ProposalId cannot be empty");
+
+ ProposalId = proposalId;
+ Touch();
+ }
+}
+
+public enum ChatMessageRole
+{
+ User,
+ Assistant,
+ System
+}
diff --git a/backend/src/Taskdeck.Domain/Entities/ChatSession.cs b/backend/src/Taskdeck.Domain/Entities/ChatSession.cs
new file mode 100644
index 000000000..b3737343f
--- /dev/null
+++ b/backend/src/Taskdeck.Domain/Entities/ChatSession.cs
@@ -0,0 +1,79 @@
+using Taskdeck.Domain.Common;
+using Taskdeck.Domain.Exceptions;
+
+namespace Taskdeck.Domain.Entities;
+
+public class ChatSession : Entity
+{
+ public Guid UserId { get; private set; }
+ public Guid? BoardId { get; private set; }
+ public string Title { get; private set; }
+ public ChatSessionStatus Status { get; private set; }
+
+ private readonly List _messages = new();
+ public IReadOnlyList Messages => _messages.AsReadOnly();
+
+ private ChatSession() { } // EF Core
+
+ public ChatSession(
+ Guid userId,
+ string title,
+ Guid? boardId = null)
+ {
+ if (userId == Guid.Empty)
+ throw new DomainException(ErrorCodes.ValidationError, "UserId cannot be empty");
+ if (string.IsNullOrWhiteSpace(title))
+ throw new DomainException(ErrorCodes.ValidationError, "Title cannot be empty");
+ if (title.Length > 200)
+ throw new DomainException(ErrorCodes.ValidationError, "Title cannot exceed 200 characters");
+
+ UserId = userId;
+ BoardId = boardId;
+ Title = title;
+ Status = ChatSessionStatus.Active;
+ }
+
+ public void UpdateTitle(string title)
+ {
+ if (string.IsNullOrWhiteSpace(title))
+ throw new DomainException(ErrorCodes.ValidationError, "Title cannot be empty");
+ if (title.Length > 200)
+ throw new DomainException(ErrorCodes.ValidationError, "Title cannot exceed 200 characters");
+
+ Title = title;
+ Touch();
+ }
+
+ public void Archive()
+ {
+ if (Status == ChatSessionStatus.Archived)
+ throw new DomainException(ErrorCodes.InvalidOperation, "Session is already archived");
+
+ Status = ChatSessionStatus.Archived;
+ Touch();
+ }
+
+ public void Reactivate()
+ {
+ if (Status == ChatSessionStatus.Active)
+ throw new DomainException(ErrorCodes.InvalidOperation, "Session is already active");
+
+ Status = ChatSessionStatus.Active;
+ Touch();
+ }
+
+ public void AddMessage(ChatMessage message)
+ {
+ if (Status == ChatSessionStatus.Archived)
+ throw new DomainException(ErrorCodes.InvalidOperation, "Cannot add messages to archived session");
+
+ _messages.Add(message);
+ Touch();
+ }
+}
+
+public enum ChatSessionStatus
+{
+ Active,
+ Archived
+}
diff --git a/backend/src/Taskdeck.Domain/Entities/CommandRun.cs b/backend/src/Taskdeck.Domain/Entities/CommandRun.cs
new file mode 100644
index 000000000..a0420da74
--- /dev/null
+++ b/backend/src/Taskdeck.Domain/Entities/CommandRun.cs
@@ -0,0 +1,127 @@
+using Taskdeck.Domain.Common;
+using Taskdeck.Domain.Exceptions;
+
+namespace Taskdeck.Domain.Entities;
+
+public class CommandRun : Entity
+{
+ public string TemplateName { get; private set; }
+ public Guid RequestedByUserId { get; private set; }
+ public CommandRunStatus Status { get; private set; }
+ public DateTime? StartedAt { get; private set; }
+ public DateTime? CompletedAt { get; private set; }
+ public int? ExitCode { get; private set; }
+ public bool Truncated { get; private set; }
+ public string CorrelationId { get; private set; }
+ public string? ErrorMessage { get; private set; }
+ public string? OutputPreview { get; private set; }
+
+ private readonly List _logs = new();
+ public IReadOnlyList Logs => _logs.AsReadOnly();
+
+ private CommandRun() { } // EF Core
+
+ public CommandRun(
+ string templateName,
+ Guid requestedByUserId,
+ string correlationId)
+ {
+ if (string.IsNullOrWhiteSpace(templateName))
+ throw new DomainException(ErrorCodes.ValidationError, "TemplateName cannot be empty");
+ if (requestedByUserId == Guid.Empty)
+ throw new DomainException(ErrorCodes.ValidationError, "RequestedByUserId cannot be empty");
+ if (string.IsNullOrWhiteSpace(correlationId))
+ throw new DomainException(ErrorCodes.ValidationError, "CorrelationId cannot be empty");
+
+ TemplateName = templateName;
+ RequestedByUserId = requestedByUserId;
+ Status = CommandRunStatus.Queued;
+ CorrelationId = correlationId;
+ Truncated = false;
+ }
+
+ public void Start()
+ {
+ if (Status != CommandRunStatus.Queued)
+ throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot start command run in status {Status}");
+
+ Status = CommandRunStatus.Running;
+ StartedAt = DateTime.UtcNow;
+ Touch();
+ }
+
+ public void Complete(int exitCode)
+ {
+ if (Status != CommandRunStatus.Running)
+ throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot complete command run in status {Status}");
+
+ Status = CommandRunStatus.Completed;
+ CompletedAt = DateTime.UtcNow;
+ ExitCode = exitCode;
+ Touch();
+ }
+
+ public void Fail(string errorMessage)
+ {
+ if (string.IsNullOrWhiteSpace(errorMessage))
+ throw new DomainException(ErrorCodes.ValidationError, "ErrorMessage cannot be empty");
+ if (Status != CommandRunStatus.Queued && Status != CommandRunStatus.Running)
+ throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot fail command run in status {Status}");
+
+ Status = CommandRunStatus.Failed;
+ CompletedAt = DateTime.UtcNow;
+ ErrorMessage = errorMessage;
+ Touch();
+ }
+
+ public void Timeout()
+ {
+ if (Status != CommandRunStatus.Running)
+ throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot timeout command run in status {Status}");
+
+ Status = CommandRunStatus.TimedOut;
+ CompletedAt = DateTime.UtcNow;
+ Touch();
+ }
+
+ public void Cancel()
+ {
+ if (Status != CommandRunStatus.Queued && Status != CommandRunStatus.Running)
+ throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot cancel command run in status {Status}");
+
+ Status = CommandRunStatus.Cancelled;
+ CompletedAt = DateTime.UtcNow;
+ Touch();
+ }
+
+ public void SetTruncated()
+ {
+ Truncated = true;
+ Touch();
+ }
+
+ public void SetOutputPreview(string outputPreview)
+ {
+ if (outputPreview != null && outputPreview.Length > 1000)
+ throw new DomainException(ErrorCodes.ValidationError, "OutputPreview cannot exceed 1000 characters");
+
+ OutputPreview = outputPreview;
+ Touch();
+ }
+
+ public void AddLog(CommandRunLog log)
+ {
+ _logs.Add(log);
+ Touch();
+ }
+}
+
+public enum CommandRunStatus
+{
+ Queued,
+ Running,
+ Completed,
+ Failed,
+ TimedOut,
+ Cancelled
+}
diff --git a/backend/src/Taskdeck.Domain/Entities/CommandRunLog.cs b/backend/src/Taskdeck.Domain/Entities/CommandRunLog.cs
new file mode 100644
index 000000000..664cbe2f2
--- /dev/null
+++ b/backend/src/Taskdeck.Domain/Entities/CommandRunLog.cs
@@ -0,0 +1,45 @@
+using Taskdeck.Domain.Common;
+using Taskdeck.Domain.Exceptions;
+
+namespace Taskdeck.Domain.Entities;
+
+public class CommandRunLog : Entity
+{
+ public Guid CommandRunId { get; private set; }
+ public DateTime Timestamp { get; private set; }
+ public string Level { get; private set; }
+ public string Source { get; private set; }
+ public string Message { get; private set; }
+ public string? Metadata { get; private set; } // JSON
+
+ // Navigation
+ public CommandRun CommandRun { get; private set; } = null!;
+
+ private CommandRunLog() { } // EF Core
+
+ public CommandRunLog(
+ Guid commandRunId,
+ string level,
+ string source,
+ string message,
+ string? metadata = null)
+ {
+ if (commandRunId == Guid.Empty)
+ throw new DomainException(ErrorCodes.ValidationError, "CommandRunId cannot be empty");
+ if (string.IsNullOrWhiteSpace(level))
+ throw new DomainException(ErrorCodes.ValidationError, "Level cannot be empty");
+ if (level != "Debug" && level != "Info" && level != "Warning" && level != "Error")
+ throw new DomainException(ErrorCodes.ValidationError, "Level must be 'Debug', 'Info', 'Warning', or 'Error'");
+ if (string.IsNullOrWhiteSpace(source))
+ throw new DomainException(ErrorCodes.ValidationError, "Source cannot be empty");
+ if (string.IsNullOrWhiteSpace(message))
+ throw new DomainException(ErrorCodes.ValidationError, "Message cannot be empty");
+
+ CommandRunId = commandRunId;
+ Timestamp = DateTime.UtcNow;
+ Level = level;
+ Source = source;
+ Message = message;
+ Metadata = metadata;
+ }
+}
diff --git a/backend/src/Taskdeck.Domain/Exceptions/DomainException.cs b/backend/src/Taskdeck.Domain/Exceptions/DomainException.cs
index 986ec476f..51a485da0 100644
--- a/backend/src/Taskdeck.Domain/Exceptions/DomainException.cs
+++ b/backend/src/Taskdeck.Domain/Exceptions/DomainException.cs
@@ -26,4 +26,5 @@ public static class ErrorCodes
public const string Unauthorized = "Unauthorized";
public const string Forbidden = "Forbidden";
public const string AuthenticationFailed = "AuthenticationFailed";
+ public const string InvalidOperation = "InvalidOperation";
}
diff --git a/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs b/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs
index 249365d8f..f8c1eaf67 100644
--- a/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs
+++ b/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs
@@ -25,6 +25,11 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
services.AddScoped();
return services;
diff --git a/backend/src/Taskdeck.Infrastructure/Identity/UserContext.cs b/backend/src/Taskdeck.Infrastructure/Identity/UserContext.cs
new file mode 100644
index 000000000..d3b5760ac
--- /dev/null
+++ b/backend/src/Taskdeck.Infrastructure/Identity/UserContext.cs
@@ -0,0 +1,36 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Http;
+using Taskdeck.Application.Interfaces;
+
+namespace Taskdeck.Infrastructure.Identity;
+
+///
+/// Implementation of IUserContext that extracts user identity from HTTP context claims.
+///
+public class UserContext : IUserContext
+{
+ private readonly IHttpContextAccessor _httpContextAccessor;
+
+ public UserContext(IHttpContextAccessor httpContextAccessor)
+ {
+ _httpContextAccessor = httpContextAccessor;
+ }
+
+ public string? UserId
+ {
+ get
+ {
+ var user = _httpContextAccessor.HttpContext?.User;
+ if (user == null || user.Identity?.IsAuthenticated != true)
+ return null;
+
+ // Try both 'sub' (standard JWT claim) and 'nameid' (alternative)
+ return user.FindFirst(ClaimTypes.NameIdentifier)?.Value
+ ?? user.FindFirst("sub")?.Value;
+ }
+ }
+
+ public bool IsAuthenticated => _httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated == true;
+
+ public string? Role => _httpContextAccessor.HttpContext?.User?.FindFirst(ClaimTypes.Role)?.Value;
+}
diff --git a/backend/src/Taskdeck.Infrastructure/Migrations/20260212200649_AddAutomationArchiveChatOpsEntities.Designer.cs b/backend/src/Taskdeck.Infrastructure/Migrations/20260212200649_AddAutomationArchiveChatOpsEntities.Designer.cs
new file mode 100644
index 000000000..60499a545
--- /dev/null
+++ b/backend/src/Taskdeck.Infrastructure/Migrations/20260212200649_AddAutomationArchiveChatOpsEntities.Designer.cs
@@ -0,0 +1,951 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Taskdeck.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace Taskdeck.Infrastructure.Migrations
+{
+ [DbContext(typeof(TaskdeckDbContext))]
+ [Migration("20260212200649_AddAutomationArchiveChatOpsEntities")]
+ partial class AddAutomationArchiveChatOpsEntities
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "8.0.0");
+
+ modelBuilder.Entity("Taskdeck.Domain.Entities.ArchiveItem", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("ArchivedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("ArchivedByUserId")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("BoardId")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("EntityId")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("EntityType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("Reason")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("RestoreStatus")
+ .HasColumnType("INTEGER");
+
+ b.Property("RestoredAt")
+ .HasColumnType("TEXT");
+
+ b.Property("RestoredByUserId")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("SnapshotJson")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ArchivedAt");
+
+ b.HasIndex("ArchivedByUserId");
+
+ b.HasIndex("BoardId");
+
+ b.HasIndex("RestoreStatus");
+
+ b.HasIndex("EntityType", "EntityId");
+
+ b.ToTable("ArchiveItems", (string)null);
+ });
+
+ modelBuilder.Entity("Taskdeck.Domain.Entities.AuditLog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Action")
+ .HasColumnType("INTEGER");
+
+ b.Property("Changes")
+ .HasMaxLength(4000)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("EntityId")
+ .HasColumnType("TEXT");
+
+ b.Property("EntityType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("Timestamp")
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Timestamp");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("EntityType", "EntityId");
+
+ b.ToTable("AuditLogs", (string)null);
+ });
+
+ modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposal", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("AppliedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("BoardId")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("CorrelationId")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DecidedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DecidedByUserId")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("DiffPreview")
+ .HasColumnType("TEXT");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("TEXT");
+
+ b.Property("FailureReason")
+ .HasMaxLength(1000)
+ .HasColumnType("TEXT");
+
+ b.Property("RequestedByUserId")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("RiskLevel")
+ .HasColumnType("INTEGER");
+
+ b.Property("SourceReferenceId")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("SourceType")
+ .HasColumnType("INTEGER");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("Summary")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("ValidationIssues")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BoardId");
+
+ b.HasIndex("CorrelationId");
+
+ b.HasIndex("ExpiresAt");
+
+ b.HasIndex("RequestedByUserId");
+
+ b.HasIndex("Status");
+
+ b.ToTable("AutomationProposals", (string)null);
+ });
+
+ modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposalOperation", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("ActionType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("ExpectedVersion")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("IdempotencyKey")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("Parameters")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ProposalId")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("Sequence")
+ .HasColumnType("INTEGER");
+
+ b.Property("TargetId")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("TargetType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("IdempotencyKey")
+ .IsUnique();
+
+ b.HasIndex("ProposalId");
+
+ b.HasIndex("ProposalId", "Sequence");
+
+ b.ToTable("AutomationProposalOperations", (string)null);
+ });
+
+ modelBuilder.Entity("Taskdeck.Domain.Entities.Board", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Description")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property