feat: Phase 3 J/K/L (Sell Decision, Trade Execution, Portfolio Reconciliation) + fix pre-existing build/boot breakage

Completes VS-10/VS-12/VS-14 and makes the solution and Host actually
build and boot for the first time on this branch (main did not build
before this commit).

Root-cause fixes required to reach a green build/boot (not scoped to
J/K/L but blocking any verification of it):
- Restore Polly PackageVersion accidentally deleted from
  Directory.Packages.props (broke KArtSell.Host).
- Remove MediatR dependency from Compliance/VS-04 (package was never
  installed; ICommand/ICommandHandler/IMediator never existed) and
  wire Endpoint -> Handler directly per this repo's convention.
- Migrate FastEndpoints v5 API calls (SendOkAsync/SendAsync/
  SendCreatedAtAsync/SendNotFoundAsync, Description().WithName()) to
  the v7 Send.* fluent API across ~10 endpoint files.
- Fix migrations 0036/0038/0039/0040: rewritten from invalid T-SQL
  (`IF NOT EXISTS ... BEGIN ... END`) to idiomatic Postgres
  (`CREATE TABLE/INDEX IF NOT EXISTS`) — these could not apply to any
  fresh database before this fix.
- Collapse 3 duplicate cross-cutting abstractions that shadowed the
  BuildingBlocks versions and caused type-mismatch compile errors:
  IKrxDataService, IOutboxWriter (ReconcileTradeHandler), IClock
  (ApprovalWorkflow/ApprovalPolicy).
- Inject IClock (BuildingBlocks.Time) in place of direct
  DateTime.Now/UtcNow across 19 files to satisfy the architecture
  test AGENTS.md#DateTime-abstraction rule (13/13 architecture tests
  now pass, was 12/13).
- Register all new and previously-unregistered slices in
  Program.cs DI (SellDecision, TradeExecution, PortfolioReconciliation,
  Compliance, Features/ApprovalWorkflow) — the Host had never
  successfully completed a boot with this code present.
- Disable ("[DontRegister]") the older, route-colliding
  ApprovalWorkflow/ (Workstream H) endpoint set in favor of
  Features/ApprovalWorkflow/ (Workstream G, matches the documented
  Features/<Slice>/ convention); kept for its existing test coverage.
  See TECH_DEBT-017 for the follow-up decision needed.

Verified: dotnet build 0 errors/0 warnings; architecture tests 13/13;
unit tests 54/54 + 18/18; integration tests 34/36 (2 failures are a
local test-DB migration-journal/schema mismatch, not a code defect);
Host boots cleanly and registers all 34 endpoints.

New tech debt recorded: DEBT-017 (duplicate VS-03 implementation),
DEBT-018 (outbox write not co-transactional with entity write in
TradeExecution/PortfolioReconciliation), DEBT-019 (duplicate
BuildingBlocks-shadowing abstractions, partially resolved).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 19:53:38 +09:00
parent 75f72fbb72
commit b1e38ac374
55 changed files with 5620 additions and 196 deletions
@@ -7,13 +7,13 @@ namespace KArtSell.Modules.ModelOperations.Compliance;
public class AuditEvent
{
public Guid Id { get; set; }
public string EventType { get; set; } // MODEL_CREATED, APPROVAL_PROPOSED, APPROVAL_APPROVED, MODEL_ACTIVATED, SELL_DECISION_MADE, SELL_EXECUTED, BACKTEST_COMPLETED, DATA_CORRECTION
public string EntityType { get; set; } // MODEL, APPROVAL, SELL_DECISION, TRADE_EXECUTION
public required string EventType { get; set; } // MODEL_CREATED, APPROVAL_PROPOSED, APPROVAL_APPROVED, MODEL_ACTIVATED, SELL_DECISION_MADE, SELL_EXECUTED, BACKTEST_COMPLETED, DATA_CORRECTION
public required string EntityType { get; set; } // MODEL, APPROVAL, SELL_DECISION, TRADE_EXECUTION
public Guid EntityId { get; set; }
public string ActorEmail { get; set; }
public required string ActorEmail { get; set; }
public string? ActorRole { get; set; } // MAKER, CHECKER, SRE, SYSTEM
public DateTime EventAt { get; set; }
public string Result { get; set; } // SUCCESS, FAILURE, PARTIAL
public required string Result { get; set; } // SUCCESS, FAILURE, PARTIAL
public string? ErrorMessage { get; set; }
public Dictionary<string, object>? Details { get; set; } // Event-specific metadata
public string[]? EvidenceLinks { get; set; } // S3 artifact URLs
@@ -1,5 +1,8 @@
using System.Data;
using System.Text.Json;
using Dapper;
using KArtSell.BuildingBlocks.Observability;
using Microsoft.Extensions.Logging;
using NpgsqlTypes;
namespace KArtSell.Modules.ModelOperations.Compliance;
@@ -57,7 +60,7 @@ public class AuditSql
EventAt = eventAt,
Result = result,
ErrorMessage = errorMessage,
Details = details == null ? null : Json.Serialize(details),
Details = details == null ? null : JsonSerializer.Serialize(details),
EvidenceLinks = evidenceLinks,
IpAddress = ipAddress,
UserAgent = userAgent,
@@ -11,7 +11,7 @@ public class GdprRetention
public Guid? CustomerId { get; set; }
public string[]? DataCategories { get; set; } // PII, EMAIL, TRADING_HISTORY, PORTFOLIO_DATA, etc.
public DateTime RetentionEndsAt { get; set; }
public string PurgeStatus { get; set; } // PENDING, PURGED, EXCEPTION
public required string PurgeStatus { get; set; } // PENDING, PURGED, EXCEPTION
public DateTime? PurgedAt { get; set; }
public string? ExceptionReason { get; set; }
public DateTime PublishedAt { get; set; }
@@ -1,11 +1,13 @@
using MediatR;
using System.Data;
using KArtSell.BuildingBlocks.Time;
using Microsoft.Extensions.Logging;
namespace KArtSell.Modules.ModelOperations.Compliance;
/// <summary>
/// Command to log an audit event.
/// </summary>
public class LogAuditEventCommand : ICommand
public class LogAuditEventCommand
{
public Guid Id { get; set; } = Guid.NewGuid();
public string EventType { get; set; } = string.Empty;
@@ -13,7 +15,7 @@ public class LogAuditEventCommand : ICommand
public Guid EntityId { get; set; }
public string ActorEmail { get; set; } = string.Empty;
public string? ActorRole { get; set; }
public DateTime EventAt { get; set; } = DateTime.UtcNow;
public DateTime EventAt { get; set; }
public string Result { get; set; } = "SUCCESS";
public string? ErrorMessage { get; set; }
public Dictionary<string, object>? Details { get; set; }
@@ -27,19 +29,22 @@ public class LogAuditEventCommand : ICommand
/// Handler to log audit events (immutable insert).
/// Idempotent: Multiple calls with same Id result in same outcome.
/// </summary>
public class LogAuditEventHandler : ICommandHandler<LogAuditEventCommand>
public class LogAuditEventCommandHandler
{
private readonly IDbConnection _db;
private readonly AuditSql _sql;
private readonly ILogger<LogAuditEventHandler> _logger;
private readonly IClock _clock;
private readonly ILogger<LogAuditEventCommandHandler> _logger;
public LogAuditEventHandler(
public LogAuditEventCommandHandler(
IDbConnection db,
AuditSql sql,
ILogger<LogAuditEventHandler> logger)
IClock clock,
ILogger<LogAuditEventCommandHandler> logger)
{
_db = db;
_sql = sql;
_clock = clock;
_logger = logger;
}
@@ -67,7 +72,7 @@ public class LogAuditEventHandler : ICommandHandler<LogAuditEventCommand>
ct);
// Track GDPR retention for 7 years (FSS requirement)
var retentionEndsAt = DateTime.UtcNow.AddYears(7);
var retentionEndsAt = _clock.UtcNow.UtcDateTime.AddYears(7);
await _sql.InsertGdprRetentionAsync(
_db,
Guid.NewGuid(),
@@ -1,16 +1,17 @@
using System.Data;
using Hangfire;
using MediatR;
using Microsoft.Extensions.Logging;
namespace KArtSell.Modules.ModelOperations.Compliance;
/// <summary>
/// Command to process GDPR right-to-be-forgotten request.
/// </summary>
public class ProcessGdprRequestCommand : ICommand
public class ProcessGdprRequestCommand
{
public Guid TrackingId { get; set; } = Guid.NewGuid();
public Guid CustomerId { get; set; }
public DateTime RequestDate { get; set; } = DateTime.UtcNow;
public DateTime RequestDate { get; set; }
public string Reason { get; set; } = "Right to be forgotten (GDPR Article 17)";
public Guid CorrelationId { get; set; }
}
@@ -19,7 +20,7 @@ public class ProcessGdprRequestCommand : ICommand
/// Handler to process GDPR requests asynchronously.
/// Queues Hangfire job for redaction (soft delete via JSONB anonymization).
/// </summary>
public class ProcessGdprRequestHandler : ICommandHandler<ProcessGdprRequestCommand>
public class ProcessGdprRequestHandler
{
private readonly IBackgroundJobClient _backgroundJobClient;
private readonly ILogger<ProcessGdprRequestHandler> _logger;
@@ -1,4 +1,7 @@
using FastEndpoints;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace KArtSell.Modules.ModelOperations.Compliance;
@@ -13,7 +16,7 @@ public class QueryAuditEventsRequest
public DateTime? DateFrom { get; set; }
public DateTime? DateTo { get; set; }
public string? ActorEmail { get; set; }
public int Skip { get; set; } = 0;
public int Skip { get; set; }
public int Take { get; set; } = 50;
}
@@ -57,10 +60,11 @@ public class QueryAuditEventsEndpoint : Endpoint<QueryAuditEventsRequest, QueryA
{
Get("/audit/events");
AllowAnonymous(); // RBAC enforced at handler level (Compliance Officer role)
Description(d => d
.WithName("Query Audit Events")
.WithDescription("Query immutable audit trail with optional filters")
.WithOpenApi());
Summary(x =>
{
x.Summary = "Query Audit Events";
x.Description = "Query immutable audit trail with optional filters";
});
}
public override async Task HandleAsync(QueryAuditEventsRequest req, CancellationToken ct)
@@ -103,19 +107,20 @@ public class QueryAuditEventsEndpoint : Endpoint<QueryAuditEventsRequest, QueryA
Take = req.Take
};
await SendOkAsync(response);
await Send.OkAsync(response, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to query audit events");
await SendInternalErrorResponse();
await SendInternalErrorResponse(ct);
}
}
private async Task SendInternalErrorResponse()
private async Task SendInternalErrorResponse(CancellationToken ct)
{
await SendAsync(
await Send.ResponseAsync(
new QueryAuditEventsResponse(),
statusCode: StatusCodes.Status500InternalServerError);
StatusCodes.Status500InternalServerError,
ct);
}
}
@@ -1,5 +1,7 @@
using FastEndpoints;
using MediatR;
using KArtSell.BuildingBlocks.Time;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace KArtSell.Modules.ModelOperations.Compliance;
@@ -23,12 +25,14 @@ public class GdprRequestResponseDto
public class SubmitGdprRequestEndpoint : Endpoint<SubmitGdprRequestDto, GdprRequestResponseDto>
{
private readonly IMediator _mediator;
private readonly ProcessGdprRequestHandler _handler;
private readonly IClock _clock;
private readonly ILogger<SubmitGdprRequestEndpoint> _logger;
public SubmitGdprRequestEndpoint(IMediator mediator, ILogger<SubmitGdprRequestEndpoint> logger)
public SubmitGdprRequestEndpoint(ProcessGdprRequestHandler handler, IClock clock, ILogger<SubmitGdprRequestEndpoint> logger)
{
_mediator = mediator;
_handler = handler;
_clock = clock;
_logger = logger;
}
@@ -36,10 +40,11 @@ public class SubmitGdprRequestEndpoint : Endpoint<SubmitGdprRequestDto, GdprRequ
{
Post("/compliance/gdpr-request");
AllowAnonymous(); // RBAC enforced at handler level (Data Admin/Compliance Officer role)
Description(d => d
.WithName("Submit GDPR Request")
.WithDescription("Submit right-to-be-forgotten request for customer data redaction")
.WithOpenApi());
Summary(x =>
{
x.Summary = "Submit GDPR Request";
x.Description = "Submit right-to-be-forgotten request for customer data redaction";
});
}
public override async Task HandleAsync(SubmitGdprRequestDto req, CancellationToken ct)
@@ -47,6 +52,7 @@ public class SubmitGdprRequestEndpoint : Endpoint<SubmitGdprRequestDto, GdprRequ
try
{
var trackingId = Guid.NewGuid();
var now = _clock.UtcNow.UtcDateTime;
var correlationId = HttpContext.Request.Headers.TryGetValue("X-Correlation-ID", out var header)
? Guid.Parse(header.ToString())
: Guid.NewGuid();
@@ -55,21 +61,22 @@ public class SubmitGdprRequestEndpoint : Endpoint<SubmitGdprRequestDto, GdprRequ
{
TrackingId = trackingId,
CustomerId = req.CustomerId,
RequestDate = now,
Reason = req.Reason,
CorrelationId = correlationId
};
await _mediator.Send(command, ct);
await _handler.Handle(command, ct);
var response = new GdprRequestResponseDto
{
GdprTrackingId = trackingId,
Status = "IN_PROGRESS",
EstimatedCompletion = DateTime.UtcNow.AddHours(24),
EstimatedCompletion = now.AddHours(24),
Message = $"GDPR request {trackingId} submitted. Redaction will complete within 24 hours."
};
await SendAsync(response, statusCode: StatusCodes.Status202Accepted);
await Send.ResponseAsync(response, StatusCodes.Status202Accepted, ct);
_logger.LogInformation(
"GDPR request {TrackingId} submitted for customer {CustomerId}",
@@ -78,9 +85,10 @@ public class SubmitGdprRequestEndpoint : Endpoint<SubmitGdprRequestDto, GdprRequ
catch (Exception ex)
{
_logger.LogError(ex, "Failed to submit GDPR request for customer {CustomerId}", req.CustomerId);
await SendAsync(
await Send.ResponseAsync(
new GdprRequestResponseDto { Message = "Failed to submit request" },
statusCode: StatusCodes.Status500InternalServerError);
StatusCodes.Status500InternalServerError,
ct);
}
}
}