From 2ccf74c41082afd51385dd7f6c07bc92c189aa2a Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Fri, 7 Aug 2026 23:21:04 +0900 Subject: [PATCH 1/5] fix: Release build breakage + Dapper mapping bugs in VS-03/VS-04/Phase3-K - KArtSell.Host.csproj: FrontendFiles glob was evaluated at project-load time, before pnpm build ran, so it copied stale/missing Vite-hashed filenames every Release build. Move the glob inside the target, after the build Exec. - ApprovalSql/AuditSql/TradeSql: fix live-DB integration failures never caught by unit tests: DateOnly and inet columns can't be bound/read directly through Dapper without conversion; kis_response (jsonb) read as JsonElement threw InvalidCastException; GdprRetention.RetentionEndsAt was typed DateTime against a DATE column. - TradeSql: UpdateTradeStatusAsync only ever persisted status/kis_response /error_message, silently dropping kis_order_id, executed_quantity, unit_price, total_amount, commission, net_proceeds and the execution/ settlement timestamps on every call. Changed it to take the Trade aggregate so the full state transition persists. - TradeSql: add a static ctor setting Dapper.DefaultTypeMap. MatchNamesWithUnderscores = true. The repo's [ModuleInitializer] in KArtSell.BuildingBlocks only fires once that assembly is actually loaded; TradeSql/Trade never reference a BuildingBlocks type, so under test isolation (or any host that queries a trade before touching BuildingBlocks) every snake_case column silently mapped to null/default. - Test fixes: seed the FK prerequisites (model_operations.models, sell_decisions) that ApprovalWorkflowTests/TradeExecutionTests were missing, correct a SellPriorityRanker test input to match the approved VS-10-SLICE_SPEC age-boost threshold, and fix a GDPR redaction assertion that called ToString() on a Dictionary instead of inspecting its values. 12 DbUpMigrationTests failures remain and are unrelated to this fix: the kartsell DB user isn't the owner of kartsell_migration_test, so DbUp's fresh-database rehearsal can't DROP/CREATE it. Needs a DBA grant. Co-Authored-By: Claude Haiku 4.5 --- src/KArtSell.Host/KArtSell.Host.csproj | 10 ++-- .../ApprovalWorkflow/ApprovalSql.cs | 4 +- .../Compliance/AuditSql.cs | 4 +- .../Compliance/GdprRetention.cs | 2 +- .../TradeExecution/Trade.cs | 10 ++-- .../TradeExecution/TradeHandlers.cs | 54 +++---------------- .../TradeExecution/TradeSql.cs | 50 ++++++++++++----- .../ApprovalWorkflow/ApprovalWorkflowTests.cs | 11 ++++ .../Compliance/AuditTrailTests.cs | 8 ++- .../SellDecision/SellDecisionTests.cs | 4 +- .../TradeExecution/TradeExecutionTests.cs | 54 ++++++++++++++----- 11 files changed, 119 insertions(+), 92 deletions(-) diff --git a/src/KArtSell.Host/KArtSell.Host.csproj b/src/KArtSell.Host/KArtSell.Host.csproj index 2e8d1492..e5b986a8 100644 --- a/src/KArtSell.Host/KArtSell.Host.csproj +++ b/src/KArtSell.Host/KArtSell.Host.csproj @@ -4,16 +4,18 @@ + + + + - - - - diff --git a/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalSql.cs b/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalSql.cs index 897d2008..62ed678c 100644 --- a/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalSql.cs +++ b/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalSql.cs @@ -69,7 +69,7 @@ public class ApprovalSql const string sql = """ INSERT INTO model_operations.approval_proposals (id, model_id, status, created_by, created_at, justification, effective_at, published_at, revision, correlation_id) - VALUES (@id, @modelId, @status, @createdBy, @createdAt, @justification, @effectiveAt, @publishedAt, 1, @correlationId) + VALUES (@id, @modelId, @status, @createdBy, @createdAt, @justification, @effectiveAt::date, @publishedAt, 1, @correlationId) """; await conn.ExecuteAsync(sql, new @@ -80,7 +80,7 @@ public class ApprovalSql createdBy, createdAt = _clock.UtcNow, justification, - effectiveAt, + effectiveAt = effectiveAt.ToString("yyyy-MM-dd"), // Dapper: DateOnly cannot be used as a parameter value directly publishedAt, correlationId }); diff --git a/src/KArtSell.Modules.ModelOperations/Compliance/AuditSql.cs b/src/KArtSell.Modules.ModelOperations/Compliance/AuditSql.cs index bebdad94..f7d69e1c 100644 --- a/src/KArtSell.Modules.ModelOperations/Compliance/AuditSql.cs +++ b/src/KArtSell.Modules.ModelOperations/Compliance/AuditSql.cs @@ -135,7 +135,7 @@ public class AuditSql // Get paginated results var sql = $""" SELECT id, event_type, entity_type, entity_id, actor_email, actor_role, event_at, - result, error_message, details, evidence_links, ip_address, user_agent, + result, error_message, details, evidence_links, ip_address::text as ip_address, user_agent, published_at, correlation_id, revision FROM compliance.audit_events WHERE {whereClause} @@ -162,7 +162,7 @@ public class AuditSql { const string sql = """ SELECT id, event_type, entity_type, entity_id, actor_email, actor_role, event_at, - result, error_message, details, evidence_links, ip_address, user_agent, + result, error_message, details, evidence_links, ip_address::text as ip_address, user_agent, published_at, correlation_id, revision FROM compliance.audit_events WHERE id = @EventId diff --git a/src/KArtSell.Modules.ModelOperations/Compliance/GdprRetention.cs b/src/KArtSell.Modules.ModelOperations/Compliance/GdprRetention.cs index e2d7b884..0adccb22 100644 --- a/src/KArtSell.Modules.ModelOperations/Compliance/GdprRetention.cs +++ b/src/KArtSell.Modules.ModelOperations/Compliance/GdprRetention.cs @@ -10,7 +10,7 @@ public class GdprRetention public Guid EventId { get; set; } public Guid? CustomerId { get; set; } public string[]? DataCategories { get; set; } // PII, EMAIL, TRADING_HISTORY, PORTFOLIO_DATA, etc. - public DateTime RetentionEndsAt { get; set; } + public DateOnly RetentionEndsAt { get; set; } public required string PurgeStatus { get; set; } // PENDING, PURGED, EXCEPTION public DateTime? PurgedAt { get; set; } public string? ExceptionReason { get; set; } diff --git a/src/KArtSell.Modules.ModelOperations/TradeExecution/Trade.cs b/src/KArtSell.Modules.ModelOperations/TradeExecution/Trade.cs index d7ce3783..49a73361 100644 --- a/src/KArtSell.Modules.ModelOperations/TradeExecution/Trade.cs +++ b/src/KArtSell.Modules.ModelOperations/TradeExecution/Trade.cs @@ -26,7 +26,7 @@ public class Trade public decimal? Commission { get; set; } public decimal? NetProceeds { get; set; } public string? ErrorMessage { get; set; } - public JsonElement? KisResponse { get; set; } + public string? KisResponse { get; set; } public DateTime? ExecutionTimestamp { get; set; } public DateTime? SettlementTimestamp { get; set; } public DateTime PublishedAt { get; set; } @@ -55,14 +55,14 @@ public class Trade { Status = TradeStatus.Submitted; KisOrderId = kisOrderId; - KisResponse = response; + KisResponse = response.ToString(); Revision++; } public void MarkAccepted(JsonElement response) { Status = TradeStatus.Accepted; - KisResponse = response; + KisResponse = response.ToString(); Revision++; } @@ -73,7 +73,7 @@ public class Trade TotalAmount = executedQty * unitPrice; Status = executedQty >= Quantity ? TradeStatus.FullyFilled : TradeStatus.PartiallyFilled; ExecutionTimestamp = now; - KisResponse = response; + KisResponse = response.ToString(); Revision++; } @@ -98,7 +98,7 @@ public class Trade public void MarkErrored(KisTradeExecutionException exception) { ErrorMessage = exception.Message; - KisResponse = exception.KisResponse; + KisResponse = exception.KisResponse?.ToString(); Revision++; } } diff --git a/src/KArtSell.Modules.ModelOperations/TradeExecution/TradeHandlers.cs b/src/KArtSell.Modules.ModelOperations/TradeExecution/TradeHandlers.cs index 1179e413..21aa73b3 100644 --- a/src/KArtSell.Modules.ModelOperations/TradeExecution/TradeHandlers.cs +++ b/src/KArtSell.Modules.ModelOperations/TradeExecution/TradeHandlers.cs @@ -57,14 +57,7 @@ public class SubmitTradeHandler ); trade.MarkSubmitted(orderId, response); - await _sql.UpdateTradeStatusAsync( - trade.Id, - TradeStatus.Submitted, - response, - null, - command.CorrelationId, - ct - ); + await _sql.UpdateTradeStatusAsync(trade, response, null, ct); await PublishEventAsync( "TradeSubmitted", @@ -84,14 +77,7 @@ public class SubmitTradeHandler catch (KisTradeExecutionException ex) { trade.MarkErrored(ex); - await _sql.UpdateTradeStatusAsync( - trade.Id, - trade.Status, - ex.KisResponse, - ex.Message, - command.CorrelationId, - ct - ); + await _sql.UpdateTradeStatusAsync(trade, ex.KisResponse, ex.Message, ct); _logger.LogError( "Trade submission failed: {TradeId} {Classification}", @@ -163,14 +149,7 @@ public class PollTradeStatusHandler trade.MarkFilled(executedQty, unitPrice, response, _clock.UtcNow.UtcDateTime); } - await _sql.UpdateTradeStatusAsync( - trade.Id, - trade.Status, - response, - null, - command.CorrelationId, - ct - ); + await _sql.UpdateTradeStatusAsync(trade, response, null, ct); if (trade.Status is TradeStatus.FullyFilled) { @@ -195,14 +174,7 @@ public class PollTradeStatusHandler } catch (KisTradeExecutionException ex) { - await _sql.UpdateTradeStatusAsync( - trade.Id, - trade.Status, - ex.KisResponse, - ex.Message, - command.CorrelationId, - ct - ); + await _sql.UpdateTradeStatusAsync(trade, ex.KisResponse, ex.Message, ct); _logger.LogError("Failed to poll trade status: {TradeId}", trade.Id); } @@ -262,14 +234,7 @@ public class ConfirmSettlementHandler if (success) { trade.MarkConfirmed(_clock.UtcNow.UtcDateTime, command.Commission); - await _sql.UpdateTradeStatusAsync( - trade.Id, - TradeStatus.Confirmed, - response, - null, - command.CorrelationId, - ct - ); + await _sql.UpdateTradeStatusAsync(trade, response, null, ct); await TradeOutboxPublisher.PublishAsync( _connectionFactory, @@ -290,14 +255,7 @@ public class ConfirmSettlementHandler } catch (KisTradeExecutionException ex) { - await _sql.UpdateTradeStatusAsync( - trade.Id, - trade.Status, - ex.KisResponse, - ex.Message, - command.CorrelationId, - ct - ); + await _sql.UpdateTradeStatusAsync(trade, ex.KisResponse, ex.Message, ct); _logger.LogError("Failed to confirm settlement: {TradeId}", trade.Id); } diff --git a/src/KArtSell.Modules.ModelOperations/TradeExecution/TradeSql.cs b/src/KArtSell.Modules.ModelOperations/TradeExecution/TradeSql.cs index 542ec168..b1df1a00 100644 --- a/src/KArtSell.Modules.ModelOperations/TradeExecution/TradeSql.cs +++ b/src/KArtSell.Modules.ModelOperations/TradeExecution/TradeSql.cs @@ -12,7 +12,7 @@ public interface ITradeSql Task> GetTradesByStatusAsync(TradeStatus status, Guid correlationId, CancellationToken ct = default); Task> GetTradesByDecisionIdAsync(Guid sellDecisionId, Guid correlationId, CancellationToken ct = default); Task InsertTradeAsync(Trade trade, CancellationToken ct = default); - Task UpdateTradeStatusAsync(Guid tradeId, TradeStatus newStatus, JsonElement? kisResponse, string? errorMessage, Guid correlationId, CancellationToken ct = default); + Task UpdateTradeStatusAsync(Trade trade, JsonElement? kisResponse, string? errorMessage, CancellationToken ct = default); Task CountTradesByStatusAsync(TradeStatus status, CancellationToken ct = default); } @@ -21,6 +21,16 @@ public class TradeSql : ITradeSql private readonly NpgsqlDataSource _dataSource; private readonly ILogger _logger; + static TradeSql() + { + // KArtSell.BuildingBlocks.Data.DapperBootstrap sets this via [ModuleInitializer], but that + // only fires once its assembly is actually loaded into the process. Nothing in this class + // references a BuildingBlocks type, so under test isolation (or any host that queries Trade + // before touching BuildingBlocks) that assembly load - and the mapping - can be skipped, + // silently nulling out every snake_case column (kis_order_id, sell_decision_id, ...). + Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true; + } + public TradeSql(NpgsqlDataSource dataSource, ILogger logger) { _dataSource = dataSource; @@ -33,7 +43,7 @@ public class TradeSql : ITradeSql const string sql = """ SELECT id, sell_decision_id, kis_order_id, status, quantity, executed_quantity, - unit_price, total_amount, commission, net_proceeds, error_message, kis_response, + unit_price, total_amount, commission, net_proceeds, error_message, kis_response::text as kis_response, execution_timestamp, settlement_timestamp, published_at, correlation_id, revision FROM model_operations.trades WHERE id = @tradeId @@ -61,7 +71,7 @@ public class TradeSql : ITradeSql const string sql = """ SELECT id, sell_decision_id, kis_order_id, status, quantity, executed_quantity, - unit_price, total_amount, commission, net_proceeds, error_message, kis_response, + unit_price, total_amount, commission, net_proceeds, error_message, kis_response::text as kis_response, execution_timestamp, settlement_timestamp, published_at, correlation_id, revision FROM model_operations.trades WHERE kis_order_id = @kisOrderId @@ -82,7 +92,7 @@ public class TradeSql : ITradeSql const string sql = """ SELECT id, sell_decision_id, kis_order_id, status, quantity, executed_quantity, - unit_price, total_amount, commission, net_proceeds, error_message, kis_response, + unit_price, total_amount, commission, net_proceeds, error_message, kis_response::text as kis_response, execution_timestamp, settlement_timestamp, published_at, correlation_id, revision FROM model_operations.trades WHERE status = @status @@ -102,7 +112,7 @@ public class TradeSql : ITradeSql const string sql = """ SELECT id, sell_decision_id, kis_order_id, status, quantity, executed_quantity, - unit_price, total_amount, commission, net_proceeds, error_message, kis_response, + unit_price, total_amount, commission, net_proceeds, error_message, kis_response::text as kis_response, execution_timestamp, settlement_timestamp, published_at, correlation_id, revision FROM model_operations.trades WHERE sell_decision_id = @sellDecisionId @@ -143,7 +153,7 @@ public class TradeSql : ITradeSql trade.Commission, trade.NetProceeds, trade.ErrorMessage, - kisResponse = trade.KisResponse?.ToString(), + trade.KisResponse, trade.ExecutionTimestamp, trade.SettlementTimestamp, trade.PublishedAt, @@ -155,11 +165,9 @@ public class TradeSql : ITradeSql } public async Task UpdateTradeStatusAsync( - Guid tradeId, - TradeStatus newStatus, + Trade trade, JsonElement? kisResponse, string? errorMessage, - Guid correlationId, CancellationToken ct = default) { using var connection = await _dataSource.OpenConnectionAsync(ct); @@ -173,6 +181,14 @@ public class TradeSql : ITradeSql UPDATE model_operations.trades SET status = @newStatus, + kis_order_id = COALESCE(@kisOrderId, kis_order_id), + executed_quantity = COALESCE(@executedQuantity, executed_quantity), + unit_price = COALESCE(@unitPrice, unit_price), + total_amount = COALESCE(@totalAmount, total_amount), + commission = COALESCE(@commission, commission), + net_proceeds = COALESCE(@netProceeds, net_proceeds), + execution_timestamp = COALESCE(@executionTimestamp, execution_timestamp), + settlement_timestamp = COALESCE(@settlementTimestamp, settlement_timestamp), kis_response = COALESCE(@kisResponse::jsonb, kis_response), error_message = COALESCE(@errorMessage, error_message), revision = revision + 1 @@ -182,14 +198,22 @@ public class TradeSql : ITradeSql await connection.ExecuteAsync(sql, new { id = Guid.NewGuid(), - tradeId, - newStatus = newStatus.ToString(), + tradeId = trade.Id, + newStatus = trade.Status.ToString(), + kisOrderId = trade.KisOrderId, + executedQuantity = trade.ExecutedQuantity, + unitPrice = trade.UnitPrice, + totalAmount = trade.TotalAmount, + commission = trade.Commission, + netProceeds = trade.NetProceeds, + executionTimestamp = trade.ExecutionTimestamp, + settlementTimestamp = trade.SettlementTimestamp, kisResponse = kisResponse?.ToString(), errorMessage, - correlationId + correlationId = trade.CorrelationId }); - _logger.LogInformation("Updated trade {TradeId} status to {Status}", tradeId, newStatus); + _logger.LogInformation("Updated trade {TradeId} status to {Status}", trade.Id, trade.Status); } public async Task CountTradesByStatusAsync(TradeStatus status, CancellationToken ct = default) diff --git a/tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs b/tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs index 791498f2..ee9440fb 100644 --- a/tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs +++ b/tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs @@ -3,6 +3,7 @@ namespace KArtSell.Integration.Tests.ApprovalWorkflow; using System; using System.Collections.Generic; using System.Threading.Tasks; +using Dapper; using Xunit; using KArtSell.BuildingBlocks.Time; using KArtSell.Modules.ModelOperations.ApprovalWorkflow; @@ -165,6 +166,7 @@ public class ApprovalWorkflowTests : IAsyncLifetime var id = Guid.NewGuid(); var modelId = Guid.NewGuid(); var correlationId = Guid.NewGuid(); + await SeedModelAsync(modelId); // Act await _sql.InsertProposalAsync( @@ -185,6 +187,15 @@ public class ApprovalWorkflowTests : IAsyncLifetime Assert.Equal(modelId, retrieved.ModelId); Assert.Equal(correlationId, retrieved.CorrelationId); } + + private async Task SeedModelAsync(Guid modelId) + { + await using var conn = new Npgsql.NpgsqlConnection(_connectionString); + await conn.OpenAsync(); + await conn.ExecuteAsync( + "INSERT INTO model_operations.models (id, ticker, correlation_id) VALUES (@Id, @Ticker, @CorrelationId)", + new { Id = modelId, Ticker = "TEST", CorrelationId = Guid.NewGuid() }); + } } public class InMemoryOutbox : IOutbox diff --git a/tests/KArtSell.Integration.Tests/Compliance/AuditTrailTests.cs b/tests/KArtSell.Integration.Tests/Compliance/AuditTrailTests.cs index ea22d52e..bf1fdc2a 100644 --- a/tests/KArtSell.Integration.Tests/Compliance/AuditTrailTests.cs +++ b/tests/KArtSell.Integration.Tests/Compliance/AuditTrailTests.cs @@ -106,6 +106,11 @@ public class AuditTrailTests : IAsyncLifetime var customerId = Guid.NewGuid(); var retentionId = Guid.NewGuid(); + await _sql.InsertAuditEventAsync( + _db, eventId, AuditEventTypes.ModelActivated, AuditEntityTypes.Model, + Guid.NewGuid(), "customer@company.com", null, DateTime.UtcNow, "SUCCESS", null, + null, null, null, null, Guid.NewGuid(), CancellationToken.None); + // Act await _sql.InsertGdprRetentionAsync( _db, retentionId, eventId, customerId, @@ -175,7 +180,8 @@ public class AuditTrailTests : IAsyncLifetime // Assert var @event = await _sql.GetAuditEventByIdAsync(_db, eventId, CancellationToken.None); Assert.NotNull(@event); - Assert.Contains("", @event.Details?.ToString() ?? ""); + Assert.Equal("", @event.Details?["actor_email"].ToString()); + Assert.Equal("", @event.Details?["customer_id"].ToString()); } private const string TestConnectionString = diff --git a/tests/KArtSell.Integration.Tests/SellDecision/SellDecisionTests.cs b/tests/KArtSell.Integration.Tests/SellDecision/SellDecisionTests.cs index 67451a26..a9bb2fdb 100644 --- a/tests/KArtSell.Integration.Tests/SellDecision/SellDecisionTests.cs +++ b/tests/KArtSell.Integration.Tests/SellDecision/SellDecisionTests.cs @@ -118,8 +118,8 @@ public class SellPriorityRankerTests [Fact] public void CalculateScore_HardImpairment_ReturnsLowestScore() { - var score = _ranker.CalculateScore(SellPriority.HardImpairment, fundAgeDays: 200, liquidityPercent: 0.5m); - Assert.Equal(950m, score); // 1000 - 50 (age boost) + var score = _ranker.CalculateScore(SellPriority.HardImpairment, fundAgeDays: 400, liquidityPercent: 0.5m); + Assert.Equal(950m, score); // 1000 - 50 (age boost, fundAgeDays > 365 per VS-10-SLICE_SPEC.md) } [Fact] diff --git a/tests/KArtSell.Integration.Tests/TradeExecution/TradeExecutionTests.cs b/tests/KArtSell.Integration.Tests/TradeExecution/TradeExecutionTests.cs index b8d4e011..20e44a38 100644 --- a/tests/KArtSell.Integration.Tests/TradeExecution/TradeExecutionTests.cs +++ b/tests/KArtSell.Integration.Tests/TradeExecution/TradeExecutionTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Dapper; using KArtSell.Modules.ModelOperations.TradeExecution; using Microsoft.Extensions.Logging; using Npgsql; @@ -31,11 +32,31 @@ public class TradeExecutionTests : IAsyncLifetime await _dataSource.DisposeAsync(); } + private async Task SeedSellDecisionAsync() + { + await using var connection = await _dataSource.OpenConnectionAsync(); + var modelId = Guid.NewGuid(); + await connection.ExecuteAsync( + "INSERT INTO model_operations.models (id, ticker, correlation_id) VALUES (@Id, @Ticker, @CorrelationId)", + new { Id = modelId, Ticker = "TEST", CorrelationId = Guid.NewGuid() }); + + var sellDecisionId = Guid.NewGuid(); + await connection.ExecuteAsync( + """ + INSERT INTO model_operations.sell_decisions + (id, model_id, status, created_by, published_at, correlation_id) + VALUES (@Id, @ModelId, 'PENDING', 'test@company.com', NOW(), @CorrelationId) + """, + new { Id = sellDecisionId, ModelId = modelId, CorrelationId = Guid.NewGuid() }); + + return sellDecisionId; + } + [Fact] public async Task CreateTrade_WithValidData_ShouldInsertSuccessfully() { var sql = new TradeSql(_dataSource, _logger); - var sellDecisionId = Guid.NewGuid(); + var sellDecisionId = await SeedSellDecisionAsync(); var correlationId = Guid.NewGuid(); var trade = Trade.Create(sellDecisionId, 1000, correlationId, DateTime.UtcNow); @@ -55,14 +76,14 @@ public class TradeExecutionTests : IAsyncLifetime { var sql = new TradeSql(_dataSource, _logger); var correlationId = Guid.NewGuid(); - var trade = Trade.Create(Guid.NewGuid(), 1000, correlationId, DateTime.UtcNow); + var trade = Trade.Create(await SeedSellDecisionAsync(), 1000, correlationId, DateTime.UtcNow); await sql.InsertTradeAsync(trade); var response = JsonDocument.Parse("{}").RootElement; trade.MarkSubmitted("KIS-ORDER-123", response); - await sql.UpdateTradeStatusAsync(trade.Id, TradeStatus.Submitted, response, null, correlationId); + await sql.UpdateTradeStatusAsync(trade, response, null); var retrieved = await sql.GetTradeByIdAsync(trade.Id, correlationId); @@ -76,14 +97,14 @@ public class TradeExecutionTests : IAsyncLifetime { var sql = new TradeSql(_dataSource, _logger); var correlationId = Guid.NewGuid(); - var trade = Trade.Create(Guid.NewGuid(), 1000, correlationId, DateTime.UtcNow); + var trade = Trade.Create(await SeedSellDecisionAsync(), 1000, correlationId, DateTime.UtcNow); await sql.InsertTradeAsync(trade); var response = JsonDocument.Parse("{}").RootElement; trade.MarkFilled(1000, 49.95m, response, DateTime.UtcNow); - await sql.UpdateTradeStatusAsync(trade.Id, TradeStatus.FullyFilled, response, null, correlationId); + await sql.UpdateTradeStatusAsync(trade, response, null); var retrieved = await sql.GetTradeByIdAsync(trade.Id, correlationId); @@ -100,8 +121,8 @@ public class TradeExecutionTests : IAsyncLifetime var sql = new TradeSql(_dataSource, _logger); var correlationId = Guid.NewGuid(); - var trade1 = Trade.Create(Guid.NewGuid(), 1000, correlationId, DateTime.UtcNow); - var trade2 = Trade.Create(Guid.NewGuid(), 2000, correlationId, DateTime.UtcNow); + var trade1 = Trade.Create(await SeedSellDecisionAsync(), 1000, correlationId, DateTime.UtcNow); + var trade2 = Trade.Create(await SeedSellDecisionAsync(), 2000, correlationId, DateTime.UtcNow); await sql.InsertTradeAsync(trade1); await sql.InsertTradeAsync(trade2); @@ -118,14 +139,14 @@ public class TradeExecutionTests : IAsyncLifetime { var sql = new TradeSql(_dataSource, _logger); var correlationId = Guid.NewGuid(); - var trade = Trade.Create(Guid.NewGuid(), 1000, correlationId, DateTime.UtcNow); + var trade = Trade.Create(await SeedSellDecisionAsync(), 1000, correlationId, DateTime.UtcNow); await sql.InsertTradeAsync(trade); trade.TotalAmount = 49950m; trade.MarkConfirmed(DateTime.UtcNow, 50m); - await sql.UpdateTradeStatusAsync(trade.Id, TradeStatus.Confirmed, null, null, correlationId); + await sql.UpdateTradeStatusAsync(trade, null, null); var retrieved = await sql.GetTradeByIdAsync(trade.Id, correlationId); @@ -140,11 +161,16 @@ public class TradeExecutionTests : IAsyncLifetime { var sql = new TradeSql(_dataSource, _logger); var correlationId = Guid.NewGuid(); - var trade = Trade.Create(Guid.NewGuid(), 1000, correlationId, DateTime.UtcNow); + var trade = Trade.Create(await SeedSellDecisionAsync(), 1000, correlationId, DateTime.UtcNow); await sql.InsertTradeAsync(trade); - await sql.UpdateTradeStatusAsync(trade.Id, TradeStatus.Submitted, null, null, correlationId); - await sql.UpdateTradeStatusAsync(trade.Id, TradeStatus.Accepted, null, null, correlationId); + + var response = JsonDocument.Parse("{}").RootElement; + trade.MarkSubmitted("KIS-1", response); + await sql.UpdateTradeStatusAsync(trade, response, null); + + trade.MarkAccepted(response); + await sql.UpdateTradeStatusAsync(trade, response, null); var retrieved = await sql.GetTradeByIdAsync(trade.Id, correlationId); @@ -158,8 +184,8 @@ public class TradeExecutionTests : IAsyncLifetime var sql = new TradeSql(_dataSource, _logger); var correlationId = Guid.NewGuid(); - var trade1 = Trade.Create(Guid.NewGuid(), 1000, correlationId, DateTime.UtcNow); - var trade2 = Trade.Create(Guid.NewGuid(), 2000, correlationId, DateTime.UtcNow); + var trade1 = Trade.Create(await SeedSellDecisionAsync(), 1000, correlationId, DateTime.UtcNow); + var trade2 = Trade.Create(await SeedSellDecisionAsync(), 2000, correlationId, DateTime.UtcNow); await sql.InsertTradeAsync(trade1); await sql.InsertTradeAsync(trade2); From 8ed2bcf56f2d94b1731f2be5e1ae1a68e7c4b7fd Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Fri, 7 Aug 2026 23:36:08 +0900 Subject: [PATCH 2/5] fix: Dapper underscore-mapping race condition affects AuditSql too, not just TradeSql The static-ctor guard added to TradeSql in the previous commit was a symptom fix. Confirmed the same bug independently affects AuditSql: running the Compliance test filter in isolation (no other class that happens to touch a BuildingBlocks type first) reproduced the identical failure mode - every snake_case column (event_type, purge_status, ...) silently mapped to null. Root cause: KArtSell.BuildingBlocks.Data.DapperBootstrap's [ModuleInitializer] only runs once that assembly is actually loaded, and a `using` directive for a BuildingBlocks namespace does not force that load - only an executed reference to one of its types does. Any Sql class that never actually touches a BuildingBlocks type at runtime is exposed, and this is a property of *when* a given test/request happens to run relative to everything else in the process, not of any one class. Replaced the ad-hoc TradeSql static ctor with one [ModuleInitializer] per module assembly (KArtSell.Modules.ModelOperations, KArtSell.Modules.SignalEngine). Every Sql/reader class lives inside its own module's assembly, so a module initializer there is guaranteed to run before any of them are used, independent of BuildingBlocks or load order. Verified both KArtSell.Integration.Tests.Compliance and .TradeExecution now pass 100% run in full isolation, not just as part of the full suite. Co-Authored-By: Claude Haiku 4.5 --- .../DapperMappingBootstrap.cs | 25 +++++++++++++++++++ .../TradeExecution/TradeSql.cs | 10 -------- .../DapperMappingBootstrap.cs | 21 ++++++++++++++++ 3 files changed, 46 insertions(+), 10 deletions(-) create mode 100644 src/KArtSell.Modules.ModelOperations/DapperMappingBootstrap.cs create mode 100644 src/KArtSell.Modules.SignalEngine/DapperMappingBootstrap.cs diff --git a/src/KArtSell.Modules.ModelOperations/DapperMappingBootstrap.cs b/src/KArtSell.Modules.ModelOperations/DapperMappingBootstrap.cs new file mode 100644 index 00000000..55943901 --- /dev/null +++ b/src/KArtSell.Modules.ModelOperations/DapperMappingBootstrap.cs @@ -0,0 +1,25 @@ +using System.Runtime.CompilerServices; + +namespace KArtSell.Modules.ModelOperations; + +/// +/// KArtSell.BuildingBlocks.Data.DapperBootstrap sets Dapper's snake_case-to-PascalCase column +/// mapping via its own [ModuleInitializer], but that only fires once that assembly is actually +/// loaded into the process. Several Sql classes in this module (e.g. AuditSql, TradeSql) only +/// have a `using` for a BuildingBlocks namespace without ever touching a type from it at +/// runtime, so under test isolation - or any host that queries this module before touching +/// BuildingBlocks - the load (and the mapping) can be skipped, silently nulling out every +/// snake_case column. Every Sql class in this assembly is defined here, so a module initializer +/// in this assembly is guaranteed to run before any of them are used, regardless of what else +/// has loaded. +/// +internal static class DapperMappingBootstrap +{ +#pragma warning disable CA2255 + [ModuleInitializer] +#pragma warning restore CA2255 + public static void Initialize() + { + Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true; + } +} diff --git a/src/KArtSell.Modules.ModelOperations/TradeExecution/TradeSql.cs b/src/KArtSell.Modules.ModelOperations/TradeExecution/TradeSql.cs index b1df1a00..a33dc955 100644 --- a/src/KArtSell.Modules.ModelOperations/TradeExecution/TradeSql.cs +++ b/src/KArtSell.Modules.ModelOperations/TradeExecution/TradeSql.cs @@ -21,16 +21,6 @@ public class TradeSql : ITradeSql private readonly NpgsqlDataSource _dataSource; private readonly ILogger _logger; - static TradeSql() - { - // KArtSell.BuildingBlocks.Data.DapperBootstrap sets this via [ModuleInitializer], but that - // only fires once its assembly is actually loaded into the process. Nothing in this class - // references a BuildingBlocks type, so under test isolation (or any host that queries Trade - // before touching BuildingBlocks) that assembly load - and the mapping - can be skipped, - // silently nulling out every snake_case column (kis_order_id, sell_decision_id, ...). - Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true; - } - public TradeSql(NpgsqlDataSource dataSource, ILogger logger) { _dataSource = dataSource; diff --git a/src/KArtSell.Modules.SignalEngine/DapperMappingBootstrap.cs b/src/KArtSell.Modules.SignalEngine/DapperMappingBootstrap.cs new file mode 100644 index 00000000..f7f6ce1f --- /dev/null +++ b/src/KArtSell.Modules.SignalEngine/DapperMappingBootstrap.cs @@ -0,0 +1,21 @@ +using System.Runtime.CompilerServices; + +namespace KArtSell.Modules.SignalEngine; + +/// +/// See KArtSell.Modules.ModelOperations.DapperMappingBootstrap for the full rationale: Dapper's +/// snake_case-to-PascalCase column mapping is a process-wide static flag set via +/// KArtSell.BuildingBlocks.Data.DapperBootstrap's [ModuleInitializer], which only fires once +/// that assembly is loaded. This mirrors it locally so every Sql/reader class defined in this +/// assembly is guaranteed the mapping is on before its first query, regardless of load order. +/// +internal static class DapperMappingBootstrap +{ +#pragma warning disable CA2255 + [ModuleInitializer] +#pragma warning restore CA2255 + public static void Initialize() + { + Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true; + } +} From 60c90baa2821e51351478c97e196aa83e6453162 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sat, 8 Aug 2026 12:36:00 +0900 Subject: [PATCH 3/5] docs: resolve VS-03/04/12/14 numbering collision (renumber to VS-26/27/28/29) Renumbers the four 2026-08-07 slices (ApprovalWorkflow, AuditTrail, TradeExecution, PortfolioReconciliation) to previously-unused VS-26..29, leaving WBS_MASTER.csv's original VS-03/04/12/14 definitions (IngestMarketDataPIT, ApplyCorporateActions, RankBuyCandidates, GenerateDailyRecommendations) untouched, per docs/DECISIONS/ADR-WBS-001-slice-renumbering.md. While investigating, found two things not yet resolved by this commit: - DEBT-017 (duplicate ApprovalWorkflow implementation): the tested backend (ApprovalWorkflow/) is [DontRegister]'d dead code; the live one (Features/ApprovalWorkflow/, wired in Program.cs) has no dedicated tests. AEG-VS-26-01 downgraded from COMPLETED to BLOCKED in the tracker pending an architect decision on which implementation is canonical. - Features/MarketData and Features/Portfolio (VS-03/04/05/08 Market Data Ingestion Dashboard, Portfolio Rebalance, Risk Metrics, Dashboard) are a third, already-implemented-and-tested body of work entirely absent from WBS_PROGRESS_TRACKER.csv. Flagged in CURRENT_ROADMAP.md as a follow-up. Co-Authored-By: Claude Sonnet 5 --- CURRENT_ROADMAP.md | 28 +++--- docs/CURRENT/CATALOGS/WBS_MASTER.csv | 4 + .../CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv | 8 +- ...S-03-SLICE_SPEC.md => VS-26-SLICE_SPEC.md} | 8 +- ...S-04-SLICE_SPEC.md => VS-27-SLICE_SPEC.md} | 6 +- ...S-12-SLICE_SPEC.md => VS-28-SLICE_SPEC.md} | 26 +++--- ...S-14-SLICE_SPEC.md => VS-29-SLICE_SPEC.md} | 16 ++-- .../ADR-WBS-001-slice-renumbering.md | 92 +++++++++++++++++++ .../ApprovalWorkflow/README.md | 8 +- .../TradeExecution/README.md | 24 ++--- 10 files changed, 159 insertions(+), 61 deletions(-) rename docs/CURRENT/SLICE_SPECS/{VS-03-SLICE_SPEC.md => VS-26-SLICE_SPEC.md} (96%) rename docs/CURRENT/SLICE_SPECS/{VS-04-SLICE_SPEC.md => VS-27-SLICE_SPEC.md} (98%) rename docs/CURRENT/SLICE_SPECS/{VS-12-SLICE_SPEC.md => VS-28-SLICE_SPEC.md} (88%) rename docs/CURRENT/SLICE_SPECS/{VS-14-SLICE_SPEC.md => VS-29-SLICE_SPEC.md} (93%) create mode 100644 docs/DECISIONS/ADR-WBS-001-slice-renumbering.md diff --git a/CURRENT_ROADMAP.md b/CURRENT_ROADMAP.md index ebd19a54..e013fd02 100644 --- a/CURRENT_ROADMAP.md +++ b/CURRENT_ROADMAP.md @@ -1,28 +1,30 @@ # ๐Ÿš€ K-ArtSell Aegis v16.0 - ํ˜„์žฌ ์ง„ํ–‰ ๋กœ๋“œ๋งต -**์ตœ์ข… ๊ฐฑ์‹ :** 2026-08-07 (์ด ๋ฌธ์„œ์˜ ์ด์ „ ๋ฒ„์ „์€ 2026-08-03 ์ƒํƒœ๋กœ ์ •์ฒด๋˜์–ด ์žˆ์—ˆ๊ณ , ๊ทธ ์‚ฌ์ด ๋ณ‘ํ•ฉ๋œ 115๊ฐœ ์ปค๋ฐ‹์„ ๋ฐ˜์˜ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฒˆ ๊ฐฑ์‹ ์€ ์‹ค์ œ ์ฝ”๋“œ/ํ…Œ์ŠคํŠธ๋ฅผ ์ง์ ‘ ํ™•์ธํ•œ ๊ฒฐ๊ณผ์ž…๋‹ˆ๋‹ค.) +**์ตœ์ข… ๊ฐฑ์‹ :** 2026-08-08 (VS ๋ฒˆํ˜ธ ์žฌ๋ฐฐ์ • โ€” ์•„๋ž˜ "์•Œ๋ ค์ง„ ๋ฌธ์„œ ์ •ํ•ฉ์„ฑ ๋ฌธ์ œ" 1๋ฒˆ ์ฐธ์กฐ. 2026-08-07 ๊ฐฑ์‹  ๋‚ด์šฉ์€ ์‹ค์ œ ์ฝ”๋“œ/ํ…Œ์ŠคํŠธ๋ฅผ ์ง์ ‘ ํ™•์ธํ•œ ๊ฒฐ๊ณผ์˜€๊ณ  ์ด๋ฒˆ ๊ฐฑ์‹ ์€ ๊ทธ ์œ„์— ๋ฒˆํ˜ธ ์ถฉ๋Œ๋งŒ ์ •์ •ํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค.) -**์ƒํƒœ ์š”์•ฝ:** VS-03(์Šน์ธ ์›Œํฌํ”Œ๋กœ์šฐ), VS-04(๊ฐ์‚ฌ ์ถ”์ ), VS-10(๋งค๋„ ๊ฒฐ์ •), VS-12(๊ฑฐ๋ž˜ ์‹คํ–‰), VS-14(ํฌํŠธํด๋ฆฌ์˜ค ๋Œ€์‚ฌ) ๋ฐฑ์—”๋“œ ๊ตฌํ˜„ + ํ…Œ์ŠคํŠธ ์™„๋ฃŒ. Phase 1 Shadow Run(Gate 5a, 252+ ๊ฑฐ๋ž˜์ผ ๊ฒ€์ฆ)์€ **์•„์ง ์‹œ์ž‘๋˜์ง€ ์•Š์Œ** (๊ณผ๊ฑฐ "RUNNING" ๊ธฐ๋ก์€ ํ—ˆ์œ„์˜€์Œ์ด ์ด๋ฏธ ๋ฌธ์„œ๋กœ ์ •์ •๋จ). ์ƒ์„ธ ํ•ญ๋ชฉ๋ณ„ ์ƒํƒœ๋Š” `docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv` ์ฐธ์กฐ. +**์ƒํƒœ ์š”์•ฝ:** VS-27(๊ฐ์‚ฌ ์ถ”์ ), VS-10(๋งค๋„ ๊ฒฐ์ •), VS-28(๊ฑฐ๋ž˜ ์‹คํ–‰), VS-29(ํฌํŠธํด๋ฆฌ์˜ค ๋Œ€์‚ฌ) ๋ฐฑ์—”๋“œ ๊ตฌํ˜„ + ํ…Œ์ŠคํŠธ ์™„๋ฃŒ. VS-26(์Šน์ธ ์›Œํฌํ”Œ๋กœ์šฐ, ๊ตฌ VS-03)์€ DEBT-017(์ค‘๋ณต ๊ตฌํ˜„) ๋ฏธํ•ด๊ฒฐ๋กœ **BLOCKED**. Phase 1 Shadow Run(Gate 5a, 252+ ๊ฑฐ๋ž˜์ผ ๊ฒ€์ฆ)์€ **์•„์ง ์‹œ์ž‘๋˜์ง€ ์•Š์Œ** (๊ณผ๊ฑฐ "RUNNING" ๊ธฐ๋ก์€ ํ—ˆ์œ„์˜€์Œ์ด ์ด๋ฏธ ๋ฌธ์„œ๋กœ ์ •์ •๋จ). ์ƒ์„ธ ํ•ญ๋ชฉ๋ณ„ ์ƒํƒœ๋Š” `docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv` ์ฐธ์กฐ. --- ## โš ๏ธ ์•Œ๋ ค์ง„ ๋ฌธ์„œ ์ •ํ•ฉ์„ฑ ๋ฌธ์ œ (DECISION_REQUIRED) -1. **VS ๋ฒˆํ˜ธ ์ฒด๊ณ„ ์ถฉ๋Œ:** `docs/CURRENT/CATALOGS/WBS_MASTER.csv`(์› ๊ณ„ํš)์™€ ์‹ค์ œ ๊ตฌํ˜„/`WBS_PROGRESS_TRACKER.csv`(์‹คํ–‰ ํŠธ๋ž˜์ปค) ์‚ฌ์ด์— VS-03/VS-04/VS-12/VS-14 ๋ฒˆํ˜ธ๊ฐ€ ์„œ๋กœ ๋‹ค๋ฅธ ๊ธฐ๋Šฅ์„ ๊ฐ€๋ฆฌํ‚ค๋Š” ์ถฉ๋Œ์ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฒˆ ์„ธ์…˜์—์„œ ๋ฐœ๊ฒฌํ–ˆ๊ณ , ์‚ฌ์šฉ์ž ๊ฒฐ์ •์— ๋”ฐ๋ผ **๊ธฐ์กด ํŠธ๋ž˜์ปค ๋ฒˆํ˜ธ๋ฅผ ์œ ์ง€**ํ•˜๊ณ  ์ถฉ๋Œ ์‚ฌ์‹ค๋งŒ ๊ฐ ํ–‰์— ๋ช…์‹œํ–ˆ์Šต๋‹ˆ๋‹ค. ๊ทผ๋ณธ ํ•ด๊ฒฐ(์žฌ๋ฒˆํ˜ธ ๋ถ€์—ฌ ๋˜๋Š” WBS_MASTER ๊ณต์‹ ๋Œ€์ฒด)์€ ์•„์ง ๋ฏธ๊ฒฐ์ •์ž…๋‹ˆ๋‹ค. -2. **DbUp ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํ…Œ์ŠคํŠธ DB ๊ถŒํ•œ ๋ฌธ์ œ:** `kartsell` DB ์‚ฌ์šฉ์ž๊ฐ€ `kartsell_migration_test` ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์˜ ์†Œ์œ ์ž๊ฐ€ ์•„๋‹ˆ์–ด์„œ `DbUpMigrationTests`(12๊ฑด)๊ฐ€ ๋กœ์ปฌ์—์„œ ์‹คํŒจํ•ฉ๋‹ˆ๋‹ค. ์ฝ”๋“œ ๋ฌธ์ œ๊ฐ€ ์•„๋‹ˆ๋ผ DBA ์กฐ์น˜(์†Œ์œ ๊ถŒ ๋ถ€์—ฌ)๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. +1. **VS ๋ฒˆํ˜ธ ์ฒด๊ณ„ ์ถฉ๋Œ โ€” 2026-08-08 ๋ถ€๋ถ„ ํ•ด๊ฒฐ:** `WBS_MASTER.csv`(์› ๊ณ„ํš)์™€ `WBS_PROGRESS_TRACKER.csv`(์‹คํ–‰ ํŠธ๋ž˜์ปค) ์‚ฌ์ด์˜ VS-03/VS-04/VS-12/VS-14 ์ถฉ๋Œ์€ ํŠธ๋ž˜์ปค ์ชฝ 4๊ฐœ ์Šฌ๋ผ์ด์Šค(์Šน์ธ์›Œํฌํ”Œ๋กœ์šฐ/๊ฐ์‚ฌ์ถ”์ /๊ฑฐ๋ž˜์‹คํ–‰/ํฌํŠธํด๋ฆฌ์˜ค๋Œ€์‚ฌ)๋ฅผ VS-26/27/28/29๋กœ ์žฌ๋ฒˆํ˜ธ ๋ถ€์—ฌํ•˜์—ฌ ํ•ด๊ฒฐํ–ˆ์Šต๋‹ˆ๋‹ค. ๊ทผ๊ฑฐ: `docs/DECISIONS/ADR-WBS-001-slice-renumbering.md`. + - **์ด ๊ณผ์ •์—์„œ ์ƒˆ๋กœ ๋ฐœ๊ฒฌํ•œ, ์•„์ง ๋ฏธํ•ด๊ฒฐ์ธ ๋ฌธ์ œ (TECH_DEBT_REGISTER.md DEBT-017):** VS-26(๊ตฌ VS-03) ์Šน์ธ์›Œํฌํ”Œ๋กœ์šฐ๋Š” **๋™์ผ ๊ธฐ๋Šฅ์˜ ์ค‘๋ณต ๊ตฌํ˜„์ด ๋‘ ๋ฒŒ** ์กด์žฌํ•ฉ๋‹ˆ๋‹ค โ€” `src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/`(20/20 ํ…Œ์ŠคํŠธ ํ†ต๊ณผํ•˜์ง€๋งŒ ์ „ ์—”๋“œํฌ์ธํŠธ๊ฐ€ `[DontRegister]`๋กœ ๋น„ํ™œ์„ฑํ™”๋˜์–ด ์‹ค์ œ๋กœ๋Š” ํ˜ธ์ถœ ๋ถˆ๊ฐ€๋Šฅํ•œ ์ฃฝ์€ ์ฝ”๋“œ)์™€ `Features/ApprovalWorkflow/`(`Program.cs`์— ์‹ค์ œ ๋“ฑ๋ก๋˜์–ด ์‚ด์•„์žˆ์ง€๋งŒ ์ „์šฉ ํ…Œ์ŠคํŠธ๊ฐ€ ์—†์Œ). ์ฆ‰ **ํ…Œ์ŠคํŠธ๋œ ์ฝ”๋“œ๋Š” ์ฃฝ์–ด์žˆ๊ณ , ์‚ด์•„์žˆ๋Š” ์ฝ”๋“œ๋Š” ํ…Œ์ŠคํŠธ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.** ์•„ํ‚คํ…ํŠธ ๊ฒฐ์ •(๋‘˜ ์ค‘ ํ•˜๋‚˜๋ฅผ ์ •์‹์œผ๋กœ ์„ ํƒํ•˜๊ณ  ๋‚˜๋จธ์ง€ ์‚ญ์ œ) ์ „๊นŒ์ง€ ์ด ์Šฌ๋ผ์ด์Šค์— ํ”„๋ŸฐํŠธ์—”๋“œ๋ฅผ ๋ถ™์ด์ง€ ๋งˆ์„ธ์š”. WBS_PROGRESS_TRACKER.csv์˜ AEG-VS-26-01 ํ–‰ ์ƒํƒœ๋ฅผ `BLOCKED`๋กœ ์ •์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. + - **๋˜ ๋‹ค๋ฅธ ๋ฐœ๊ฒฌ โ€” ๋ฏธ์ถ”์  ์ž‘์—…:** `src/KArtSell.Host/Features/MarketData/VS03_*.cs`, `Features/Portfolio/VS04_*.cs`/`VS05_*.cs`/`VS08_*.cs`๋Š” ์‹ค์ œ ๊ตฌํ˜„๋˜๊ณ  ํ…Œ์ŠคํŠธ๋„ ์žˆ๋Š”(commits `2bc2b1e`, `32b49a4`, `14c5e4f`, `2eee44d`) **์„ธ ๋ฒˆ์งธ** VS-03/04/05/08 ์‚ฌ์šฉ๋ก€(Market Data Ingestion Dashboard, Portfolio Rebalance, Risk Metrics, Dashboard)์ธ๋ฐ, `WBS_PROGRESS_TRACKER.csv`์— ์ „ํ˜€ ๊ธฐ๋ก๋˜์–ด ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋‹ค์Œ ์„ธ์…˜์—์„œ ์ด ์ž‘์—…์„ ๊ฒ€์ฆ(๋นŒ๋“œ/ํ…Œ์ŠคํŠธ ์žฌํ˜„, ํ”„๋ŸฐํŠธ์—”๋“œ ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ)ํ•˜๊ณ  ํŠธ๋ž˜์ปค์— ์ถ”๊ฐ€ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. +2. **DbUp ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํ…Œ์ŠคํŠธ DB ๊ถŒํ•œ ๋ฌธ์ œ:** `kartsell` DB ์‚ฌ์šฉ์ž๊ฐ€ `kartsell_migration_test` ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์˜ ์†Œ์œ ์ž๊ฐ€ ์•„๋‹ˆ์–ด์„œ `DbUpMigrationTests`(12๊ฑด)๊ฐ€ ๋กœ์ปฌ์—์„œ ์‹คํŒจํ•ฉ๋‹ˆ๋‹ค. ์ฝ”๋“œ ๋ฌธ์ œ๊ฐ€ ์•„๋‹ˆ๋ผ DBA ์กฐ์น˜(์†Œ์œ ๊ถŒ ๋ถ€์—ฌ)๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ์‹คํ–‰ํ•  SQL ์ดˆ์•ˆ: `scripts/dba/grant-migration-test-db-ownership.sql`. 3. **frontend ๋นŒ๋“œ ์‚ฐ์ถœ๋ฌผ ์žฌํ•ด์‹œ:** `dotnet build`๋ฅผ ์‹คํ–‰ํ•  ๋•Œ๋งˆ๋‹ค `pnpm build`๊ฐ€ ์žฌ์‹คํ–‰๋˜์–ด `wwwroot/assets/*` ํ•ด์‹œ ํŒŒ์ผ๋ช…์ด ๋ฐ”๋€Œ๊ณ  git์— ๋ถˆํ•„์š”ํ•œ ๋ณ€๊ฒฝ์ด ์Œ“์ด๋Š” ๊ตฌ์กฐ์  ๋ฌธ์ œ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค (์•„์ง ๋ฏธํ•ด๊ฒฐ). --- ## โœ… ์™„๋ฃŒ (Backend ๊ตฌํ˜„ + ํ…Œ์ŠคํŠธ, 2026-08-07 ๊ธฐ์ค€ ๊ฒ€์ฆ๋จ) -### VS-03: ๋ชจ๋ธ ์Šน์ธ ์›Œํฌํ”Œ๋กœ์šฐ (Maker-Checker Governance) -- **์œ„์น˜:** `src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/` -- **ํ…Œ์ŠคํŠธ:** 20/20 PASS (๊ฒฉ๋ฆฌ ์‹คํ–‰ ๊ธฐ์ค€) -- **๋ฏธ์™„๋ฃŒ:** ํ”„๋ŸฐํŠธ์—”๋“œ UI ์—†์Œ -- **์ด๋ฒˆ ์„ธ์…˜์—์„œ ๋ฐœ๊ฒฌ/์ˆ˜์ •ํ•œ ๊ฒฐํ•จ:** `ApprovalSql`์ด Dapper๋กœ `DateOnly` ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๋ฐ”์ธ๋”ฉํ•˜์ง€ ๋ชปํ•ด ์Šน์ธ ์ œ์•ˆ์„œ ์ƒ์„ฑ์ด ์‹ค DB ํ™˜๊ฒฝ์—์„œ 100% ์‹คํŒจํ•˜๋˜ ๋ฒ„๊ทธ โ€” ๋ณ‘ํ•ฉ ์ดํ›„ ์‹ค DB๋กœ ํ•œ ๋ฒˆ๋„ ๊ฒ€์ฆ๋˜์ง€ ์•Š์•„ ๋ฐœ๊ฒฌ๋˜์ง€ ์•Š๊ณ  ์žˆ์—ˆ์Œ +### VS-26 (๊ตฌ VS-03): ๋ชจ๋ธ ์Šน์ธ ์›Œํฌํ”Œ๋กœ์šฐ (Maker-Checker Governance) โ€” ๐Ÿ”ด BLOCKED +- **์œ„์น˜:** `src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/` (์ฃฝ์€ ์ฝ”๋“œ, `[DontRegister]`) + `Features/ApprovalWorkflow/` (์‹ค์ œ ๋“ฑ๋ก๋จ, ํ…Œ์ŠคํŠธ ์—†์Œ) +- **ํ…Œ์ŠคํŠธ:** 20/20 PASS๋Š” ์ฃฝ์€ ์ฝ”๋“œ ์ชฝ ๊ฒฐ๊ณผ์ž…๋‹ˆ๋‹ค โ€” ์‹ค์ œ๋กœ ํ˜ธ์ถœ๋˜๋Š” ๊ตฌํ˜„์€ ํ…Œ์ŠคํŠธ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ์ž์„ธํ•œ ๋‚ด์šฉ์€ TECH_DEBT_REGISTER.md DEBT-017 ์ฐธ์กฐ. +- **๋ฏธ์™„๋ฃŒ:** ํ”„๋ŸฐํŠธ์—”๋“œ UI ์—†์Œ. DEBT-017 ํ•ด๊ฒฐ ์ „๊นŒ์ง€ ์ฐฉ์ˆ˜ ๊ธˆ์ง€. +- **์ด์ „ ์„ธ์…˜์—์„œ ๋ฐœ๊ฒฌ/์ˆ˜์ •ํ•œ ๊ฒฐํ•จ (์—ฌ์ „ํžˆ ์œ ํšจ, ์ฃฝ์€ ์ฝ”๋“œ ์ชฝ ํ•œ์ •):** `ApprovalSql`์ด Dapper๋กœ `DateOnly` ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๋ฐ”์ธ๋”ฉํ•˜์ง€ ๋ชปํ•ด ์Šน์ธ ์ œ์•ˆ์„œ ์ƒ์„ฑ์ด ์‹ค DB ํ™˜๊ฒฝ์—์„œ 100% ์‹คํŒจํ•˜๋˜ ๋ฒ„๊ทธ โ€” ๋ณ‘ํ•ฉ ์ดํ›„ ์‹ค DB๋กœ ํ•œ ๋ฒˆ๋„ ๊ฒ€์ฆ๋˜์ง€ ์•Š์•„ ๋ฐœ๊ฒฌ๋˜์ง€ ์•Š๊ณ  ์žˆ์—ˆ์Œ -### VS-04: ๋ถˆ๋ณ€ ๊ฐ์‚ฌ ์ถ”์  (Audit Trail / GDPR) +### VS-27 (๊ตฌ VS-04): ๋ถˆ๋ณ€ ๊ฐ์‚ฌ ์ถ”์  (Audit Trail / GDPR) - **์œ„์น˜:** `src/KArtSell.Modules.ModelOperations/Compliance/` - **ํ…Œ์ŠคํŠธ:** 5/5 PASS (๊ฒฉ๋ฆฌ ์‹คํ–‰ ๊ธฐ์ค€) - **๋ฏธ์™„๋ฃŒ:** ํ”„๋ŸฐํŠธ์—”๋“œ UI ์—†์Œ @@ -31,16 +33,16 @@ ### VS-10: ๋งค๋„ ๊ฒฐ์ • ์—”์ง„ (Sell Decision Engine) - **์œ„์น˜:** `src/KArtSell.Modules.ModelOperations/SellDecision/`, `frontend/src/features/sell-decision/` - **ํ…Œ์ŠคํŠธ:** 32/32 PASS (๊ฒฉ๋ฆฌ ์‹คํ–‰ ๊ธฐ์ค€) -- **์™„๋ฃŒ๋„:** Backend + Frontend ๋ชจ๋‘ ์กด์žฌ (VS-03/04/12/14 ์ค‘ ์œ ์ผ) +- **์™„๋ฃŒ๋„:** Backend + Frontend ๋ชจ๋‘ ์กด์žฌ (VS-26/27/28/29 ์ค‘ ์œ ์ผ) - **โš ๏ธ ๋ฏธ๊ฒ€์ฆ ์‚ฌํ•ญ:** ์ฝ”๋“œ/ํ…Œ์ŠคํŠธ ์™„๋ฃŒ โ‰  PBO/DSR ํ”„๋กœ๋•์…˜ ๊ฒ€์ฆ ์™„๋ฃŒ. ์‹ค ์‹œ์žฅ ๋ฐ์ดํ„ฐ ๊ธฐ๋ฐ˜ ๊ฒ€์ฆ์€ Phase 1 Shadow Run ์™„๋ฃŒ ํ›„์—๋งŒ ๊ฐ€๋Šฅ -### VS-12: ๊ฑฐ๋ž˜ ์‹คํ–‰ ์‹œ์Šคํ…œ (Trade Execution, KIS ์—ฐ๋™) +### VS-28 (๊ตฌ VS-12): ๊ฑฐ๋ž˜ ์‹คํ–‰ ์‹œ์Šคํ…œ (Trade Execution, KIS ์—ฐ๋™) - **์œ„์น˜:** `src/KArtSell.Modules.ModelOperations/TradeExecution/` - **ํ…Œ์ŠคํŠธ:** 13/13 PASS (๊ฒฉ๋ฆฌ ์‹คํ–‰ ๊ธฐ์ค€) - **๋ฏธ์™„๋ฃŒ:** ํ”„๋ŸฐํŠธ์—”๋“œ UI ์—†์Œ - **์ด๋ฒˆ ์„ธ์…˜์—์„œ ๋ฐœ๊ฒฌ/์ˆ˜์ •ํ•œ ๊ฒฐํ•จ (์‹ฌ๊ฐ):** `UpdateTradeStatusAsync`๊ฐ€ `status`/`kis_response`/`error_message`๋งŒ ์ €์žฅํ•˜๊ณ  `kis_order_id`, `executed_quantity`, `unit_price`, `commission`, `net_proceeds`, ์ฒด๊ฒฐ/์ •์‚ฐ ํƒ€์ž„์Šคํƒฌํ”„๋Š” ๋ณ‘ํ•ฉ ์ดํ›„ ๋งค๋ฒˆ ์กฐ์šฉํžˆ ์œ ์‹ค์‹œํ‚ค๋˜ ๋ฒ„๊ทธ. ๊ฑฐ๋ž˜ ์ฒด๊ฒฐยท์ •์‚ฐ ๋ฐ์ดํ„ฐ๊ฐ€ ์‹ค์ œ๋กœ๋Š” ์ €์žฅ๋˜๊ณ  ์žˆ์ง€ ์•Š์•˜์Œ -### VS-14: ํฌํŠธํด๋ฆฌ์˜ค ๋Œ€์‚ฌ (Portfolio Reconciliation) +### VS-29 (๊ตฌ VS-14): ํฌํŠธํด๋ฆฌ์˜ค ๋Œ€์‚ฌ (Portfolio Reconciliation) - **์œ„์น˜:** `src/KArtSell.Modules.ModelOperations/PortfolioReconciliation/` - **ํ…Œ์ŠคํŠธ:** 18/18 PASS (๊ฒฉ๋ฆฌ ์‹คํ–‰ ๊ธฐ์ค€) - **๋ฏธ์™„๋ฃŒ:** ํ”„๋ŸฐํŠธ์—”๋“œ UI ์—†์Œ diff --git a/docs/CURRENT/CATALOGS/WBS_MASTER.csv b/docs/CURRENT/CATALOGS/WBS_MASTER.csv index 39c772ea..5c1ecaa8 100644 --- a/docs/CURRENT/CATALOGS/WBS_MASTER.csv +++ b/docs/CURRENT/CATALOGS/WBS_MASTER.csv @@ -663,3 +663,7 @@ AEG-V16-085,S15,W31-32,Cross,Cross-cutting,Packaging/Docs,PKG,REQ-V16-PKG-05,Cro AEG-V16-086,S15,W31-32,Cross,Cross-cutting,Packaging/Docs,PKG,REQ-V16-PKG-06,Cross,Cross,Cross,Cross,T-V16-PKG-06,Manifest/SHA256,PACKAGE_MANIFEST/SHA256SUMS,์ „ ํŒŒ์ผ hash,Release Manager,PM/QA,4,AEG-V16-085,G6,SOURCE+V16_DELTA,P1,PLANNED AEG-V16-087,S15,W31-32,Cross,Cross-cutting,Packaging/Docs,PKG,REQ-V16-PKG-07,Cross,Cross,Cross,Cross,T-V16-PKG-07,ZIP CRC/recursive scan,PACKAGE_QA,testzip nullยทself include 0,Release Manager,PM/QA,5,AEG-V16-086,G6,SOURCE+V16_DELTA,P1,PLANNED AEG-V16-088,S15,W31-32,Cross,Cross-cutting,Packaging/Docs,PKG,REQ-V16-PKG-08,Cross,Cross,Cross,Cross,T-V16-PKG-08,Distribution README,DISTRIBUTION_README,๊ฒ€์ฆ ์ง„์‹ค์„ฑยท์‹คํ–‰ ์ˆœ์„œ,Release Manager,PM/QA,6,AEG-V16-087,G6,SOURCE+V16_DELTA,P1,PLANNED +AEG-VS-26-01,S2,W-,VS-26,ApprovalWorkflow,ModelOperations,GOV,REQ-APR-001,APR-01,MIG-0036,-,UI-APR-01,T-APR-001,๋ชจ๋ธ ์Šน์ธ ์›Œํฌํ”Œ๋กœ์šฐ (Maker-Checker Governance),"docs/CURRENT/SLICE_SPECS/VS-26-SLICE_SPEC.md; ADR-WBS-001",์ƒ์„ธ ์ƒํƒœ๋Š” WBS_PROGRESS_TRACKER.csv AEG-VS-26-01 ์ฐธ์กฐ โ€” BLOCKED (DEBT-017: ๋‘ ๊ฐœ์˜ ์ค‘๋ณต ๊ตฌํ˜„ ์ค‘ ์‹ค์ œ ๋“ฑ๋ก๋œ ์ชฝ์€ ํ…Œ์ŠคํŠธ ์•ˆ๋จ),PM/BE Lead,Security/QA,-,-,G1-B,SOURCE+DESIGN_PROPOSAL,P0,BLOCKED +AEG-VS-27-01,S2,W-,VS-27,AuditTrail,ModelOperations,GOV,REQ-AUD-001,AUD-01,MIG-0037,-,UI-AUD-01,T-AUD-001,๋ถˆ๋ณ€ ๊ฐ์‚ฌ ์ถ”์  (Audit Trail / GDPR),"docs/CURRENT/SLICE_SPECS/VS-27-SLICE_SPEC.md; ADR-WBS-001",์ƒ์„ธ ์ƒํƒœ๋Š” WBS_PROGRESS_TRACKER.csv AEG-VS-27-01 ์ฐธ์กฐ โ€” Backend 5/5 tests PASS(๊ฒฉ๋ฆฌ); ํ”„๋ŸฐํŠธ์—”๋“œ UI ์—†์Œ,PM/BE Lead,Security/QA,-,-,G1-B,SOURCE+DESIGN_PROPOSAL,P0,COMPLETED +AEG-VS-28-01,S2,W-,VS-28,TradeExecution,ModelOperations,BE,REQ-TRD-001,TRD-01,MIG-0039,-,UI-TRD-01,T-TRD-001,"๊ฑฐ๋ž˜ ์‹คํ–‰ ์‹œ์Šคํ…œ (Trade Execution, KIS)","docs/CURRENT/SLICE_SPECS/VS-28-SLICE_SPEC.md; ADR-WBS-001",์ƒ์„ธ ์ƒํƒœ๋Š” WBS_PROGRESS_TRACKER.csv AEG-VS-28-01 ์ฐธ์กฐ โ€” Backend 13/13 tests PASS(๊ฒฉ๋ฆฌ); ํ”„๋ŸฐํŠธ์—”๋“œ UI ์—†์Œ,BE Lead/Trading Ops,Security/QA,-,-,G1-B,SOURCE+DESIGN_PROPOSAL,P0,COMPLETED +AEG-VS-29-01,S2,W-,VS-29,PortfolioReconciliation,ModelOperations,BE,REQ-REC-002,REC-02,MIG-0040,-,UI-REC-02,T-REC-002,ํฌํŠธํด๋ฆฌ์˜ค ๋Œ€์‚ฌ,"docs/CURRENT/SLICE_SPECS/VS-29-SLICE_SPEC.md; ADR-WBS-001",์ƒ์„ธ ์ƒํƒœ๋Š” WBS_PROGRESS_TRACKER.csv AEG-VS-29-01 ์ฐธ์กฐ โ€” Backend 18/18 tests PASS(๊ฒฉ๋ฆฌ); ํ”„๋ŸฐํŠธ์—”๋“œ UI ์—†์Œ,BE Lead,Security/QA,-,-,G1-B,SOURCE+DESIGN_PROPOSAL,P0,COMPLETED diff --git a/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv b/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv index b6c3b663..8282827d 100644 --- a/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv +++ b/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv @@ -17,13 +17,13 @@ AEG-VS-00-07,S0,VS-00,ํšŒ๊ท€ยท๊ด€์ œยทRunbookยทRollback ์ฆ๊ฑฐ,COMPLETED,2026-08 AEG-X-009,S1,Cross,Source catalog ๊ณ ๋„ํ™”,COMPLETED,2026-08-07,"docs/CURRENT/CATALOGS/source-catalog.md; docs/CURRENT/AEG-X-009_AUTOMATION_PROPOSAL.md; contracts/data/source-approval.v1.proposed.json; docs/DECISIONS/ADR-DATA-001.md; db/migrations/0033_source_approval_contract.sql; db/migrations/0034_dataset_manifest_freeze_contract.sql; db/migrations/0033_market_data_import_logs.sql; src/KArtSell.Modules.ModelOperations/Infrastructure/DapperApprovedModelContextReader.cs",Data Governance,"โœ… Workstream D/E/F COMPLETED: source-catalog.md v2.0 (KRX/OpenDart/KIS consolidated), VS-02_DATA_GOVERNANCE_POLICY.md, VS-03/04 SLICE_SPECs. All 4 unknowns resolved. โœ… Workstream G (commit 136665c, 2026-08-07) also now COMPLETE: live KRX OpenAPI / OpenDart / KIS service integrations (P1-P3), daily scheduling + error classification + SLA tracking + LKG fallback (P4-P6), market_data schema with append-only import logs, correlation_id-based idempotent replay. โš ๏ธ Note: 0033 is used by two different, unrelated migrations across branches (source_approval_contract.sql vs market_data_import_logs.sql) โ€” confirm actual applied migration number in the target DB's kartsell_schema_versions journal before assuming both landed as authored." AEG-VS-01-01,S1,VS-01,์ •์ฑ…ยท๋ฒ”์œ„ยท์‹คํŒจ์ƒํƒœ ๊ณ„์•ฝ ํ™•์ •,COMPLETED,2026-08-07,docs/CURRENT/SLICE_SPECS/VS-01-SLICE_SPEC.md,PM/Architect,"โœ… SLICE_SPEC produced: VS-01-SLICE_SPEC.md (identity/MFA/RBAC/maker-checker contract). Prerequisite AEG-X-001 + AEG-VS-00-02 already COMPLETED. Ready for security team review and schema implementation." AEG-VS-02-01,S1,VS-02,์ •์ฑ…ยท๋ฒ”์œ„ยท์‹คํŒจ์ƒํƒœ ๊ณ„์•ฝ ํ™•์ •,COMPLETED,2026-08-07,"docs/CURRENT/SLICE_SPECS/VS-02-SLICE_SPEC.md; docs/CURRENT/VS-02_DATA_GOVERNANCE_POLICY.md",PM/Architect,"โœ… COMPLETE: VS-02-SLICE_SPEC.md + governance policy. All 4 unknowns resolved (data source, import SLA, audit policy, schema versioning). Financial security master implementation ready for Phase 2." -AEG-VS-03-01,S2,VS-03,๋ชจ๋ธ ์Šน์ธ ์›Œํฌํ”Œ๋กœ์šฐ ๊ตฌํ˜„ (Maker-Checker Governance),COMPLETED,2026-08-07,"docs/CURRENT/SLICE_SPECS/VS-03-SLICE_SPEC.md; src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ (ApprovalSql.cs, ApprovalPolicy.cs); db/migrations/0036_approval_workflow.sql; tests/KArtSell.Integration.Tests/ApprovalWorkflow/*; commit a2e742c (Workstream H, PR #23, merged to main)",PM/BE Lead,"โœ… Backend implementation + tests complete: maker-checker 2-person approval gate, evidence attachment, approve/reject with justification. 20/20 tests PASS run in isolation (2026-08-07, KARTSELL_POSTGRES=kartselldb). Also fixed same day: ApprovalSql couldn't insert a DateOnly parameter through Dapper at all (see fix/dapper-underscore-mapping-and-build branch) โ€” prior to that fix, proposal creation failed 100% of the time against a real database; this was never caught because no integration test had run against a live DB since the slice merged. โš ๏ธ No frontend UI yet (no frontend/src/features/approval-workflow/). โš ๏ธ WBS_ID COLLISION โ€” DECISION_REQUIRED: docs/CURRENT/CATALOGS/WBS_MASTER.csv defines VS-03 as 'IngestMarketDataPIT' (market data PIT ingestion), an unrelated slice. This VS-03 label was assigned by SLICE_SPECS/VS-03-SLICE_SPEC.md independently, without checking WBS_MASTER.csv. Needs an explicit decision: renumber this slice, or formally supersede WBS_MASTER's original VS-03 definition and record why." -AEG-VS-04-01,S2,VS-04,๋ถˆ๋ณ€ ๊ฐ์‚ฌ ์ถ”์  ๊ตฌํ˜„ (Audit Trail / GDPR),COMPLETED,2026-08-07,"docs/CURRENT/SLICE_SPECS/VS-04-SLICE_SPEC.md; src/KArtSell.Modules.ModelOperations/Compliance/ (AuditSql.cs, GdprRetention.cs); tests/KArtSell.Integration.Tests/Compliance/AuditTrailTests.cs; commit 97444c9 (Workstream I, PR #24, merged to main)",PM/BE Lead,"โœ… Backend implementation + tests complete: append-only audit_events table, GDPR retention tracking + redaction (actor_emailโ†’'', customer_idโ†’''), PII fields (ip_address). 5/5 tests PASS run in isolation (2026-08-07). Also fixed same day (fix/dapper-underscore-mapping-and-build branch): ip_address (inet) and kis_response-style jsonb columns threw InvalidCastException when read through Dapper into a typed class; GdprRetention.RetentionEndsAt was declared DateTime against a DATE column, same failure mode; and a process-wide Dapper snake_case-mapping race condition (KArtSell.BuildingBlocks' [ModuleInitializer] only fires once that assembly loads โ€” AuditSql doesn't reliably touch it) intermittently nulled out every column read from this table depending on unrelated test/host startup order. None of this had ever been exercised against a live database before. โš ๏ธ No frontend UI yet. โš ๏ธ WBS_ID COLLISION โ€” DECISION_REQUIRED: WBS_MASTER.csv defines VS-04 as 'ApplyCorporateActions' (corporate actions processing), an unrelated slice. Same issue and same required decision as AEG-VS-03-01 above." +AEG-VS-26-01,S2,VS-26,๋ชจ๋ธ ์Šน์ธ ์›Œํฌํ”Œ๋กœ์šฐ ๊ตฌํ˜„ (Maker-Checker Governance),BLOCKED,-,"docs/CURRENT/SLICE_SPECS/VS-26-SLICE_SPEC.md; src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ (ApprovalSql.cs, ApprovalPolicy.cs, [DontRegister]'d); src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/ (Handlers.cs, Sql.cs, Policy.cs โ€” the one actually wired in Program.cs); db/migrations/0036_approval_workflow.sql; tests/KArtSell.Integration.Tests/ApprovalWorkflow/*; TECH_DEBT_REGISTER.md DEBT-017; commit a2e742c (Workstream H, PR #23, merged to main)",PM/BE Lead,"๐Ÿ”ด CORRECTED 2026-08-08 โ€” this row previously said 'Backend implementation + tests complete, 20/20 tests PASS' and that claim is misleading: TECH_DEBT_REGISTER.md DEBT-017 records that TWO independent ApprovalWorkflow implementations exist with colliding routes. The one this row's evidence links to and the one with 20/20 passing tests (`ApprovalWorkflow/`, Workstream H) has all 4 endpoints annotated `[DontRegister]` and is NOT reachable via HTTP. The implementation actually registered in `Program.cs` and reachable at runtime is `Features/ApprovalWorkflow/` (Workstream G) โ€” grep found no dedicated test file exercising it. Net effect: the maker-checker approval gate that is actually live has not been shown to work, and the one that has been shown to work is dead code. Do not build frontend UI against either implementation, and do not re-mark this COMPLETED, until an architect decision (DEBT-017) picks the canonical implementation, deletes the other, and a test suite runs against whichever is kept. Renumbered from VS-03 to VS-26 on 2026-08-08 per docs/DECISIONS/ADR-WBS-001-slice-renumbering.md โ€” VS-03 in WBS_MASTER.csv ('IngestMarketDataPIT') is a separate, unrelated, still-unimplemented slice and keeps its original number unchanged. Also note: src/KArtSell.Host/Features/MarketData/VS03_*.cs is a THIRD, unrelated, already-implemented-and-tested body of work also labeled 'VS-03' (Market Data Ingestion Dashboard, commits 2bc2b1e/32b49a4) that is entirely absent from this tracker โ€” see CURRENT_ROADMAP.md for a note to investigate and add it." +AEG-VS-27-01,S2,VS-27,๋ถˆ๋ณ€ ๊ฐ์‚ฌ ์ถ”์  ๊ตฌํ˜„ (Audit Trail / GDPR),COMPLETED,2026-08-07,"docs/CURRENT/SLICE_SPECS/VS-27-SLICE_SPEC.md; src/KArtSell.Modules.ModelOperations/Compliance/ (AuditSql.cs, GdprRetention.cs); tests/KArtSell.Integration.Tests/Compliance/AuditTrailTests.cs; commit 97444c9 (Workstream I, PR #24, merged to main)",PM/BE Lead,"โœ… Backend implementation + tests complete: append-only audit_events table, GDPR retention tracking + redaction (actor_emailโ†’'', customer_idโ†’''), PII fields (ip_address). 5/5 tests PASS run in isolation (2026-08-07). Also fixed same day (fix/dapper-underscore-mapping-and-build branch): ip_address (inet) and kis_response-style jsonb columns threw InvalidCastException when read through Dapper into a typed class; GdprRetention.RetentionEndsAt was declared DateTime against a DATE column, same failure mode; and a process-wide Dapper snake_case-mapping race condition (KArtSell.BuildingBlocks' [ModuleInitializer] only fires once that assembly loads โ€” AuditSql doesn't reliably touch it) intermittently nulled out every column read from this table depending on unrelated test/host startup order. None of this had ever been exercised against a live database before. โš ๏ธ No frontend UI yet โ€” in progress 2026-08-08. Renumbered from VS-04 to VS-27 on 2026-08-08 per docs/DECISIONS/ADR-WBS-001-slice-renumbering.md โ€” VS-04 in WBS_MASTER.csv ('ApplyCorporateActions') was an unrelated, still-unimplemented slice and keeps its original number unchanged." AEG-VS-05-01,S3,VS-05,์ •์ฑ…ยท๋ฒ”์œ„ยท์‹คํŒจ์ƒํƒœ ๊ณ„์•ฝ ํ™•์ •,PLANNED,-,-,PM/Architect,"Not started. No docs/CURRENT/SLICE_SPECS/VS-05-SLICE_SPEC.md and no src/ implementation exist yet (confirmed 2026-08-07). Previous note referenced 'Job 976 (~50-90 days)' as if Phase 1 were actively running and counting down โ€” that was false; see PHASE-1-SHADOW-RUN row below. Correct statement: blocked behind Gate 1 (Phase 1 shadow run), which itself has not been queued yet." AEG-X-011,S4,Cross,Golden vector ๊ณ ๋„ํ™”,BLOCKED,TBD,"AGENTS.md: Algorithm changes require Golden data",Quant/QA,"Gate 2 prerequisite. Blocked by Phase 1 completion. Corrected 2026-08-07: Phase 1 (Job 893/976) has not been started, not 'in progress' โ€” see PHASE-1-SHADOW-RUN row. No countdown is currently running." AEG-VS-09-01,S4,VS-09,BuildEvidenceSnapshot,BLOCKED,TBD,"CLAUDE.md: Evidence requires Phase 1 results",PM/Architect,"Gate 2 prerequisite. Blocked by Phase 1, which has not been started (confirmed 2026-08-07). No src/ implementation exists for this slice." AEG-VS-10-01,S4,VS-10,๋งค๋„ ๊ฒฐ์ • ์—”์ง„ ๊ตฌํ˜„ (GenerateSellDecision),COMPLETED,2026-08-07,"docs/CURRENT/SLICE_SPECS/VS-10-SLICE_SPEC.md; src/KArtSell.Modules.ModelOperations/SellDecision/ (SellDecisionEndpoints.cs, SellDecisionHandler.cs, SellDecisionSql.cs, SellPriorityRanker.cs); frontend/src/features/sell-decision/; tests/KArtSell.Integration.Tests/SellDecision/SellDecisionTests.cs; commit b1e38ac (Phase 3 J, PR #28, merged to main)",BE Lead/Quant Lead,"โœ… Implementation complete (code + BE + FE + tests), matches WBS_MASTER's VS-10='GenerateSellDecision' definition (no ID collision here). Sell priority ranking (HARD_IMPAIRMENTโ†’...โ†’REENTRY_OPTION) with age/liquidity score boosts per VS-10-SLICE_SPEC.md. 32/32 tests PASS run in isolation (2026-08-07); one test (CalculateScore_HardImpairment_ReturnsLowestScore) had a wrong input value that happened to not exercise the >365-day age-boost branch the spec defines โ€” fixed as a test bug, not a product bug (see fix/dapper-underscore-mapping-and-build branch). โš ๏ธ NOT validated: this row was previously (incorrectly) marked BLOCKED with reasoning 'Model must pass PBO/DSR validation' โ€” that Gate-3/production-readiness validation genuinely still requires real Phase 1 shadow-run data and has not happened. Distinguish 'code implemented and unit/integration-tested' (done) from 'PBO/DSR-validated against real market data' (not done, blocked on Phase 1)." AEG-VS-19-01,S5,VS-19,RunFrozenBacktest,BLOCKED,TBD,"CLAUDE.md: Requires evidence from Phase 1-4",PM/Architect,"Gate 3 prerequisite. Blocked by Phase 1, which has not been started (confirmed 2026-08-07). No src/ implementation exists for this slice." -AEG-VS-12-01,S2,VS-12,"๊ฑฐ๋ž˜ ์‹คํ–‰ ์‹œ์Šคํ…œ ๊ตฌํ˜„ (Trade Execution, KIS Integration)",COMPLETED,2026-08-07,"docs/CURRENT/SLICE_SPECS/VS-12-SLICE_SPEC.md; src/KArtSell.Modules.ModelOperations/TradeExecution/ (TradeEndpoints.cs, TradeHandlers.cs, TradeSql.cs, Trade.cs, KisTradeExecutionService.cs); db/migrations/0039_trades.sql; tests/KArtSell.Integration.Tests/TradeExecution/TradeExecutionTests.cs; commit b1e38ac (Phase 3 K, PR #28, merged to main)",BE Lead/Trading Ops,"New row โ€” no prior tracker entry existed for this slice. โœ… Backend implementation + tests complete: Trade state machine (Pendingโ†’Submittedโ†’Acceptedโ†’PartiallyFilled/FullyFilledโ†’Confirmedโ†’Reconciled), KIS order submission/poll/settlement. 13/13 tests PASS run in isolation (2026-08-07), but only after two real bugs were fixed on fix/dapper-underscore-mapping-and-build: (1) UpdateTradeStatusAsync only ever persisted status/kis_response/error_message and silently dropped kis_order_id, executed_quantity, unit_price, commission, net_proceeds and both timestamps on every single call since the slice merged โ€” trade fills and settlements were not actually being recorded; (2) the same Dapper snake_case-mapping race condition described in AEG-VS-04-01's notes. โš ๏ธ No frontend UI yet (no frontend/src/features/trade-execution/). โš ๏ธ WBS_ID COLLISION โ€” DECISION_REQUIRED: WBS_MASTER.csv defines VS-12 as 'RankBuyCandidates' (buy candidate ranking), an unrelated slice. This VS-12 label was assigned independently by SLICE_SPECS/VS-12-SLICE_SPEC.md." -AEG-VS-14-01,S2,VS-14,ํฌํŠธํด๋ฆฌ์˜ค ๋Œ€์‚ฌ ๊ตฌํ˜„ (Portfolio Reconciliation),COMPLETED,2026-08-07,"docs/CURRENT/SLICE_SPECS/VS-14-SLICE_SPEC.md; src/KArtSell.Modules.ModelOperations/PortfolioReconciliation/ (Endpoints.cs, ReconcileTradeHandler.cs, ReconciliationEngine.cs, ReconciliationSql.cs, MismatchDetector.cs, CostBasisCalculator.cs); tests/KArtSell.Integration.Tests/PortfolioReconciliation/ReconciliationEngineTests.cs; tests/KArtSell.ModelOperations.UnitTests/EvaluationReconciliationPlannerTests.cs; commit b1e38ac (Phase 3 L, PR #28, merged to main); commit 4059828 (fix: missing model_operations.models table breaking every fresh DB, PR #29, merged to main same day)",BE Lead,"New row โ€” no prior tracker entry existed for this slice. โœ… Backend implementation + tests complete: Sell Decision โ†’ Trade โ†’ Holdings reconciliation, cost-basis calculation, mismatch detection. 18/18 tests PASS run in isolation (2026-08-07). Note this slice's own merge (PR #28) shipped with a missing model_operations.models table that broke every fresh-database migration; that was caught and fixed same day in PR #29 โ€” a reminder that this branch's fresh-install DbUp path had not actually been rehearsed before merge. โš ๏ธ No frontend UI yet. โš ๏ธ WBS_ID COLLISION โ€” DECISION_REQUIRED: WBS_MASTER.csv defines VS-14 as 'GenerateDailyRecommendations' (daily client recommendation packages), an unrelated slice." +AEG-VS-28-01,S2,VS-28,"๊ฑฐ๋ž˜ ์‹คํ–‰ ์‹œ์Šคํ…œ ๊ตฌํ˜„ (Trade Execution, KIS Integration)",COMPLETED,2026-08-07,"docs/CURRENT/SLICE_SPECS/VS-28-SLICE_SPEC.md; src/KArtSell.Modules.ModelOperations/TradeExecution/ (TradeEndpoints.cs, TradeHandlers.cs, TradeSql.cs, Trade.cs, KisTradeExecutionService.cs); db/migrations/0039_trades.sql; tests/KArtSell.Integration.Tests/TradeExecution/TradeExecutionTests.cs; commit b1e38ac (Phase 3 K, PR #28, merged to main)",BE Lead/Trading Ops,"New row โ€” no prior tracker entry existed for this slice. โœ… Backend implementation + tests complete: Trade state machine (Pendingโ†’Submittedโ†’Acceptedโ†’PartiallyFilled/FullyFilledโ†’Confirmedโ†’Reconciled), KIS order submission/poll/settlement. 13/13 tests PASS run in isolation (2026-08-07), but only after two real bugs were fixed on fix/dapper-underscore-mapping-and-build: (1) UpdateTradeStatusAsync only ever persisted status/kis_response/error_message and silently dropped kis_order_id, executed_quantity, unit_price, commission, net_proceeds and both timestamps on every single call since the slice merged โ€” trade fills and settlements were not actually being recorded; (2) the same Dapper snake_case-mapping race condition described in AEG-VS-27-01's notes. โš ๏ธ No frontend UI yet (no frontend/src/features/trade-execution/) โ€” in progress 2026-08-08. Renumbered from VS-12 to VS-28 on 2026-08-08 per docs/DECISIONS/ADR-WBS-001-slice-renumbering.md โ€” VS-12 in WBS_MASTER.csv ('RankBuyCandidates') was an unrelated, still-unimplemented slice and keeps its original number unchanged." +AEG-VS-29-01,S2,VS-29,ํฌํŠธํด๋ฆฌ์˜ค ๋Œ€์‚ฌ ๊ตฌํ˜„ (Portfolio Reconciliation),COMPLETED,2026-08-07,"docs/CURRENT/SLICE_SPECS/VS-29-SLICE_SPEC.md; src/KArtSell.Modules.ModelOperations/PortfolioReconciliation/ (Endpoints.cs, ReconcileTradeHandler.cs, ReconciliationEngine.cs, ReconciliationSql.cs, MismatchDetector.cs, CostBasisCalculator.cs); tests/KArtSell.Integration.Tests/PortfolioReconciliation/ReconciliationEngineTests.cs; tests/KArtSell.ModelOperations.UnitTests/EvaluationReconciliationPlannerTests.cs; commit b1e38ac (Phase 3 L, PR #28, merged to main); commit 4059828 (fix: missing model_operations.models table breaking every fresh DB, PR #29, merged to main same day)",BE Lead,"New row โ€” no prior tracker entry existed for this slice. โœ… Backend implementation + tests complete: Sell Decision โ†’ Trade โ†’ Holdings reconciliation, cost-basis calculation, mismatch detection. 18/18 tests PASS run in isolation (2026-08-07). Note this slice's own merge (PR #28) shipped with a missing model_operations.models table that broke every fresh-database migration; that was caught and fixed same day in PR #29 โ€” a reminder that this branch's fresh-install DbUp path had not actually been rehearsed before merge. โš ๏ธ No frontend UI yet โ€” in progress 2026-08-08. Renumbered from VS-14 to VS-29 on 2026-08-08 per docs/DECISIONS/ADR-WBS-001-slice-renumbering.md โ€” VS-14 in WBS_MASTER.csv ('GenerateDailyRecommendations') was an unrelated, still-unimplemented slice and keeps its original number unchanged." PHASE-1-SHADOW-RUN,S0-S5,Cross,252+ Trading Day Shadow Run,BLOCKED,TBD,"docs/CURRENT/PHASE-1_SHADOW_RUN_STATUS_CORRECTION.md; docs/CURRENT/AEG-X-004_DBUP_EVIDENCE.md; docs/CURRENT/PHASE-1_REQUEUE_READINESS.md; docs/CURRENT/PHASE-1_EXECUTION_EVIDENCE_PLAN.md; docs/CURRENT/PHASE-1_PREFLIGHT_20260806.md; docs/CURRENT/PHASE-1_PRODUCTION_PREFLIGHT_20260806.md; evidence/AEG-X-004/production-readonly-preflight-20260806.md; db/migrations/0032_shadow_run_queued_status_contract.sql; logs/phase-1-execution.log; logs/host-startup-20260804-173000.log",๊น€์žฌํ˜„/BE/SRE,"Read-only preflight: active DbUp journal public.kartsell_schema_versions contains 0032 and check_status includes Queued. Capabilities remain order/KIS/client publication OFF. Server-side dataset_manifest, model_version_registry, evidence_snapshot, and release_evidence_bundle contain no approved/frozen rows; no RunId/JobId/enqueue created. Blocked pending approved server-side VersionSet. Re-confirmed 2026-08-07: still no RunId/JobId exists anywhere in this workspace or its evidence trail; nothing changed on this row this session. Any future document that claims this row is RUNNING must cite a real RunId/JobId โ€” do not restate the earlier (already-corrected) false claim." diff --git a/docs/CURRENT/SLICE_SPECS/VS-03-SLICE_SPEC.md b/docs/CURRENT/SLICE_SPECS/VS-26-SLICE_SPEC.md similarity index 96% rename from docs/CURRENT/SLICE_SPECS/VS-03-SLICE_SPEC.md rename to docs/CURRENT/SLICE_SPECS/VS-26-SLICE_SPEC.md index a5d663a7..b6107321 100644 --- a/docs/CURRENT/SLICE_SPECS/VS-03-SLICE_SPEC.md +++ b/docs/CURRENT/SLICE_SPECS/VS-26-SLICE_SPEC.md @@ -1,6 +1,6 @@ -# VS-03: Model Approval Workflow (Maker-Checker Governance) +# VS-26: Model Approval Workflow (Maker-Checker Governance) -**Vertical Slice:** VS-03 (Model Approval & Activation Gateway) +**Vertical Slice:** VS-26 (Model Approval & Activation Gateway) **Version:** 1.0 COMPLETE **Date:** 2026-08-07 **Owner:** Platform Lead + Compliance @@ -228,11 +228,11 @@ CREATE TABLE model_operations.approval_events ( - **VS-00:** PIT envelope (published_at, correlation_id, revision) - **VS-02:** Financial security master (governance foundation) -- **VS-04:** Audit trail (event logging) +- **VS-27:** Audit trail (event logging) - **VS-10:** Sell decision (uses approved models) --- **Co-Authored-By:** Claude Haiku 4.5 **Status:** โœ… READY FOR IMPLEMENTATION -**Next:** VS-04 (audit trail), then Phase 2 implementation +**Next:** VS-27 (audit trail), then Phase 2 implementation diff --git a/docs/CURRENT/SLICE_SPECS/VS-04-SLICE_SPEC.md b/docs/CURRENT/SLICE_SPECS/VS-27-SLICE_SPEC.md similarity index 98% rename from docs/CURRENT/SLICE_SPECS/VS-04-SLICE_SPEC.md rename to docs/CURRENT/SLICE_SPECS/VS-27-SLICE_SPEC.md index 8c7835ae..11058b9e 100644 --- a/docs/CURRENT/SLICE_SPECS/VS-04-SLICE_SPEC.md +++ b/docs/CURRENT/SLICE_SPECS/VS-27-SLICE_SPEC.md @@ -1,6 +1,6 @@ -# VS-04: Immutable Audit Trail (GDPR/Compliance) +# VS-27: Immutable Audit Trail (GDPR/Compliance) -**Vertical Slice:** VS-04 (Audit Log & Compliance Trail) +**Vertical Slice:** VS-27 (Audit Log & Compliance Trail) **Version:** 1.0 COMPLETE **Date:** 2026-08-07 **Owner:** Compliance + Security @@ -245,7 +245,7 @@ WHERE entity_id IN (SELECT id FROM ... WHERE customer_id = $1); - **VS-00:** PIT envelope (published_at, correlation_id, revision) - **VS-02:** Governance foundation (data sources, policies) -- **VS-03:** Approval workflow (events logged by VS-04) +- **VS-26:** Approval workflow (events logged by VS-27) - **Compliance:** GDPR, FSS, PCI-DSS requirements --- diff --git a/docs/CURRENT/SLICE_SPECS/VS-12-SLICE_SPEC.md b/docs/CURRENT/SLICE_SPECS/VS-28-SLICE_SPEC.md similarity index 88% rename from docs/CURRENT/SLICE_SPECS/VS-12-SLICE_SPEC.md rename to docs/CURRENT/SLICE_SPECS/VS-28-SLICE_SPEC.md index 9a60ca22..05e735d1 100644 --- a/docs/CURRENT/SLICE_SPECS/VS-12-SLICE_SPEC.md +++ b/docs/CURRENT/SLICE_SPECS/VS-28-SLICE_SPEC.md @@ -1,11 +1,11 @@ -# VS-12: Trade Execution System (KIS Integration) +# VS-28: Trade Execution System (KIS Integration) -**Vertical Slice:** VS-12 (Trade Execution) +**Vertical Slice:** VS-28 (Trade Execution) **Version:** 1.0 COMPLETE **Date:** 2026-08-07 **Owner:** Backend Lead + Trading Ops **Status:** โœ… READY FOR IMPLEMENTATION -**Depends On:** VS-10 (sell decisions), VS-03 (approval), VS-04 (audit) +**Depends On:** VS-10 (sell decisions), VS-26 (approval), VS-27 (audit) --- @@ -16,12 +16,12 @@ **So that** portfolios are rebalanced automatically with full audit trail **Acceptance Criteria:** -- โœ… Execute trade only after VS-03 approval +- โœ… Execute trade only after VS-26 approval - โœ… Submit order to KIS, track order status - โœ… Handle partial fills and slippage - โœ… Confirm settlement and update cost basis - โœ… Classify errors (transient/permanent/liquidity) -- โœ… All state changes logged (VS-04 audit) +- โœ… All state changes logged (VS-27 audit) --- @@ -47,7 +47,7 @@ PARTIAL_FILLED / FULLY_FILLED (execution progress) โ†“ CONFIRMED (settlement confirmed) โ†“ -RECONCILED (cost basis updated by VS-14) +RECONCILED (cost basis updated by VS-29) ``` --- @@ -87,7 +87,7 @@ CREATE INDEX idx_trades_correlation_id ON trades(correlation_id); ### POST /trades (Create Trade) -**Role:** System (after VS-03 approval) +**Role:** System (after VS-26 approval) **Request:** ```json { @@ -164,12 +164,12 @@ ConfirmSettlementAsync(kisOrderId, correlationId) - Wait 1 business day after FILLED - Confirm settlement with KIS - Update status=CONFIRMED -- Emit event to VS-14 (reconciliation) +- Emit event to VS-29 (reconciliation) ### ReconcileTradeHandler - Receive settlement event - Update status=RECONCILED -- Mark ready for VS-14 processing +- Mark ready for VS-29 processing --- @@ -204,7 +204,7 @@ ConfirmSettlementAsync(kisOrderId, correlationId) - Liquidity: Partial fills, slippage **RBAC:** -- System role: Submit trades (via VS-03 approval) +- System role: Submit trades (via VS-26 approval) - Operations: View & monitor execution - Audit: Query immutable trail @@ -213,9 +213,9 @@ ConfirmSettlementAsync(kisOrderId, correlationId) ## ๐Ÿ“‹ Related Specifications - **VS-10:** Sell Decision (generates trades) -- **VS-03:** Approval Workflow (prerequisite) -- **VS-04:** Audit Trail (logs all state changes) -- **VS-14:** Portfolio Reconciliation (consumes trade settlement) +- **VS-26:** Approval Workflow (prerequisite) +- **VS-27:** Audit Trail (logs all state changes) +- **VS-29:** Portfolio Reconciliation (consumes trade settlement) --- diff --git a/docs/CURRENT/SLICE_SPECS/VS-14-SLICE_SPEC.md b/docs/CURRENT/SLICE_SPECS/VS-29-SLICE_SPEC.md similarity index 93% rename from docs/CURRENT/SLICE_SPECS/VS-14-SLICE_SPEC.md rename to docs/CURRENT/SLICE_SPECS/VS-29-SLICE_SPEC.md index 98f74d1d..e6f61de8 100644 --- a/docs/CURRENT/SLICE_SPECS/VS-14-SLICE_SPEC.md +++ b/docs/CURRENT/SLICE_SPECS/VS-29-SLICE_SPEC.md @@ -1,11 +1,11 @@ -# VS-14: Portfolio Reconciliation (Sell Decision โ†’ Trade โ†’ Holdings) +# VS-29: Portfolio Reconciliation (Sell Decision โ†’ Trade โ†’ Holdings) -**Vertical Slice:** VS-14 (Portfolio Reconciliation) +**Vertical Slice:** VS-29 (Portfolio Reconciliation) **Version:** 1.0 COMPLETE **Date:** 2026-08-07 **Owner:** Data Architecture + Finance **Status:** โœ… READY FOR IMPLEMENTATION -**Depends On:** K (trade execution), uses VS-03 (approval) + VS-04 (audit) +**Depends On:** K (trade execution), uses VS-26 (approval) + VS-27 (audit) --- @@ -38,11 +38,11 @@ ## ๐Ÿ“Š Reconciliation Flow ``` -Trade Executed (from VS-12) +Trade Executed (from VS-28) โ†“ Extract trade details: quantity, price, settlement date โ†“ -Validate against approval (from VS-03) +Validate against approval (from VS-26) โ†“ Update holdings: quantity ยฑ executed โ†“ @@ -237,9 +237,9 @@ CREATE TABLE lots ( ## ๐Ÿ“‹ Related Specifications -- **VS-03:** Approval workflow (approval_proposals, evidence linkage) -- **VS-04:** Audit trail (reconciliation events logged) -- **K (VS-12):** Trade execution (provides trade_id, quantity, price) +- **VS-26:** Approval workflow (approval_proposals, evidence linkage) +- **VS-27:** Audit trail (reconciliation events logged) +- **K (VS-28):** Trade execution (provides trade_id, quantity, price) - **VS-00:** PIT envelope (published_at, correlation_id, revision) --- diff --git a/docs/DECISIONS/ADR-WBS-001-slice-renumbering.md b/docs/DECISIONS/ADR-WBS-001-slice-renumbering.md new file mode 100644 index 00000000..6f308d82 --- /dev/null +++ b/docs/DECISIONS/ADR-WBS-001-slice-renumbering.md @@ -0,0 +1,92 @@ +# ADR-WBS-001: Resolve VS-03/04/12/14 numbering collision + +**Date:** 2026-08-08 +**Status:** ACCEPTED (partial โ€” see "Not resolved by this ADR" below) +**Decision owner:** ๊น€์žฌํ˜„ (per session 2026-08-08 direction: "์‹ค์ œ ๊ตฌํ˜„ ํŠธ๋ž˜์ปค ์œ ์ง€, WBS_MASTER ๊ฐฑ์‹ " + "๊ตฌํ˜„๋œ ์Šฌ๋ผ์ด์Šค๋ฅผ ์ƒˆ ๋ฒˆํ˜ธ๋กœ ์žฌ๋ฐฐ์ •") + +## Context + +`docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv` and `docs/CURRENT/SLICE_SPECS/` independently +assigned VS-03, VS-04, VS-12, and VS-14 to four slices implemented in 2026-08-07 (PR #23, #24, #28): + +| Number used by tracker/specs | Actual slice | +|---|---| +| VS-03 | Model Approval Workflow (Maker-Checker Governance) | +| VS-04 | Immutable Audit Trail (GDPR/Compliance) | +| VS-12 | Trade Execution System (KIS Integration) | +| VS-14 | Portfolio Reconciliation | + +These numbers were never checked against `docs/CURRENT/CATALOGS/WBS_MASTER.csv`, the original +666-row plan, which had already assigned the same four numbers to unrelated, still-unimplemented +slices: + +| Number in WBS_MASTER.csv | Original planned slice | +|---|---| +| VS-03 | IngestMarketDataPIT (market data PIT ingestion) | +| VS-04 | ApplyCorporateActions (corporate actions processing) | +| VS-12 | RankBuyCandidates (buy candidate ranking) | +| VS-14 | GenerateDailyRecommendations (daily client recommendation packages) | + +Separately, `src/KArtSell.Host/Features/MarketData/VS03_*.cs` and +`src/KArtSell.Host/Features/Portfolio/VS04_*.cs` / `VS05_*.cs` / `VS08_*.cs` are a **third**, +already-implemented-and-tested body of work (commits `2bc2b1e`, `32b49a4`, `14c5e4f`, `2eee44d`) +that also uses VS-03/04/05/08, for yet another set of features (Market Data Ingestion Dashboard, +Portfolio Rebalance, Risk Metrics, Dashboard). This body of work is not referenced anywhere in +`WBS_PROGRESS_TRACKER.csv` at all. This ADR does not resolve that โ€” see "Not resolved" below. + +## Decision + +Renumber the four 2026-08-07 slices to previously-unused numbers, leaving `WBS_MASTER.csv`'s +original VS-03/04/12/14 rows (and the separate `Features/MarketData`/`Features/Portfolio` +VS-03/04/05/08 code) untouched: + +| Old number | New number | Slice | Code location | +|---|---|---|---| +| VS-03 | **VS-26** | Model Approval Workflow | `src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/` and `Features/ApprovalWorkflow/` (see DEBT-017 below) | +| VS-04 | **VS-27** | Immutable Audit Trail / GDPR | `src/KArtSell.Modules.ModelOperations/Compliance/` | +| VS-12 | **VS-28** | Trade Execution (KIS) | `src/KArtSell.Modules.ModelOperations/TradeExecution/` | +| VS-14 | **VS-29** | Portfolio Reconciliation | `src/KArtSell.Modules.ModelOperations/PortfolioReconciliation/` | + +VS-26 through VS-29 were free (highest number previously used in `WBS_MASTER.csv` was VS-25). + +## Why renumber the new slices rather than renumber WBS_MASTER's originals + +`WBS_MASTER.csv`'s VS-03/04/12/14 rows are referenced by ID from dozens of other rows across the +666-row file (dependency chains, addendum sections `AEG22-*`, `AEG-V15-*`, `AEG-V16-*` all use +VS-03 to mean "MarketData" consistently throughout). Renumbering those in place would touch 50+ +rows with a high chance of missing a cross-reference. The four 2026-08-07 slices have a much +smaller footprint (2 SLICE_SPEC files each side, 2 README.md files, a handful of tracker rows) and +were not yet referenced by ID anywhere else, so moving them was the lower-risk edit. + +## What changed + +- `docs/CURRENT/SLICE_SPECS/VS-03-SLICE_SPEC.md` โ†’ `VS-26-SLICE_SPEC.md` (content updated) +- `docs/CURRENT/SLICE_SPECS/VS-04-SLICE_SPEC.md` โ†’ `VS-27-SLICE_SPEC.md` (content updated) +- `docs/CURRENT/SLICE_SPECS/VS-12-SLICE_SPEC.md` โ†’ `VS-28-SLICE_SPEC.md` (content updated) +- `docs/CURRENT/SLICE_SPECS/VS-14-SLICE_SPEC.md` โ†’ `VS-29-SLICE_SPEC.md` (content updated) +- `src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/README.md` (VS-03โ†’26, VS-04โ†’27 refs updated) +- `src/KArtSell.Modules.ModelOperations/TradeExecution/README.md` (VS-03โ†’26, VS-04โ†’27, VS-12โ†’28, VS-14โ†’29 refs updated) +- `docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv` rows `AEG-VS-03-01`/`04-01`/`12-01`/`14-01` renamed to `AEG-VS-26-01`/`27-01`/`28-01`/`29-01` +- `docs/CURRENT/CATALOGS/WBS_MASTER.csv` โ€” 4 summary rows appended (VS-26/27/28/29), original rows untouched +- `CURRENT_ROADMAP.md` updated to reflect the resolution + +## Not resolved by this ADR (still needs an explicit decision) + +1. **DEBT-017 (duplicate ApprovalWorkflow implementation).** While investigating this + renumbering, discovered that the VS-26 (formerly VS-03) tracker row credits + `ApprovalWorkflow/` (Workstream H) with "20/20 tests PASS", but that implementation's + endpoints are all `[DontRegister]`'d in FastEndpoints โ€” i.e. dead, unreachable code. The + implementation actually wired into `Program.cs` and reachable over HTTP is + `Features/ApprovalWorkflow/` (Workstream G), which has no dedicated test file found. This + ADR does **not** pick a winner between the two. `AEG-VS-26-01`'s status has been changed from + `COMPLETED` to `BLOCKED` in the tracker pending that decision โ€” see the row's Notes column and + `TECH_DEBT_REGISTER.md` DEBT-017. +2. **`Features/MarketData`/`Features/Portfolio` (VS-03/04/05/08 Market Data Ingestion Dashboard, + Portfolio Rebalance, Risk Metrics, Dashboard).** This is real, committed, tested code + (commits `2bc2b1e`, `32b49a4`, `14c5e4f`, `2eee44d`, `fed750f`, `e0d58ac`) that is completely + absent from `WBS_PROGRESS_TRACKER.csv`. It is not renumbered by this ADR because it appears to + be a genuine (if structurally divergent โ€” it lives under `Features/` rather than as its own + module) implementation attempt at `WBS_MASTER.csv`'s original VS-03/04/05/08 definitions, + not a new collision. It needs to be added to the tracker with real evidence (test results, + whether it is wired into `Program.cs`, whether a frontend exists) rather than silently + inherited into this renumbering. Flagged in `CURRENT_ROADMAP.md` as a follow-up. diff --git a/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/README.md b/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/README.md index 5450bc93..675fad2f 100644 --- a/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/README.md +++ b/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/README.md @@ -1,11 +1,11 @@ -# VS-03: Model Approval Workflow +# VS-26: Model Approval Workflow ## Overview This vertical slice implements a maker-checker approval workflow for model activation. It enforces separation of duties, state machine transitions, and evidence linkage for regulatory compliance. **Status:** โœ… Ready for implementation -**Specification:** `docs/CURRENT/SLICE_SPECS/VS-03-SLICE_SPEC.md` +**Specification:** `docs/CURRENT/SLICE_SPECS/VS-26-SLICE_SPEC.md` --- @@ -183,7 +183,7 @@ published_at, correlation_id - **VS-00:** PIT envelope (published_at, correlation_id, revision) - **VS-02:** Financial security master (governance foundation) -- **VS-04:** Audit trail (logs all approval events) +- **VS-27:** Audit trail (logs all approval events) - **VS-10:** Sell decision (uses approved models) --- @@ -198,7 +198,7 @@ published_at, correlation_id 6. โœ… FastEndpoints (ApprovalEndpoints.cs) 7. โœ… Unit/Integration tests 8. โณ Merge to main (awaiting PR review) -9. โณ Integration with VS-04 (audit trail subscribers) +9. โณ Integration with VS-27 (audit trail subscribers) 10. โณ Phase 2 implementation (after Phase 1 data available) --- diff --git a/src/KArtSell.Modules.ModelOperations/TradeExecution/README.md b/src/KArtSell.Modules.ModelOperations/TradeExecution/README.md index b4e716a6..f8c0919c 100644 --- a/src/KArtSell.Modules.ModelOperations/TradeExecution/README.md +++ b/src/KArtSell.Modules.ModelOperations/TradeExecution/README.md @@ -1,10 +1,10 @@ -# VS-12: Trade Execution System (KIS Integration) +# VS-28: Trade Execution System (KIS Integration) ## Overview -VS-12 implements automated trade execution through Korea Investment & Securities (KIS) API. This vertical slice handles order submission, status polling, settlement confirmation, and reconciliation for approved sell decisions. +VS-28 implements automated trade execution through Korea Investment & Securities (KIS) API. This vertical slice handles order submission, status polling, settlement confirmation, and reconciliation for approved sell decisions. -**Depends On:** VS-10 (sell decisions) โ†’ VS-03 (approval) โ†’ VS-12 (execution) โ†’ VS-14 (reconciliation) +**Depends On:** VS-10 (sell decisions) โ†’ VS-26 (approval) โ†’ VS-28 (execution) โ†’ VS-29 (reconciliation) ## Architecture @@ -21,7 +21,7 @@ PARTIAL_FILLED / FULLY_FILLED (execution progress) โ†“ CONFIRMED (settlement confirmed) โ†“ -RECONCILED (cost basis updated by VS-14) +RECONCILED (cost basis updated by VS-29) ``` ### Components @@ -158,12 +158,12 @@ CREATE TABLE model_operations.trade_status_history ( ### Incoming - **VS-10 (Sell Decision):** Creates TradeSubmitted event โ†’ triggers SubmitTradeHandler -- **VS-03 (Approval):** Approval pre-requisite checked before trade submission +- **VS-26 (Approval):** Approval pre-requisite checked before trade submission ### Outgoing - **TradeSubmittedEvent:** KIS order ID, quantity, sell decision ID - **TradeFilledEvent:** Executed quantity, unit price, trade ID -- **TradeSettledEvent:** Net proceeds, trade ID โ†’ consumed by VS-14 +- **TradeSettledEvent:** Net proceeds, trade ID โ†’ consumed by VS-29 ### External (KIS API) - **Order submission:** POST /v1/orders @@ -182,10 +182,10 @@ CREATE TABLE model_operations.trade_status_history ( ### Audit Trail - โœ… INSERT-only trades & trade_status_history tables - โœ… All state transitions logged with timestamps -- โœ… VS-04 audit trail integration +- โœ… VS-27 audit trail integration ### RBAC -- โœ… System role: Submit trades (via VS-03 approval) +- โœ… System role: Submit trades (via VS-26 approval) - โœ… Operations: View & monitor execution - โœ… Audit: Query immutable trail @@ -196,7 +196,7 @@ CREATE TABLE model_operations.trade_status_history ( - [ ] HTTP client timeout configured (30 seconds default) - [ ] Circuit breaker SLA validated (< 1% error rate) - [ ] Hangfire jobs q-evaluation queue ready -- [ ] VS-04 audit trail integration verified +- [ ] VS-27 audit trail integration verified - [ ] Logs & alerts configured for transient/permanent/liquidity errors ## Performance Considerations @@ -216,9 +216,9 @@ CREATE TABLE model_operations.trade_status_history ( ## Related Documentation - **VS-10:** Sell Decision Engine (PLANNED) -- **VS-03:** Approval Workflow (MERGED, PR #23) -- **VS-04:** Audit Trail (MERGED, PR #24) -- **VS-14:** Portfolio Reconciliation (PLANNED) +- **VS-26:** Approval Workflow (MERGED, PR #23) +- **VS-27:** Audit Trail (MERGED, PR #24) +- **VS-29:** Portfolio Reconciliation (PLANNED) - **CLAUDE.md:** KIS API reference, error handling patterns --- From df7d41df7d7ca0834e1ebfbf4d836c98530ed9d0 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sat, 8 Aug 2026 12:39:18 +0900 Subject: [PATCH 4/5] docs: add DBA grant script and Phase 1 VersionSet approval checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/dba/grant-migration-test-db-ownership.sql: for a DBA to run, fixes the kartsell_migration_test ownership regression blocking DbUpMigrationTests/DbUpRecoveryTests (12 tests) locally. - docs/CURRENT/PHASE-1_APPROVAL_CHECKLIST.md + scripts/phase1/template-approve-versionset.sql: documents/templates the human maker-checker approval steps needed to freeze a VersionSet before Phase 1 shadow run can be re-queued. Does not perform any approval โ€” every placeholder must be filled by a real, named maker and a different named checker. No automation should insert rows into dataset_manifest / model_version_registry / evidence_snapshot / release_evidence_bundle. Co-Authored-By: Claude Sonnet 5 --- docs/CURRENT/PHASE-1_APPROVAL_CHECKLIST.md | 71 +++++++++++++++++++ .../dba/grant-migration-test-db-ownership.sql | 30 ++++++++ .../phase1/template-approve-versionset.sql | 71 +++++++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 docs/CURRENT/PHASE-1_APPROVAL_CHECKLIST.md create mode 100644 scripts/dba/grant-migration-test-db-ownership.sql create mode 100644 scripts/phase1/template-approve-versionset.sql diff --git a/docs/CURRENT/PHASE-1_APPROVAL_CHECKLIST.md b/docs/CURRENT/PHASE-1_APPROVAL_CHECKLIST.md new file mode 100644 index 00000000..3c492321 --- /dev/null +++ b/docs/CURRENT/PHASE-1_APPROVAL_CHECKLIST.md @@ -0,0 +1,71 @@ +# Phase 1 Shadow Run โ€” VersionSet approval checklist + +**Purpose:** `PHASE-1-SHADOW-RUN` (WBS tracker row, `docs/CURRENT/PHASE-1_SHADOW_RUN_STATUS_CORRECTION.md`) +is blocked because `evaluation.dataset_manifest`, `governance.model_version_registry`, +`signal_engine.evidence_snapshot`, and `governance.release_evidence_bundle` contain no +approved/frozen rows. Nothing can be RunId/JobId-enqueued until a human approves a VersionSet. + +**This document is a checklist, not an approval.** It exists so the approval step is fast and +auditable once a real human decides to do it. It does not grant approval, and no automation in +this repository should insert rows into these tables on a schedule or "because the checklist +passed" โ€” every `approved_by`/`checker_id` value below must be a real person who reviewed the +evidence, per CLAUDE.md's maker-checker principle and AGENTS.md's guardrail against fabricated +governance records. + +## Why this can't be scripted away + +Each table below enforces maker-checker at the database level (see the CHECK constraints in +`db/migrations/0016_continuous_model_operations.sql`, `0017_execution_assurance.sql`, +`0034_dataset_manifest_freeze_contract.sql`): + +- `dataset_manifest`: `FROZEN` requires `approved_by`, `approved_at`, `frozen_at` all set, and the + table is append-only (trigger blocks UPDATE/DELETE โ€” corrections are new rows). +- `model_version_registry`: `lifecycle_state = 'APPROVED'` requires `approved_by` + `approved_at`. +- `release_evidence_bundle`: `status IN ('APPROVED','REJECTED')` requires `checker_id IS NOT NULL`, + `checker_id <> maker_id` (the checker cannot be the maker), and `decided_at`. + +There is no code path that satisfies these constraints without a named maker and a *different* +named checker actually deciding. That is intentional โ€” do not add one. + +## Checklist (walk in order) + +1. **Dataset manifest exists and is content-addressed.** + - [ ] A `dataset_manifest` row exists for the target `scope_key` with `content_hash` computed + from the actual dataset (not a placeholder). + - [ ] `lineage_hash` traces back to real source ingestion (KRX/OpenDart/KIS via + `src/KArtSell.Modules.ModelOperations/Infrastructure/`), not synthetic/test data. + - [ ] A maker sets `status = 'PROPOSED'`. + - [ ] A **different** person (checker) reviews the dataset and, if acceptable, updates status to + `APPROVED` and later `FROZEN` (setting `approved_by`, `approved_at`, `frozen_at`). + +2. **Model version registered.** + - [ ] `model_version_registry` row exists for the model/scope with `code_sha` matching the exact + commit that will run, `model_card_hash` matching a real ModelCard document. + - [ ] `lifecycle_state` progressed through `RESEARCH โ†’ CHALLENGER โ†’ SHADOW โ†’ CANDIDATE` with + real review at each step (not skipped). + - [ ] Checker sets `lifecycle_state = 'APPROVED'` with `approved_by`/`approved_at`. + +3. **Evidence snapshot frozen.** + - [ ] `signal_engine.evidence_snapshot` row references the approved `dataset_id` and + `model_version` above, with `content_hash` computed from the actual frozen payload. + +4. **Release evidence bundle.** + - [ ] `release_evidence_bundle` row aggregates build/test/migration/security/rollback artifact + hashes for the exact code that will run. + - [ ] Maker (`maker_id`) creates it in `DRAFT`/`REVIEW_REQUIRED`. + - [ ] A different checker (`checker_id <> maker_id`) reviews and sets `status = 'APPROVED'`, + `decided_at`. + +5. **Only after all four are real and APPROVED/FROZEN:** + - [ ] Reconcile `shadow_run.check_status` constraint (already done โ€” see + `db/migrations/0032_shadow_run_queued_status_contract.sql`, `AEG-X-004`). + - [ ] Call `POST /api/shadow-runs` referencing the approved VersionSet. + - [ ] Record the returned RunId/JobId in `docs/CURRENT/PHASE-1_SHADOW_RUN_STATUS_CORRECTION.md` + and update `WBS_PROGRESS_TRACKER.csv`'s `PHASE-1-SHADOW-RUN` row to `RUNNING` โ€” only with that + real RunId/JobId cited as evidence. + +## SQL template + +`scripts/phase1/template-approve-versionset.sql` has the parameterized statements for steps 1-4, +with every value that must be a real human decision left as an explicit placeholder. It is a +template to hand-fill and run interactively (e.g. via `psql`), not a script to execute as-is. diff --git a/scripts/dba/grant-migration-test-db-ownership.sql b/scripts/dba/grant-migration-test-db-ownership.sql new file mode 100644 index 00000000..7bb75c28 --- /dev/null +++ b/scripts/dba/grant-migration-test-db-ownership.sql @@ -0,0 +1,30 @@ +-- DEBT: AEG-X-004 regression (2026-08-07) โ€” see docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv row AEG-X-004 +-- and CURRENT_ROADMAP.md "์•Œ๋ ค์ง„ ๋ฌธ์„œ ์ •ํ•ฉ์„ฑ ๋ฌธ์ œ" #2. +-- +-- Symptom: 12 DbUpMigrationTests (Migration0008/0009/0010/0032) fail locally with +-- Postgres 42501 "must be owner of database kartsell_migration_test" โ€” the `kartsell` +-- DB user can no longer DROP+CREATE that database, which the fresh/upgrade/re-run +-- rehearsal requires. +-- +-- Run this as a Postgres superuser (or the role that currently owns kartsell_migration_test) +-- against the target server, NOT as the `kartsell` application user (it cannot grant itself +-- ownership of a database it does not already own). +-- +-- Usage: +-- psql -h -p 5432 -U -d postgres -f scripts/dba/grant-migration-test-db-ownership.sql + +ALTER DATABASE kartsell_migration_test OWNER TO kartsell; + +-- If the database does not exist yet on this server, create it first, then re-run the ALTER +-- above (CREATE DATABASE already makes the creating role the owner, so the ALTER becomes a +-- no-op in that case): +-- CREATE DATABASE kartsell_migration_test OWNER kartsell; + +-- Verify: +-- SELECT datname, pg_catalog.pg_get_userbyid(datdba) AS owner +-- FROM pg_database WHERE datname = 'kartsell_migration_test'; +-- Expected: owner = kartsell + +-- After this grant, re-run the isolated rehearsal to confirm: +-- dotnet test --filter "FullyQualifiedName~DbUpMigrationTests" -c Release +-- dotnet test --filter "FullyQualifiedName~DbUpRecoveryTests" -c Release diff --git a/scripts/phase1/template-approve-versionset.sql b/scripts/phase1/template-approve-versionset.sql new file mode 100644 index 00000000..0d7c4082 --- /dev/null +++ b/scripts/phase1/template-approve-versionset.sql @@ -0,0 +1,71 @@ +-- Phase 1 Shadow Run โ€” VersionSet approval TEMPLATE. +-- See docs/CURRENT/PHASE-1_APPROVAL_CHECKLIST.md before touching this file. +-- +-- THIS IS NOT A SCRIPT TO RUN AS-IS. Every :placeholder below must be filled in by a real +-- person who actually reviewed the referenced evidence. Do not fill these in programmatically, +-- from a template default, or because "the checklist items are all checked" โ€” the checker must +-- be a different person from the maker, and both must be named humans who can be held +-- accountable for the decision. Running this file IS the approval action. +-- +-- Usage: fill in every :placeholder, remove this comment block, then run interactively: +-- psql "$KARTSELL_POSTGRES" -f scripts/phase1/template-approve-versionset.sql +-- Do not run non-interactively / in CI / from a job. + +begin; + +-- 1) Dataset manifest: propose, then (as a DIFFERENT session/person) approve + freeze. +insert into evaluation.dataset_manifest + (dataset_id, scope_key, content_hash, source_catalog_version, lineage_hash, status, frozen_at) +values + (:'dataset_id', :'scope_key', :'content_hash', :'source_catalog_version', :'lineage_hash', + 'PROPOSED', now()); + +-- -- Run as a SEPARATE step by the checker, after real review, not in the same transaction: +-- update evaluation.dataset_manifest +-- set status = 'APPROVED', approved_by = :'checker_name', approved_at = now() +-- where dataset_id = :'dataset_id'; +-- -- table is append-only after that point; freezing requires a new row, not an UPDATE to FROZEN +-- -- (see db/migrations/0034_dataset_manifest_freeze_contract.sql trigger) โ€” insert a follow-up +-- -- row with status = 'FROZEN' and the same content_hash lineage instead. + +-- 2) Model version registry. +insert into governance.model_version_registry + (model_version, scope_key, config_version, code_sha, contract_version, lifecycle_state, + effective_at, model_card_hash) +values + (:'model_version', :'scope_key', :'config_version', :'code_sha', :'contract_version', + 'CANDIDATE', now(), :'model_card_hash'); + +-- -- Checker step, separately, after real review: +-- update governance.model_version_registry +-- set lifecycle_state = 'APPROVED', approved_by = :'checker_name', approved_at = now() +-- where model_version = :'model_version' and scope_key = :'scope_key' and effective_at = :'effective_at'; + +-- 3) Evidence snapshot (references the approved dataset + model version above). +insert into signal_engine.evidence_snapshot + (evidence_id, as_of, published_at_cutoff, dataset_id, data_hash, model_version, + config_version, payload, content_hash) +values + (:'evidence_id', now(), :'published_at_cutoff', :'dataset_id', :'data_hash', + :'model_version', :'config_version', :'payload_json'::jsonb, :'content_hash'); + +-- 4) Release evidence bundle โ€” maker creates, a DIFFERENT checker approves. +insert into governance.release_evidence_bundle + (bundle_id, release_version, source_manifest_hash, build_artifact_hash, test_artifact_hash, + migration_artifact_hash, security_artifact_hash, rollback_artifact_hash, decision_log_hash, + status, maker_id, created_at, content_hash) +values + (gen_random_uuid(), :'release_version', :'source_manifest_hash', :'build_artifact_hash', + :'test_artifact_hash', :'migration_artifact_hash', :'security_artifact_hash', + :'rollback_artifact_hash', :'decision_log_hash', 'REVIEW_REQUIRED', :'maker_name', now(), + :'bundle_content_hash'); + +-- -- Checker step, separately, after real review (checker_id must differ from maker_id โ€” enforced +-- -- by CHECK constraint): +-- update governance.release_evidence_bundle +-- set status = 'APPROVED', checker_id = :'checker_name', decided_at = now() +-- where bundle_id = :'bundle_id'; + +-- Do not COMMIT this transaction until you are the maker completing steps above as one unit. +-- The checker updates are separate transactions run later by a different person. +commit; From 14e2cedc4f7c358b5e320dfa42290bbfaf62a7f1 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sat, 8 Aug 2026 13:05:03 +0900 Subject: [PATCH 5/5] fix: resolve DEBT-017 duplicate ApprovalWorkflow implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt Features/ApprovalWorkflow/ (wired into Program.cs, reachable over HTTP) as the sole VS-26 (formerly VS-03) maker-checker approval slice. Delete the dead, [DontRegister]'d duplicate under ApprovalWorkflow/ (Workstream H) and its dedicated test file, which had been misleadingly credited with "20/20 tests PASS" while being unreachable at runtime. - Sql.cs: fix the same Dapper DateOnly-parameter-binding bug that was already found and fixed in the now-deleted implementation (commit 2ccf74c) but had not been ported to this one; InsertProposalAsync would have failed 100% of the time against a real database. - tests/.../ApprovalWorkflow/ApprovalWorkflowTests.cs: new Handler+Sql+ real-Postgres integration coverage (create/approve/activate role gating, maker!=checker separation of duties, evidence attachment, DateOnly round-trip, list filtering) replacing the deleted dead-code suite at the same path. - ApprovalWorkflowPolicyTests.cs: extended (5->10 cases) rather than replaced, since it already tested the kept implementation's Policy. - Program.cs: drop the reference comment to the deleted namespace. - TECH_DEBT_REGISTER.md: DEBT-017 marked Completed (DB verification pending); corrected stale DEBT-023 to point at this resolution; registered two residual gaps discovered (not introduced) by this cleanup as DEBT-025 (no GET /approvals/{id}, evidence unreachable via HTTP) and DEBT-026 (no wired Draft->Proposed transition, so the approve/activate path is currently unreachable end-to-end via HTTP). - WBS_PROGRESS_TRACKER.csv / CURRENT_ROADMAP.md: AEG-VS-26-01 kept BLOCKED, not COMPLETED โ€” no PostgreSQL was reachable in this session (127.0.0.1:5432 connection refused), so the 8 new integration tests are unverified; only the 10 pure-Policy tests were confirmed passing. Cherry-picked cedc8d7/8c777df from docs/wbs-tracker-current-state onto this worktree branch first, to bring in the VS-26 renumbering and ADR-WBS-001 that this task's brief assumed already existed. dotnet build -c Release: 0 errors/0 warnings. dotnet test --filter "FullyQualifiedName~ApprovalWorkflow" -c Release: 10 passed (Policy, no DB), 15 failed (DB connection refused - includes 6 unrelated pre-existing tests matched by the filter substring). Co-Authored-By: Claude Sonnet 5 --- CURRENT_ROADMAP.md | 14 +- TECH_DEBT_REGISTER.md | 6 +- .../CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv | 2 +- src/KArtSell.Host/Program.cs | 2 +- .../ApprovalWorkflow/ApprovalEndpoints.cs | 177 -------- .../ApprovalWorkflow/ApprovalHandlers.cs | 205 ---------- .../ApprovalWorkflow/ApprovalPolicy.cs | 162 -------- .../ApprovalWorkflow/ApprovalProposal.cs | 109 ----- .../ApprovalWorkflow/ApprovalSql.cs | 216 ---------- .../ApprovalWorkflow/README.md | 208 ---------- .../Features/ApprovalWorkflow/README.md | 80 +++- .../Features/ApprovalWorkflow/Sql.cs | 8 +- .../ApprovalWorkflow/ApprovalWorkflowTests.cs | 380 +++++++++--------- .../ApprovalWorkflowPolicyTests.cs | 39 ++ 14 files changed, 324 insertions(+), 1284 deletions(-) delete mode 100644 src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalEndpoints.cs delete mode 100644 src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalHandlers.cs delete mode 100644 src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalPolicy.cs delete mode 100644 src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalProposal.cs delete mode 100644 src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalSql.cs delete mode 100644 src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/README.md diff --git a/CURRENT_ROADMAP.md b/CURRENT_ROADMAP.md index e013fd02..3630986c 100644 --- a/CURRENT_ROADMAP.md +++ b/CURRENT_ROADMAP.md @@ -2,14 +2,14 @@ **์ตœ์ข… ๊ฐฑ์‹ :** 2026-08-08 (VS ๋ฒˆํ˜ธ ์žฌ๋ฐฐ์ • โ€” ์•„๋ž˜ "์•Œ๋ ค์ง„ ๋ฌธ์„œ ์ •ํ•ฉ์„ฑ ๋ฌธ์ œ" 1๋ฒˆ ์ฐธ์กฐ. 2026-08-07 ๊ฐฑ์‹  ๋‚ด์šฉ์€ ์‹ค์ œ ์ฝ”๋“œ/ํ…Œ์ŠคํŠธ๋ฅผ ์ง์ ‘ ํ™•์ธํ•œ ๊ฒฐ๊ณผ์˜€๊ณ  ์ด๋ฒˆ ๊ฐฑ์‹ ์€ ๊ทธ ์œ„์— ๋ฒˆํ˜ธ ์ถฉ๋Œ๋งŒ ์ •์ •ํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค.) -**์ƒํƒœ ์š”์•ฝ:** VS-27(๊ฐ์‚ฌ ์ถ”์ ), VS-10(๋งค๋„ ๊ฒฐ์ •), VS-28(๊ฑฐ๋ž˜ ์‹คํ–‰), VS-29(ํฌํŠธํด๋ฆฌ์˜ค ๋Œ€์‚ฌ) ๋ฐฑ์—”๋“œ ๊ตฌํ˜„ + ํ…Œ์ŠคํŠธ ์™„๋ฃŒ. VS-26(์Šน์ธ ์›Œํฌํ”Œ๋กœ์šฐ, ๊ตฌ VS-03)์€ DEBT-017(์ค‘๋ณต ๊ตฌํ˜„) ๋ฏธํ•ด๊ฒฐ๋กœ **BLOCKED**. Phase 1 Shadow Run(Gate 5a, 252+ ๊ฑฐ๋ž˜์ผ ๊ฒ€์ฆ)์€ **์•„์ง ์‹œ์ž‘๋˜์ง€ ์•Š์Œ** (๊ณผ๊ฑฐ "RUNNING" ๊ธฐ๋ก์€ ํ—ˆ์œ„์˜€์Œ์ด ์ด๋ฏธ ๋ฌธ์„œ๋กœ ์ •์ •๋จ). ์ƒ์„ธ ํ•ญ๋ชฉ๋ณ„ ์ƒํƒœ๋Š” `docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv` ์ฐธ์กฐ. +**์ƒํƒœ ์š”์•ฝ:** VS-27(๊ฐ์‚ฌ ์ถ”์ ), VS-10(๋งค๋„ ๊ฒฐ์ •), VS-28(๊ฑฐ๋ž˜ ์‹คํ–‰), VS-29(ํฌํŠธํด๋ฆฌ์˜ค ๋Œ€์‚ฌ) ๋ฐฑ์—”๋“œ ๊ตฌํ˜„ + ํ…Œ์ŠคํŠธ ์™„๋ฃŒ. VS-26(์Šน์ธ ์›Œํฌํ”Œ๋กœ์šฐ, ๊ตฌ VS-03)์€ DEBT-017(์ค‘๋ณต ๊ตฌํ˜„) ์•„ํ‚คํ…ํŠธ ๊ฒฐ์ •์ด 2026-08-08์— ๋‚ด๋ ค์ง€๊ณ  ์‹คํ–‰๋˜์—ˆ์œผ๋‚˜(์ฃฝ์€ ๊ตฌํ˜„ ์‚ญ์ œ, ์œ ์ผ ๊ตฌํ˜„์— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ์‹ ๊ทœ ์ž‘์„ฑ), ๊ทธ ํ…Œ์ŠคํŠธ๋ฅผ ์‹ค PostgreSQL๋กœ ๊ฒ€์ฆํ•˜์ง€ ๋ชปํ•ด ์—ฌ์ „ํžˆ **BLOCKED**. Phase 1 Shadow Run(Gate 5a, 252+ ๊ฑฐ๋ž˜์ผ ๊ฒ€์ฆ)์€ **์•„์ง ์‹œ์ž‘๋˜์ง€ ์•Š์Œ** (๊ณผ๊ฑฐ "RUNNING" ๊ธฐ๋ก์€ ํ—ˆ์œ„์˜€์Œ์ด ์ด๋ฏธ ๋ฌธ์„œ๋กœ ์ •์ •๋จ). ์ƒ์„ธ ํ•ญ๋ชฉ๋ณ„ ์ƒํƒœ๋Š” `docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv` ์ฐธ์กฐ. --- ## โš ๏ธ ์•Œ๋ ค์ง„ ๋ฌธ์„œ ์ •ํ•ฉ์„ฑ ๋ฌธ์ œ (DECISION_REQUIRED) 1. **VS ๋ฒˆํ˜ธ ์ฒด๊ณ„ ์ถฉ๋Œ โ€” 2026-08-08 ๋ถ€๋ถ„ ํ•ด๊ฒฐ:** `WBS_MASTER.csv`(์› ๊ณ„ํš)์™€ `WBS_PROGRESS_TRACKER.csv`(์‹คํ–‰ ํŠธ๋ž˜์ปค) ์‚ฌ์ด์˜ VS-03/VS-04/VS-12/VS-14 ์ถฉ๋Œ์€ ํŠธ๋ž˜์ปค ์ชฝ 4๊ฐœ ์Šฌ๋ผ์ด์Šค(์Šน์ธ์›Œํฌํ”Œ๋กœ์šฐ/๊ฐ์‚ฌ์ถ”์ /๊ฑฐ๋ž˜์‹คํ–‰/ํฌํŠธํด๋ฆฌ์˜ค๋Œ€์‚ฌ)๋ฅผ VS-26/27/28/29๋กœ ์žฌ๋ฒˆํ˜ธ ๋ถ€์—ฌํ•˜์—ฌ ํ•ด๊ฒฐํ–ˆ์Šต๋‹ˆ๋‹ค. ๊ทผ๊ฑฐ: `docs/DECISIONS/ADR-WBS-001-slice-renumbering.md`. - - **์ด ๊ณผ์ •์—์„œ ์ƒˆ๋กœ ๋ฐœ๊ฒฌํ•œ, ์•„์ง ๋ฏธํ•ด๊ฒฐ์ธ ๋ฌธ์ œ (TECH_DEBT_REGISTER.md DEBT-017):** VS-26(๊ตฌ VS-03) ์Šน์ธ์›Œํฌํ”Œ๋กœ์šฐ๋Š” **๋™์ผ ๊ธฐ๋Šฅ์˜ ์ค‘๋ณต ๊ตฌํ˜„์ด ๋‘ ๋ฒŒ** ์กด์žฌํ•ฉ๋‹ˆ๋‹ค โ€” `src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/`(20/20 ํ…Œ์ŠคํŠธ ํ†ต๊ณผํ•˜์ง€๋งŒ ์ „ ์—”๋“œํฌ์ธํŠธ๊ฐ€ `[DontRegister]`๋กœ ๋น„ํ™œ์„ฑํ™”๋˜์–ด ์‹ค์ œ๋กœ๋Š” ํ˜ธ์ถœ ๋ถˆ๊ฐ€๋Šฅํ•œ ์ฃฝ์€ ์ฝ”๋“œ)์™€ `Features/ApprovalWorkflow/`(`Program.cs`์— ์‹ค์ œ ๋“ฑ๋ก๋˜์–ด ์‚ด์•„์žˆ์ง€๋งŒ ์ „์šฉ ํ…Œ์ŠคํŠธ๊ฐ€ ์—†์Œ). ์ฆ‰ **ํ…Œ์ŠคํŠธ๋œ ์ฝ”๋“œ๋Š” ์ฃฝ์–ด์žˆ๊ณ , ์‚ด์•„์žˆ๋Š” ์ฝ”๋“œ๋Š” ํ…Œ์ŠคํŠธ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.** ์•„ํ‚คํ…ํŠธ ๊ฒฐ์ •(๋‘˜ ์ค‘ ํ•˜๋‚˜๋ฅผ ์ •์‹์œผ๋กœ ์„ ํƒํ•˜๊ณ  ๋‚˜๋จธ์ง€ ์‚ญ์ œ) ์ „๊นŒ์ง€ ์ด ์Šฌ๋ผ์ด์Šค์— ํ”„๋ŸฐํŠธ์—”๋“œ๋ฅผ ๋ถ™์ด์ง€ ๋งˆ์„ธ์š”. WBS_PROGRESS_TRACKER.csv์˜ AEG-VS-26-01 ํ–‰ ์ƒํƒœ๋ฅผ `BLOCKED`๋กœ ์ •์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. + - **2026-08-08 ํ›„์† ๊ฐฑ์‹  (TECH_DEBT_REGISTER.md DEBT-017 ํ•ด๊ฒฐ):** ์ฃฝ์€ ๊ตฌํ˜„(`src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/`, `[DontRegister]`)๊ณผ ๊ทธ ์ „์šฉ ํ…Œ์ŠคํŠธ ํŒŒ์ผ์„ ์‚ญ์ œํ–ˆ์Šต๋‹ˆ๋‹ค. ์‚ด์•„์žˆ๋Š” `Features/ApprovalWorkflow/`๊ฐ€ ์ด์ œ ์œ ์ผํ•œ ๊ตฌํ˜„์ด๋ฉฐ, ๋™์ผ ์‹œ๋‚˜๋ฆฌ์˜ค(์ƒ์„ฑ/์Šน์ธ/ํ™œ์„ฑํ™” ์—ญํ•  ๊ฒ€์ฆ, makerโ‰ checker ๋ถ„๋ฆฌ, ์ฆ๊ฑฐ ์ฒจ๋ถ€, `DateOnly` ๋ผ์šด๋“œํŠธ๋ฆฝ)๋ฅผ ๊ฒ€์ฆํ•˜๋Š” Handler+Sql+์‹คDB ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ๋ฅผ ์ƒˆ๋กœ ์ž‘์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค. ํฌํŒ… ๊ณผ์ •์—์„œ ์‚ด์•„์žˆ๋Š” ๊ตฌํ˜„์˜ `Sql.cs`์—๋„ ์ฃฝ์€ ์ฝ”๋“œ์— ์žˆ๋˜ ๊ฒƒ๊ณผ ๋™์ผํ•œ Dapper `DateOnly` ๋ฐ”์ธ๋”ฉ ๋ฒ„๊ทธ๊ฐ€ ์žˆ์Œ์„ ๋ฐœ๊ฒฌํ•ด ๋™์ผํ•œ ๋ฐฉ์‹์œผ๋กœ ์ˆ˜์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค๋งŒ **์ด ์„ธ์…˜์—์„œ๋„ ์‹ค PostgreSQL์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์–ด(127.0.0.1:5432 ์—ฐ๊ฒฐ ๊ฑฐ๋ถ€, SSH ํ„ฐ๋„ ๋ฏธ๊ฐœํ†ต) ์ƒˆ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ 8๊ฑด์€ ํ•˜๋‚˜๋„ ์‹คํ–‰ ๊ฒ€์ฆ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค** โ€” ์ˆœ์ˆ˜ Policy ํ…Œ์ŠคํŠธ 10๊ฑด๋งŒ ํ†ต๊ณผ ํ™•์ธ. ๊ทธ๋ž˜์„œ AEG-VS-26-01์€ ์—ฌ์ „ํžˆ `BLOCKED`์ž…๋‹ˆ๋‹ค. ์ด๋ฒˆ ์ •๋ฆฌ ๊ณผ์ •์—์„œ ๋‘ ๊ฐ€์ง€ ์ž”์—ฌ ๊ฒฐํ•จ๋„ ๋ฐœ๊ฒฌํ–ˆ์Šต๋‹ˆ๋‹ค(์ด๋ฒˆ ์„ธ์…˜์ด ๋งŒ๋“  ๊ฒฐํ•จ ์•„๋‹˜, ๊ธฐ์กด๋ถ€ํ„ฐ ์žˆ์—ˆ์Œ): `GET /approvals/{id}` ์—”๋“œํฌ์ธํŠธ๊ฐ€ ์—†์–ด ์Šน์ธ ํ›„ ์ฆ๊ฑฐ(evidence)๋ฅผ HTTP๋กœ ์กฐํšŒํ•  ๋ฐฉ๋ฒ•์ด ์—†๊ณ , Draftโ†’Proposed ์ „ํ™˜์„ ํ˜ธ์ถœํ•˜๋Š” Handler/Endpoint๊ฐ€ ์–ด๋””์—๋„ ์—†์–ด ์‹ค์ œ๋กœ๋Š” ์Šน์ธ API๊ฐ€ ๋๊นŒ์ง€ ๋„๋‹ฌ ๋ถˆ๊ฐ€๋Šฅํ•œ ์ƒํƒœ์ž…๋‹ˆ๋‹ค โ€” TECH_DEBT_REGISTER.md DEBT-025/DEBT-026์œผ๋กœ ์‹ ๊ทœ ๋“ฑ๋กํ–ˆ์Šต๋‹ˆ๋‹ค. - **๋˜ ๋‹ค๋ฅธ ๋ฐœ๊ฒฌ โ€” ๋ฏธ์ถ”์  ์ž‘์—…:** `src/KArtSell.Host/Features/MarketData/VS03_*.cs`, `Features/Portfolio/VS04_*.cs`/`VS05_*.cs`/`VS08_*.cs`๋Š” ์‹ค์ œ ๊ตฌํ˜„๋˜๊ณ  ํ…Œ์ŠคํŠธ๋„ ์žˆ๋Š”(commits `2bc2b1e`, `32b49a4`, `14c5e4f`, `2eee44d`) **์„ธ ๋ฒˆ์งธ** VS-03/04/05/08 ์‚ฌ์šฉ๋ก€(Market Data Ingestion Dashboard, Portfolio Rebalance, Risk Metrics, Dashboard)์ธ๋ฐ, `WBS_PROGRESS_TRACKER.csv`์— ์ „ํ˜€ ๊ธฐ๋ก๋˜์–ด ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋‹ค์Œ ์„ธ์…˜์—์„œ ์ด ์ž‘์—…์„ ๊ฒ€์ฆ(๋นŒ๋“œ/ํ…Œ์ŠคํŠธ ์žฌํ˜„, ํ”„๋ŸฐํŠธ์—”๋“œ ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ)ํ•˜๊ณ  ํŠธ๋ž˜์ปค์— ์ถ”๊ฐ€ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. 2. **DbUp ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํ…Œ์ŠคํŠธ DB ๊ถŒํ•œ ๋ฌธ์ œ:** `kartsell` DB ์‚ฌ์šฉ์ž๊ฐ€ `kartsell_migration_test` ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์˜ ์†Œ์œ ์ž๊ฐ€ ์•„๋‹ˆ์–ด์„œ `DbUpMigrationTests`(12๊ฑด)๊ฐ€ ๋กœ์ปฌ์—์„œ ์‹คํŒจํ•ฉ๋‹ˆ๋‹ค. ์ฝ”๋“œ ๋ฌธ์ œ๊ฐ€ ์•„๋‹ˆ๋ผ DBA ์กฐ์น˜(์†Œ์œ ๊ถŒ ๋ถ€์—ฌ)๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ์‹คํ–‰ํ•  SQL ์ดˆ์•ˆ: `scripts/dba/grant-migration-test-db-ownership.sql`. 3. **frontend ๋นŒ๋“œ ์‚ฐ์ถœ๋ฌผ ์žฌํ•ด์‹œ:** `dotnet build`๋ฅผ ์‹คํ–‰ํ•  ๋•Œ๋งˆ๋‹ค `pnpm build`๊ฐ€ ์žฌ์‹คํ–‰๋˜์–ด `wwwroot/assets/*` ํ•ด์‹œ ํŒŒ์ผ๋ช…์ด ๋ฐ”๋€Œ๊ณ  git์— ๋ถˆํ•„์š”ํ•œ ๋ณ€๊ฒฝ์ด ์Œ“์ด๋Š” ๊ตฌ์กฐ์  ๋ฌธ์ œ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค (์•„์ง ๋ฏธํ•ด๊ฒฐ). @@ -18,11 +18,11 @@ ## โœ… ์™„๋ฃŒ (Backend ๊ตฌํ˜„ + ํ…Œ์ŠคํŠธ, 2026-08-07 ๊ธฐ์ค€ ๊ฒ€์ฆ๋จ) -### VS-26 (๊ตฌ VS-03): ๋ชจ๋ธ ์Šน์ธ ์›Œํฌํ”Œ๋กœ์šฐ (Maker-Checker Governance) โ€” ๐Ÿ”ด BLOCKED -- **์œ„์น˜:** `src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/` (์ฃฝ์€ ์ฝ”๋“œ, `[DontRegister]`) + `Features/ApprovalWorkflow/` (์‹ค์ œ ๋“ฑ๋ก๋จ, ํ…Œ์ŠคํŠธ ์—†์Œ) -- **ํ…Œ์ŠคํŠธ:** 20/20 PASS๋Š” ์ฃฝ์€ ์ฝ”๋“œ ์ชฝ ๊ฒฐ๊ณผ์ž…๋‹ˆ๋‹ค โ€” ์‹ค์ œ๋กœ ํ˜ธ์ถœ๋˜๋Š” ๊ตฌํ˜„์€ ํ…Œ์ŠคํŠธ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ์ž์„ธํ•œ ๋‚ด์šฉ์€ TECH_DEBT_REGISTER.md DEBT-017 ์ฐธ์กฐ. -- **๋ฏธ์™„๋ฃŒ:** ํ”„๋ŸฐํŠธ์—”๋“œ UI ์—†์Œ. DEBT-017 ํ•ด๊ฒฐ ์ „๊นŒ์ง€ ์ฐฉ์ˆ˜ ๊ธˆ์ง€. -- **์ด์ „ ์„ธ์…˜์—์„œ ๋ฐœ๊ฒฌ/์ˆ˜์ •ํ•œ ๊ฒฐํ•จ (์—ฌ์ „ํžˆ ์œ ํšจ, ์ฃฝ์€ ์ฝ”๋“œ ์ชฝ ํ•œ์ •):** `ApprovalSql`์ด Dapper๋กœ `DateOnly` ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๋ฐ”์ธ๋”ฉํ•˜์ง€ ๋ชปํ•ด ์Šน์ธ ์ œ์•ˆ์„œ ์ƒ์„ฑ์ด ์‹ค DB ํ™˜๊ฒฝ์—์„œ 100% ์‹คํŒจํ•˜๋˜ ๋ฒ„๊ทธ โ€” ๋ณ‘ํ•ฉ ์ดํ›„ ์‹ค DB๋กœ ํ•œ ๋ฒˆ๋„ ๊ฒ€์ฆ๋˜์ง€ ์•Š์•„ ๋ฐœ๊ฒฌ๋˜์ง€ ์•Š๊ณ  ์žˆ์—ˆ์Œ +### VS-26 (๊ตฌ VS-03): ๋ชจ๋ธ ์Šน์ธ ์›Œํฌํ”Œ๋กœ์šฐ (Maker-Checker Governance) โ€” ๐ŸŸก BLOCKED (DB ๋ฏธ๊ฒ€์ฆ) +- **์œ„์น˜:** `Features/ApprovalWorkflow/` โ€” 2026-08-08๋ถ€๋กœ ์œ ์ผํ•œ ๊ตฌํ˜„ (์ค‘๋ณต ๊ตฌํ˜„ ์‚ญ์ œ ์™„๋ฃŒ, DEBT-017 ์ฐธ์กฐ) +- **ํ…Œ์ŠคํŠธ:** ์ˆœ์ˆ˜ `Policy` ๋‹จ์œ„ ํ…Œ์ŠคํŠธ 10/10 PASS(DB ๋ถˆํ•„์š”). Handler+Sql+์‹คDB ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ 8๊ฑด ์‹ ๊ทœ ์ž‘์„ฑํ–ˆ์œผ๋‚˜ **์ด ์„ธ์…˜์—์„œ ์‹ค PostgreSQL์— ์—ฐ๊ฒฐํ•˜์ง€ ๋ชปํ•ด(127.0.0.1:5432 connection refused) ๋‹จ ํ•˜๋‚˜๋„ ์‹คํ–‰ ๊ฒ€์ฆ๋˜์ง€ ์•Š์Œ.** `dotnet build -c Release`๋Š” 0 ๊ฒฝ๊ณ /0 ์˜ค๋ฅ˜๋กœ ์„ฑ๊ณต. +- **๋ฏธ์™„๋ฃŒ:** ํ”„๋ŸฐํŠธ์—”๋“œ UI ์—†์Œ. ์‹คDB ๋Œ€์ƒ ํ…Œ์ŠคํŠธ ์‹คํ–‰ ์ „๊นŒ์ง€ COMPLETED๋กœ ์ „ํ™˜ ๊ธˆ์ง€. +- **์ด๋ฒˆ ์„ธ์…˜(2026-08-08)์—์„œ ๋ฐœ๊ฒฌ/์ˆ˜์ •ํ•œ ๊ฒฐํ•จ:** ์‚ด์•„์žˆ๋Š” `Features/ApprovalWorkflow/Sql.cs`์˜ `InsertProposalAsync`์— ์ฃฝ์€ `ApprovalSql`์ด ๊ฐ–๊ณ  ์žˆ๋˜ ๊ฒƒ๊ณผ ๋™์ผํ•œ Dapper `DateOnly` ๋ฐ”์ธ๋”ฉ ๋ฒ„๊ทธ๊ฐ€ ์žˆ์—ˆ์Œ(์ˆ˜์ • ์™„๋ฃŒ, DB๋กœ ๋ฏธ๊ฒ€์ฆ). ์ž”์—ฌ ๊ฒฐํ•จ(์ˆ˜์ •ํ•˜์ง€ ์•Š๊ณ  README์—๋งŒ ๊ธฐ๋ก): `GET /approvals/{id}` ์—”๋“œํฌ์ธํŠธ ์—†์Œ(์ฆ๊ฑฐ ์กฐํšŒ ๋ถˆ๊ฐ€), Draftโ†’Proposed ์ „ํ™˜์ด ์–ด๋””์—๋„ ์—ฐ๊ฒฐ๋˜์–ด ์žˆ์ง€ ์•Š์Œ(์Šน์ธ API๊ฐ€ ์‹ค์‚ฌ์šฉ ์‹œ ๋๊นŒ์ง€ ๋„๋‹ฌ ๋ถˆ๊ฐ€๋Šฅ). ### VS-27 (๊ตฌ VS-04): ๋ถˆ๋ณ€ ๊ฐ์‚ฌ ์ถ”์  (Audit Trail / GDPR) - **์œ„์น˜:** `src/KArtSell.Modules.ModelOperations/Compliance/` diff --git a/TECH_DEBT_REGISTER.md b/TECH_DEBT_REGISTER.md index 68c7eafd..1ad7f75f 100644 --- a/TECH_DEBT_REGISTER.md +++ b/TECH_DEBT_REGISTER.md @@ -49,14 +49,16 @@ | DEBT-007 | Newtonsoft.Json override | Medium (2) | Medium (2) | Completed | Fixed in 88ea5ed: CA1848/CA1859 actual implementation. LoggerMessage + HashSet/Dictionary. | @claude | - | | DEBT-008 | Namespace consistency | Medium (2) | Low (1) | Accepted | All projects use RootNamespace=KArtSell.Aegis; AssemblyName retained per-project for DLL clarity. Trade-off accepted: DLL clarity > namespace alignment. No action. | @claude | PR 4d | | DEBT-016 | VS-02 mislabeled domain | Medium (2) | Low (1) | Backlog | Existing code `VS02_SyncSecurityMasterEndpoint.cs`, `VS02_SecurityMasterJobs.cs`, `VS02_SecurityMasterPolicy.cs` implement RBAC rule synchronization (access control), not financial security master data (listing/delisting/product structure). Dead code: endpoints disabled (DISABLED comment), schema `security_master.rules` table never migrated, never deployed. Correct domain documented in `docs/CURRENT/SLICE_SPECS/VS-02-SLICE_SPEC.md` (financial PIT). Removal decision deferred pending architect review (PR recommended). | @claude | docs/CURRENT/SLICE_SPECS/VS-02-SLICE_SPEC.md | -| DEBT-017 | Duplicate VS-03 Approval Workflow implementation | High (3) | Medium (2) | Backlog | Two independent, functionally-identical VS-03 maker-checker slices exist: `ApprovalWorkflow/` (Workstream H, own `ApprovalProposal`/`IClock`/`IOutbox` types) and `Features/ApprovalWorkflow/` (Workstream G, matches documented `Features//` convention). Both mapped the same routes (`/approvals`, `/approvals/{id}`, `/approvals/{id}/approve`), which crashed Host startup with a duplicate-route/missing-DI error the first time the app was actually booted (2026-08-07 โ€” apparently never booted successfully before). Old set annotated `[DontRegister]` (FastEndpoints) 2026-08-07 to unblock boot; code and its test file (`ApprovalWorkflowTests.cs`) kept for now. Needs an architect decision: delete the old slice entirely (and its test) or intentionally keep both for a reason not yet documented. | @claude | Session 2026-08-07 (Phase 3 J/K/L hardening) | +| DEBT-017 | Duplicate VS-26 (formerly VS-03) Approval Workflow implementation | High (3) | Medium (2) | Completed (DB verification pending) | **Decision (2026-08-08):** `Features/ApprovalWorkflow/` (Workstream G) kept as canonical โ€” it is the implementation actually wired into `Program.cs`/`FastEndpoints`. `src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/` (Workstream H, `[DontRegister]`'d dead code) and its dedicated test file (`tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs`, the old 20/20-passing suite that exercised only the dead code) were **deleted**. `ApprovalWorkflowPolicyTests.cs` already tested the kept implementation's pure `Policy` class and was extended (5โ†’10 cases) rather than replaced. New Handler+Sql+real-Postgres integration tests were written at the same path the old dead-code tests occupied (`tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs`), covering create (Maker-role-gated), approve (Makerโ‰ Checker separation of duties, Checker-role-gated, evidence attachment), activate (SRE-role-gated), list filtering, and an explicit `DateOnly EffectiveAt` round-trip. **Bug found and fixed while porting:** the kept implementation's `Sql.cs InsertProposalAsync` had the *exact same* Dapper-cannot-bind-`DateOnly` bug that was found and fixed in the deleted implementation's `ApprovalSql.cs` (commit `2ccf74c`) โ€” i.e. the "tested" dead code had already been fixed for this, but the "live" code had not; it would have failed 100% of proposal-creation calls against a real database. Fixed identically (`::date` cast + `"yyyy-MM-dd"` string parameter). **Not fixed (out of scope, flagged as residual gaps in the slice's README):** no `GET /approvals/{id}` endpoint (evidence becomes unreachable via HTTP after approval), and no wired Draftโ†’Proposed transition anywhere in the running app (`ApprovalWorkflowPolicy.CanProposeForReview` exists but no Handler/Endpoint calls it), and `approval_proposals` rows are mutated in place via `UPDATE` rather than appended as new PIT revisions (the table's schema only has `id` as `PRIMARY KEY`, so the deleted implementation's append-only INSERT approach would itself have violated that constraint on the second write โ€” this is pre-existing, schema-level, and not a regression from this cleanup). **Verification status: `dotnet build -c Release` is clean (0 errors/warnings). `dotnet test --filter "FullyQualifiedName~ApprovalWorkflow" -c Release` was run 2026-08-08: 10/10 pure-`Policy` tests passed; all 8 new DB-backed integration tests failed with `Npgsql.NpgsqlException: Failed to connect to 127.0.0.1:5432` (connection refused) because no PostgreSQL was reachable in that session (no SSH tunnel to 178.104.200.7 open). None of the 8 have been confirmed to pass against a real database.** Do not mark this row fully verified until that run happens; see `docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv` row `AEG-VS-26-01`, kept `BLOCKED` for the same reason. | @claude | commit a2e742c (original dup.), this session's commit (resolution), `docs/DECISIONS/ADR-WBS-001-slice-renumbering.md` | | DEBT-018 | Outbox write not co-transactional with entity write | Medium (2) | Medium (2) | Backlog | `TradeExecution/TradeHandlers.cs` (`TradeOutboxPublisher`) and `PortfolioReconciliation/ReconcileTradeHandler.cs` open a second, separate connection/transaction to write the outbox message after the trade/holding write already committed on its own connection. A crash between the two leaves the entity updated but no outbox event emitted (silent, non-atomic). Proper fix: thread a shared `NpgsqlTransaction` through `TradeSql`/`ReconciliationSql` mutation methods so entity insert + outbox insert commit together, matching `DapperModelOperationRequestRepository`'s pattern. | @claude | Session 2026-08-07 (Phase 3 J/K/L hardening) | | DEBT-019 | Multiple duplicate cross-cutting abstractions (`IClock`, `IOutboxWriter`, `IKrxDataService`) | Medium (2) | Low (1) | Completed (partial) | Found and collapsed 3 separate cases where a slice reinvented an abstraction that already existed in `KArtSell.BuildingBlocks`: a second `IKrxDataService` (deleted, `ShadowRun.Services`), a second `IOutboxWriter`/`WriteAsync` in `ReconcileTradeHandler.cs` (removed, switched to `BuildingBlocks.Reliability.IOutboxWriter`), and a second `IClock`/`SystemClock` in `ApprovalWorkflow/ApprovalPolicy.cs` (removed, switched to `BuildingBlocks.Time.IClock`). Root cause: successive sessions implementing a slice without searching `BuildingBlocks` first. Recommend a pre-implementation checklist step ("does this abstraction already exist in BuildingBlocks?") for future slices. | @claude | Session 2026-08-07 (Phase 3 J/K/L hardening) | | DEBT-020 | `model_operations.models` and `compliance` schema never created by any migration | High (3) | Low (1) | Completed | `0036`/`0038` reference `model_operations.models(id)` via FK and `OpenDartDailyBatchJob.cs` queries it directly, but no migration ever ran `CREATE TABLE model_operations.models`; `0037` wrote to `compliance.*` tables without `CREATE SCHEMA compliance`. Any fresh database โ€” including the actual deploy target (178.104.200.7), confirmed via a live failed SCP/DbMigrator deploy on 2026-08-07 โ€” failed at migration `0036`/`0037`. Fixed via new `0035_model_operations_models.sql` (minimal: id/ticker/published_at/correlation_id/revision only โ€” full Model Card schema is separate future work) and `CREATE SCHEMA IF NOT EXISTS compliance;` added to `0037`. Full chain 0000โ†’0040 now verified fresh-install + idempotent re-run clean. | @claude | Session 2026-08-07 (deploy failure triage) | | DEBT-021 | Dapper never configured for snake_caseโ†”PascalCase column mapping | High (3) | Low (1) | Completed | `Dapper.DefaultTypeMap.MatchNamesWithUnderscores` was never set anywhere in the codebase, so every `QueryAsync`/`QuerySingleOrDefaultAsync` result-mapping onto a snake_case DB column (e.g. `event_type` โ†’ `EventType`) silently returned null/default for that property instead of throwing โ€” masking the bug in every Sql class across every module. Confirmed via `ApprovalWorkflowTests.InsertAndRetrieveProposal_RoundTrips` and `AuditTrailTests.InsertAuditEvent_CreatesImmutableRecord` both getting real rows back with null fields. Fixed centrally via a `[ModuleInitializer]` in `KArtSell.BuildingBlocks/Data/DapperBootstrap.cs` (runs once per process regardless of entry point โ€” Host/DbMigrator/tests). | @claude | Session 2026-08-07 (deploy failure triage) | | DEBT-022 | jsonb/inet columns written as plain text without an explicit cast | Medium (2) | Low (1) | Completed (partial) | Dapper does not know to cast a `string` parameter to `jsonb`/`inet` for Npgsql; `AuditSql.InsertAuditEventAsync` (`details`, `ip_address`), `AuditSql.RedactAuditEventDetailsAsync` (duplicate `SET details =` assignment, separately fixed), `TradeSql.InsertTradeAsync`/`UpdateTradeStatusAsync` (`kis_response`), and `SellDecisionSql.InsertDecisionAsync` (`oos_performance`) all failed with `42804: column "x" is of type jsonb but expression is of type text` the first time they were run against a real schema. Fixed with explicit `::jsonb`/`::inet` casts at each call site (mechanical, no behavior change). `AuditSql`'s jsonb read-back (`Dictionary` from a jsonb column) also needed a raw-DTO + `JsonSerializer.Deserialize` mapping since Dapper has no built-in jsonbโ†’Dictionary conversion either. **Not yet checked**: `PortfolioReconciliation`/`ApprovalWorkflow` Sql classes for the same pattern beyond what surfaced in this session's test runs โ€” a full audit of jsonb/inet columns across all Sql classes is still open. | @claude | Session 2026-08-07 (deploy failure triage) | -| DEBT-023 | `ApprovalSql.InsertProposalAsync` fails on `DateOnly` parameter | Medium (2) | Low (1) | Backlog | `ApprovalWorkflowTests.InsertAndRetrieveProposal_RoundTrips` fails with `System.NotSupportedException: The member effectiveAt of type System.DateOnly cannot be used as a parameter value` โ€” Dapper's `LookupDbType` doesn't recognize `DateOnly` without an explicit type map (`SqlMapper.AddTypeMap`/custom `TypeHandler`). Likely affects every other `DateOnly`-typed Dapper parameter in the codebase, not just this one; needs a similar centralized fix to DEBT-021 rather than a per-call-site patch. Discovered but not fixed in this session (scope cut to unblock the live deploy). | @claude | Session 2026-08-07 (deploy failure triage) | +| DEBT-023 | `ApprovalSql.InsertProposalAsync` fails on `DateOnly` parameter | Medium (2) | Low (1) | Completed | Stale entry, corrected 2026-08-08: this described `ApprovalSql.cs` under `src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/` โ€” that per-call-site fix (`::date` cast + `"yyyy-MM-dd"` string parameter, not a centralized type handler) landed in commit `2ccf74c` but this row was never updated to reflect it. That whole file was then deleted as dead code while resolving DEBT-017 (2026-08-08); its surviving sibling, `Features/ApprovalWorkflow/Sql.cs`, was found to have the *same* unfixed bug independently and received the identical fix in that session โ€” see DEBT-017. No centralized `DateOnly` type handler was added; this remains a per-call-site fix pattern, so any *other* `DateOnly`-typed Dapper INSERT elsewhere in the codebase should still be checked individually rather than assumed safe. | @claude | commit 2ccf74c; DEBT-017 (this session) | | DEBT-024 | New integration tests don't insert FK parent rows / one pure-logic test flakes under full-suite run | Low (1) | Low (1) | Backlog | `TradeExecutionTests` constructs `Trade` with a random `sellDecisionId` that was never inserted into `sell_decisions`, so every insert now correctly fails its FK constraint (`trades_sell_decision_id_fkey`) once the schema was actually complete (see DEBT-020) โ€” test-only gap, not a production code defect; needs the tests updated to insert a parent `models`+`sell_decisions` row first. Separately, `SellPriorityRankerTests.CalculateScore_HardImpairment_ReturnsLowestScore` (pure logic, no DB) passed in isolation but returned 1000 instead of the expected 950 (age-boost not applied) when run as part of the full suite โ€” not yet root-caused; may be test-order/parallelization state leakage rather than a `SellPriorityRanker` bug. Also, `DbUpMigrationTests.*` (pre-existing, unrelated to this session) fail locally with `42501: must be owner of database kartsell_migration_test` โ€” a local Postgres role permission gap, not a code issue. | @claude | Session 2026-08-07 (deploy failure triage) | +| DEBT-025 | `Features/ApprovalWorkflow` has no `GET /approvals/{id}` endpoint | Medium (2) | Low (1) | Backlog | Discovered while resolving DEBT-017 (2026-08-08). The deleted duplicate implementation had a single-proposal fetch endpoint that included the attached `Evidence` list in its response; the kept, canonical implementation (`Features/ApprovalWorkflow/Endpoints.cs`) only has `POST /approvals`, `GET /approvals` (list, no evidence in the DTO), and `POST /approvals/{id}/approve`. Net effect: evidence attached during approval (PBO/DSR/OOS artifact links, the whole point of this slice per CLAUDE.md's "Activation gating") is currently unreachable via HTTP โ€” only readable by querying `model_operations.approval_evidence` directly. Needs a `GetApprovalByIdEndpoint` + `ApprovalDetailResponse` (with `Evidence`) added to the slice; not done in the DEBT-017 session because it is new functionality, not a duplication cleanup, and out of that session's scope. | @claude | DEBT-017 (2026-08-08), `src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/README.md` | +| DEBT-026 | `Features/ApprovalWorkflow` has no wired Draftโ†’Proposed transition | High (3) | Low (1) | Backlog | Discovered while resolving DEBT-017 (2026-08-08). `ApprovalWorkflowPolicy.CanProposeForReview` exists but no `Handler` or `Endpoint` in the kept implementation calls it, so nothing in the running application ever moves a proposal from `Draft` to `Proposed`. `ApproveApprovalHandler` requires `Proposed` and `ActivateModelHandler` requires `Approved`, so as shipped, a proposal created via `POST /approvals` cannot reach `Approved`/`Active` through the HTTP API at all โ€” the maker-checker gate is not actually completable end-to-end today. Higher impact than DEBT-025 because it blocks the slice's core purpose, not just an ancillary read. Needs a `ProposeForReviewHandler` + `POST /approvals/{id}/propose` (or equivalent) endpoint. Not done in the DEBT-017 session (new functionality, out of that session's duplication-cleanup scope). | @claude | DEBT-017 (2026-08-08), `src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/README.md` | --- diff --git a/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv b/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv index 8282827d..965d37f3 100644 --- a/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv +++ b/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv @@ -17,7 +17,7 @@ AEG-VS-00-07,S0,VS-00,ํšŒ๊ท€ยท๊ด€์ œยทRunbookยทRollback ์ฆ๊ฑฐ,COMPLETED,2026-08 AEG-X-009,S1,Cross,Source catalog ๊ณ ๋„ํ™”,COMPLETED,2026-08-07,"docs/CURRENT/CATALOGS/source-catalog.md; docs/CURRENT/AEG-X-009_AUTOMATION_PROPOSAL.md; contracts/data/source-approval.v1.proposed.json; docs/DECISIONS/ADR-DATA-001.md; db/migrations/0033_source_approval_contract.sql; db/migrations/0034_dataset_manifest_freeze_contract.sql; db/migrations/0033_market_data_import_logs.sql; src/KArtSell.Modules.ModelOperations/Infrastructure/DapperApprovedModelContextReader.cs",Data Governance,"โœ… Workstream D/E/F COMPLETED: source-catalog.md v2.0 (KRX/OpenDart/KIS consolidated), VS-02_DATA_GOVERNANCE_POLICY.md, VS-03/04 SLICE_SPECs. All 4 unknowns resolved. โœ… Workstream G (commit 136665c, 2026-08-07) also now COMPLETE: live KRX OpenAPI / OpenDart / KIS service integrations (P1-P3), daily scheduling + error classification + SLA tracking + LKG fallback (P4-P6), market_data schema with append-only import logs, correlation_id-based idempotent replay. โš ๏ธ Note: 0033 is used by two different, unrelated migrations across branches (source_approval_contract.sql vs market_data_import_logs.sql) โ€” confirm actual applied migration number in the target DB's kartsell_schema_versions journal before assuming both landed as authored." AEG-VS-01-01,S1,VS-01,์ •์ฑ…ยท๋ฒ”์œ„ยท์‹คํŒจ์ƒํƒœ ๊ณ„์•ฝ ํ™•์ •,COMPLETED,2026-08-07,docs/CURRENT/SLICE_SPECS/VS-01-SLICE_SPEC.md,PM/Architect,"โœ… SLICE_SPEC produced: VS-01-SLICE_SPEC.md (identity/MFA/RBAC/maker-checker contract). Prerequisite AEG-X-001 + AEG-VS-00-02 already COMPLETED. Ready for security team review and schema implementation." AEG-VS-02-01,S1,VS-02,์ •์ฑ…ยท๋ฒ”์œ„ยท์‹คํŒจ์ƒํƒœ ๊ณ„์•ฝ ํ™•์ •,COMPLETED,2026-08-07,"docs/CURRENT/SLICE_SPECS/VS-02-SLICE_SPEC.md; docs/CURRENT/VS-02_DATA_GOVERNANCE_POLICY.md",PM/Architect,"โœ… COMPLETE: VS-02-SLICE_SPEC.md + governance policy. All 4 unknowns resolved (data source, import SLA, audit policy, schema versioning). Financial security master implementation ready for Phase 2." -AEG-VS-26-01,S2,VS-26,๋ชจ๋ธ ์Šน์ธ ์›Œํฌํ”Œ๋กœ์šฐ ๊ตฌํ˜„ (Maker-Checker Governance),BLOCKED,-,"docs/CURRENT/SLICE_SPECS/VS-26-SLICE_SPEC.md; src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ (ApprovalSql.cs, ApprovalPolicy.cs, [DontRegister]'d); src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/ (Handlers.cs, Sql.cs, Policy.cs โ€” the one actually wired in Program.cs); db/migrations/0036_approval_workflow.sql; tests/KArtSell.Integration.Tests/ApprovalWorkflow/*; TECH_DEBT_REGISTER.md DEBT-017; commit a2e742c (Workstream H, PR #23, merged to main)",PM/BE Lead,"๐Ÿ”ด CORRECTED 2026-08-08 โ€” this row previously said 'Backend implementation + tests complete, 20/20 tests PASS' and that claim is misleading: TECH_DEBT_REGISTER.md DEBT-017 records that TWO independent ApprovalWorkflow implementations exist with colliding routes. The one this row's evidence links to and the one with 20/20 passing tests (`ApprovalWorkflow/`, Workstream H) has all 4 endpoints annotated `[DontRegister]` and is NOT reachable via HTTP. The implementation actually registered in `Program.cs` and reachable at runtime is `Features/ApprovalWorkflow/` (Workstream G) โ€” grep found no dedicated test file exercising it. Net effect: the maker-checker approval gate that is actually live has not been shown to work, and the one that has been shown to work is dead code. Do not build frontend UI against either implementation, and do not re-mark this COMPLETED, until an architect decision (DEBT-017) picks the canonical implementation, deletes the other, and a test suite runs against whichever is kept. Renumbered from VS-03 to VS-26 on 2026-08-08 per docs/DECISIONS/ADR-WBS-001-slice-renumbering.md โ€” VS-03 in WBS_MASTER.csv ('IngestMarketDataPIT') is a separate, unrelated, still-unimplemented slice and keeps its original number unchanged. Also note: src/KArtSell.Host/Features/MarketData/VS03_*.cs is a THIRD, unrelated, already-implemented-and-tested body of work also labeled 'VS-03' (Market Data Ingestion Dashboard, commits 2bc2b1e/32b49a4) that is entirely absent from this tracker โ€” see CURRENT_ROADMAP.md for a note to investigate and add it." +AEG-VS-26-01,S2,VS-26,๋ชจ๋ธ ์Šน์ธ ์›Œํฌํ”Œ๋กœ์šฐ ๊ตฌํ˜„ (Maker-Checker Governance),BLOCKED,-,"docs/CURRENT/SLICE_SPECS/VS-26-SLICE_SPEC.md; src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/ (Handlers.cs, Sql.cs, Policy.cs, Endpoints.cs โ€” sole implementation, wired in Program.cs); db/migrations/0036_approval_workflow.sql; tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs (new, 8 cases); tests/KArtSell.Integration.Tests/ApprovalWorkflowPolicyTests.cs (extended, 10 cases); TECH_DEBT_REGISTER.md DEBT-017/DEBT-023; commit a2e742c (original duplication, superseded)",PM/BE Lead,"๐ŸŸก UPDATED 2026-08-08 โ€” DEBT-017 architect decision made and executed: the duplicate `src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/` (dead code, `[DontRegister]`'d, previously credited with the misleading '20/20 tests PASS') was DELETED along with its dedicated test file. `Features/ApprovalWorkflow/` (the implementation actually reachable over HTTP) is now the sole implementation and gained new Handler+Sql+real-Postgres integration tests covering create/approve/activate role gating, makerโ‰ checker separation of duties, evidence attachment, and an explicit `DateOnly EffectiveAt` round-trip. While porting: found `Features/ApprovalWorkflow/Sql.cs InsertProposalAsync` had the same Dapper `DateOnly`-binding bug already fixed in the deleted implementation (commit 2ccf74c) โ€” fixed identically here (`::date` cast + string parameter). Two residual (pre-existing, not introduced by this cleanup) gaps documented in the slice README rather than fixed: no `GET /approvals/{id}` endpoint, and no wired Draftโ†’Proposed transition anywhere in the running app (approve/activate are therefore currently unreachable end-to-end via HTTP with real data). **Still BLOCKED, not COMPLETED, because no PostgreSQL was reachable in this session (127.0.0.1:5432 connection refused, no SSH tunnel open): `dotnet build -c Release` is clean, but `dotnet test --filter FullyQualifiedName~ApprovalWorkflow -c Release` shows only the 10 pure-Policy (no-DB) tests passing โ€” all 8 new DB-backed integration tests fail with a connection error, unverified either way against a live database.** Do not mark COMPLETED until that run happens against a reachable Postgres and actually passes. Renumbered from VS-03 to VS-26 on 2026-08-08 per docs/DECISIONS/ADR-WBS-001-slice-renumbering.md โ€” VS-03 in WBS_MASTER.csv ('IngestMarketDataPIT') remains a separate, unrelated, still-unimplemented slice. Also still open: `src/KArtSell.Host/Features/MarketData/VS03_*.cs` is a THIRD, unrelated, already-implemented-and-tested body of work also labeled 'VS-03' that is entirely absent from this tracker โ€” see CURRENT_ROADMAP.md." AEG-VS-27-01,S2,VS-27,๋ถˆ๋ณ€ ๊ฐ์‚ฌ ์ถ”์  ๊ตฌํ˜„ (Audit Trail / GDPR),COMPLETED,2026-08-07,"docs/CURRENT/SLICE_SPECS/VS-27-SLICE_SPEC.md; src/KArtSell.Modules.ModelOperations/Compliance/ (AuditSql.cs, GdprRetention.cs); tests/KArtSell.Integration.Tests/Compliance/AuditTrailTests.cs; commit 97444c9 (Workstream I, PR #24, merged to main)",PM/BE Lead,"โœ… Backend implementation + tests complete: append-only audit_events table, GDPR retention tracking + redaction (actor_emailโ†’'', customer_idโ†’''), PII fields (ip_address). 5/5 tests PASS run in isolation (2026-08-07). Also fixed same day (fix/dapper-underscore-mapping-and-build branch): ip_address (inet) and kis_response-style jsonb columns threw InvalidCastException when read through Dapper into a typed class; GdprRetention.RetentionEndsAt was declared DateTime against a DATE column, same failure mode; and a process-wide Dapper snake_case-mapping race condition (KArtSell.BuildingBlocks' [ModuleInitializer] only fires once that assembly loads โ€” AuditSql doesn't reliably touch it) intermittently nulled out every column read from this table depending on unrelated test/host startup order. None of this had ever been exercised against a live database before. โš ๏ธ No frontend UI yet โ€” in progress 2026-08-08. Renumbered from VS-04 to VS-27 on 2026-08-08 per docs/DECISIONS/ADR-WBS-001-slice-renumbering.md โ€” VS-04 in WBS_MASTER.csv ('ApplyCorporateActions') was an unrelated, still-unimplemented slice and keeps its original number unchanged." AEG-VS-05-01,S3,VS-05,์ •์ฑ…ยท๋ฒ”์œ„ยท์‹คํŒจ์ƒํƒœ ๊ณ„์•ฝ ํ™•์ •,PLANNED,-,-,PM/Architect,"Not started. No docs/CURRENT/SLICE_SPECS/VS-05-SLICE_SPEC.md and no src/ implementation exist yet (confirmed 2026-08-07). Previous note referenced 'Job 976 (~50-90 days)' as if Phase 1 were actively running and counting down โ€” that was false; see PHASE-1-SHADOW-RUN row below. Correct statement: blocked behind Gate 1 (Phase 1 shadow run), which itself has not been queued yet." AEG-X-011,S4,Cross,Golden vector ๊ณ ๋„ํ™”,BLOCKED,TBD,"AGENTS.md: Algorithm changes require Golden data",Quant/QA,"Gate 2 prerequisite. Blocked by Phase 1 completion. Corrected 2026-08-07: Phase 1 (Job 893/976) has not been started, not 'in progress' โ€” see PHASE-1-SHADOW-RUN row. No countdown is currently running." diff --git a/src/KArtSell.Host/Program.cs b/src/KArtSell.Host/Program.cs index bef836fa..61720362 100644 --- a/src/KArtSell.Host/Program.cs +++ b/src/KArtSell.Host/Program.cs @@ -208,7 +208,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); -// Approval Workflow (VS-03, maker-checker) +// Approval Workflow (VS-26, formerly VS-03; maker-checker โ€” see docs/DECISIONS/ADR-WBS-001-slice-renumbering.md) builder.Services.AddScoped(sp => new KArtSell.Modules.ModelOperations.Features.ApprovalWorkflow.ApprovalWorkflowSql(connectionString)); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalEndpoints.cs b/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalEndpoints.cs deleted file mode 100644 index d2fe4e59..00000000 --- a/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalEndpoints.cs +++ /dev/null @@ -1,177 +0,0 @@ -namespace KArtSell.Modules.ModelOperations.ApprovalWorkflow; - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using FastEndpoints; -using KArtSell.BuildingBlocks.Time; - -/// -/// Superseded by Features.ApprovalWorkflow.CreateApprovalEndpoint (same route). Kept for -/// ApprovalWorkflowTests.cs coverage of ApprovalSql/ApprovalPolicy; excluded from route -/// registration to avoid a duplicate-route conflict at Host startup. See TECH_DEBT_REGISTER.md. -/// -[DontRegister] -public class CreateApprovalEndpoint : Endpoint -{ - private readonly CreateApprovalProposalHandler _handler; - - public CreateApprovalEndpoint(CreateApprovalProposalHandler handler) - { - _handler = handler; - } - - public override void Configure() - { - Post("/approvals"); - AllowAnonymous(); - } - - public override async Task HandleAsync(CreateApprovalProposalRequest req, CancellationToken ct) - { - var userEmail = User?.FindFirst("email")?.Value ?? "system@kartsell.local"; - var userRole = User?.FindFirst("role")?.Value; - - var response = await _handler.Handle(req, userEmail, userRole); - await Send.CreatedAtAsync(new { id = response.Id }, response, cancellation: ct); - } -} - -/// Superseded by Features.ApprovalWorkflow (same route). See CreateApprovalEndpoint remarks. -[DontRegister] -public class ListApprovalsEndpoint : Endpoint> -{ - private readonly ApprovalSql _sql; - private readonly IClock _clock; - - public ListApprovalsEndpoint(ApprovalSql sql, IClock clock) - { - _sql = sql; - _clock = clock; - } - - public override void Configure() - { - Get("/approvals"); - AllowAnonymous(); - } - - public override async Task HandleAsync(EmptyRequest req, CancellationToken ct) - { - var status = Query("status"); - var cutoff = _clock.UtcNow; - - List proposals; - - if (!string.IsNullOrEmpty(status)) - { - proposals = await _sql.GetProposalsByStatusAsync(status, cutoff); - } - else - { - proposals = await _sql.GetProposalsByStatusAsync("Proposed", cutoff); - } - - var responses = proposals.ConvertAll(p => new ApprovalProposalResponse - { - Id = p.Id, - ModelId = p.ModelId, - Status = p.Status.ToString(), - CreatedBy = p.CreatedBy, - CreatedAt = p.CreatedAt, - Justification = p.Justification, - EffectiveAt = p.EffectiveAt, - ApprovedBy = p.ApprovedBy, - ApprovedAt = p.ApprovedAt, - ApprovalNotes = p.ApprovalNotes - }); - - await Send.OkAsync(responses, ct); - } -} - -/// Superseded by Features.ApprovalWorkflow (same route). See CreateApprovalEndpoint remarks. -[DontRegister] -public class GetApprovalEndpoint : Endpoint -{ - private readonly ApprovalSql _sql; - private readonly IClock _clock; - - public GetApprovalEndpoint(ApprovalSql sql, IClock clock) - { - _sql = sql; - _clock = clock; - } - - public override void Configure() - { - Get("/approvals/{id}"); - AllowAnonymous(); - } - - public override async Task HandleAsync(EmptyRequest req, CancellationToken ct) - { - var id = Route("id"); - var cutoff = _clock.UtcNow; - - var proposal = await _sql.GetProposalByIdAsync(id, cutoff); - if (proposal == null) - { - await Send.NotFoundAsync(ct); - return; - } - - var response = new ApprovalProposalResponse - { - Id = proposal.Id, - ModelId = proposal.ModelId, - Status = proposal.Status.ToString(), - CreatedBy = proposal.CreatedBy, - CreatedAt = proposal.CreatedAt, - Justification = proposal.Justification, - EffectiveAt = proposal.EffectiveAt, - ApprovedBy = proposal.ApprovedBy, - ApprovedAt = proposal.ApprovedAt, - ApprovalNotes = proposal.ApprovalNotes, - Evidence = proposal.Evidence.ConvertAll(e => new ApprovalEvidenceResponse - { - Id = e.Id, - EvidenceType = e.EvidenceType, - EvidenceUrl = e.EvidenceUrl, - ReviewerComment = e.ReviewerComment - }) - }; - - await Send.OkAsync(response, ct); - } -} - -/// Superseded by Features.ApprovalWorkflow (same route). See CreateApprovalEndpoint remarks. -[DontRegister] -public class ApproveApprovalEndpoint : Endpoint -{ - private readonly ApproveApprovalHandler _handler; - private readonly IClock _clock; - - public ApproveApprovalEndpoint(ApproveApprovalHandler handler, IClock clock) - { - _handler = handler; - _clock = clock; - } - - public override void Configure() - { - Post("/approvals/{id}/approve"); - AllowAnonymous(); - } - - public override async Task HandleAsync(ApproveApprovalRequest req, CancellationToken ct) - { - var id = Route("id"); - var checkerEmail = User?.FindFirst("email")?.Value ?? "system@kartsell.local"; - var cutoff = _clock.UtcNow; - - var response = await _handler.Handle(id, req, checkerEmail, cutoff); - await Send.OkAsync(response, ct); - } -} diff --git a/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalHandlers.cs b/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalHandlers.cs deleted file mode 100644 index 97415497..00000000 --- a/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalHandlers.cs +++ /dev/null @@ -1,205 +0,0 @@ -namespace KArtSell.Modules.ModelOperations.ApprovalWorkflow; - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using KArtSell.BuildingBlocks; - -public class CreateApprovalProposalHandler -{ - private readonly ApprovalSql _sql; - private readonly ApprovalPolicy _policy; - private readonly IOutbox _outbox; - - public CreateApprovalProposalHandler(ApprovalSql sql, ApprovalPolicy policy, IOutbox outbox) - { - _sql = sql; - _policy = policy; - _outbox = outbox; - } - - public async Task Handle( - CreateApprovalProposalRequest request, - string userEmail, - string? userRole) - { - if (!_policy.CanCreateProposal(userEmail, userRole)) - throw new UnauthorizedAccessException("Only Makers can create approval proposals"); - - var proposal = _policy.CreateProposal( - request.ModelId, - userEmail, - request.Justification, - request.EffectiveAt); - - await _sql.InsertProposalAsync( - proposal.Id, - proposal.ModelId, - proposal.Status.ToString(), - proposal.CreatedBy, - proposal.Justification, - proposal.EffectiveAt, - proposal.PublishedAt, - proposal.CorrelationId); - - // Log event - var evt = _policy.CreateProposalEvent(proposal, "CREATED", userEmail); - await _sql.InsertEventAsync(evt.Id, evt.ApprovalProposalId, evt.EventType, evt.ActorEmail, evt.Details, evt.CorrelationId); - - // Emit Outbox event - await _outbox.PublishAsync("ApprovalProposalCreated", proposal.CorrelationId, new { proposal.Id, proposal.ModelId }); - - return MapToResponse(proposal); - } - - private ApprovalProposalResponse MapToResponse(ApprovalProposal proposal) - { - return new ApprovalProposalResponse - { - Id = proposal.Id, - ModelId = proposal.ModelId, - Status = proposal.Status.ToString(), - CreatedBy = proposal.CreatedBy, - CreatedAt = proposal.CreatedAt, - Justification = proposal.Justification, - EffectiveAt = proposal.EffectiveAt, - ApprovedBy = proposal.ApprovedBy, - ApprovedAt = proposal.ApprovedAt, - ApprovalNotes = proposal.ApprovalNotes, - Evidence = proposal.Evidence.ConvertAll(e => new ApprovalEvidenceResponse - { - Id = e.Id, - EvidenceType = e.EvidenceType, - EvidenceUrl = e.EvidenceUrl, - ReviewerComment = e.ReviewerComment - }) - }; - } -} - -public class ApproveApprovalHandler -{ - private readonly ApprovalSql _sql; - private readonly ApprovalPolicy _policy; - private readonly IOutbox _outbox; - - public ApproveApprovalHandler(ApprovalSql sql, ApprovalPolicy policy, IOutbox outbox) - { - _sql = sql; - _policy = policy; - _outbox = outbox; - } - - public async Task Handle( - Guid proposalId, - ApproveApprovalRequest request, - string checkerEmail, - DateTimeOffset cutoff) - { - var proposal = await _sql.GetProposalByIdAsync(proposalId, cutoff) - ?? throw new KeyNotFoundException("Approval proposal not found"); - - if (!_policy.CanApproveApproval(proposal, checkerEmail, proposal.CreatedBy)) - throw new UnauthorizedAccessException("Cannot approve: separation of duties violation or wrong status"); - - proposal = _policy.ApproveApproval(proposal, checkerEmail, request.ApprovalNotes, request.Evidence); - - // Update proposal - await _sql.UpdateProposalStatusAsync( - proposal.Id, - proposal.Status.ToString(), - checkerEmail, - request.ApprovalNotes, - proposal.PublishedAt); - - // Add evidence - foreach (var evidence in request.Evidence) - { - await _sql.InsertEvidenceAsync( - Guid.NewGuid(), - proposal.Id, - evidence.Type, - evidence.Url, - evidence.Comment, - proposal.CorrelationId); - } - - // Log event - var evt = _policy.CreateProposalEvent(proposal, "APPROVED", checkerEmail); - await _sql.InsertEventAsync(evt.Id, evt.ApprovalProposalId, evt.EventType, evt.ActorEmail, evt.Details, evt.CorrelationId); - - // Emit Outbox event - await _outbox.PublishAsync("ApprovalProposalApproved", proposal.CorrelationId, new { proposal.Id, checkerEmail }); - - return MapToResponse(proposal); - } - - private ApprovalProposalResponse MapToResponse(ApprovalProposal proposal) - { - return new ApprovalProposalResponse - { - Id = proposal.Id, - ModelId = proposal.ModelId, - Status = proposal.Status.ToString(), - CreatedBy = proposal.CreatedBy, - CreatedAt = proposal.CreatedAt, - Justification = proposal.Justification, - EffectiveAt = proposal.EffectiveAt, - ApprovedBy = proposal.ApprovedBy, - ApprovedAt = proposal.ApprovedAt, - ApprovalNotes = proposal.ApprovalNotes, - Evidence = proposal.Evidence.ConvertAll(e => new ApprovalEvidenceResponse - { - Id = e.Id, - EvidenceType = e.EvidenceType, - EvidenceUrl = e.EvidenceUrl, - ReviewerComment = e.ReviewerComment - }) - }; - } -} - -public class ActivateApprovalHandler -{ - private readonly ApprovalSql _sql; - private readonly ApprovalPolicy _policy; - private readonly IOutbox _outbox; - - public ActivateApprovalHandler(ApprovalSql sql, ApprovalPolicy policy, IOutbox outbox) - { - _sql = sql; - _policy = policy; - _outbox = outbox; - } - - public async Task Handle(Guid proposalId, string sreEmail, string? userRole, DateTimeOffset cutoff) - { - if (!_policy.CanActivateApproval(new ApprovalProposal { CreatedBy = string.Empty, Justification = string.Empty }, userRole ?? string.Empty)) - throw new UnauthorizedAccessException("Only SRE can activate approvals"); - - var proposal = await _sql.GetProposalByIdAsync(proposalId, cutoff) - ?? throw new KeyNotFoundException("Approval proposal not found"); - - proposal = _policy.ActivateApproval(proposal, sreEmail); - - // Update proposal status to ACTIVE - await _sql.UpdateProposalStatusAsync( - proposal.Id, - proposal.Status.ToString(), - sreEmail, - null, - proposal.PublishedAt); - - // Log event - var evt = _policy.CreateProposalEvent(proposal, "ACTIVATED", sreEmail); - await _sql.InsertEventAsync(evt.Id, evt.ApprovalProposalId, evt.EventType, evt.ActorEmail, evt.Details, evt.CorrelationId); - - // Emit Outbox event for model activation - await _outbox.PublishAsync("ApprovalProposalActivated", proposal.CorrelationId, new { proposal.Id, proposal.ModelId }); - } -} - -public interface IOutbox -{ - Task PublishAsync(string eventType, Guid correlationId, object data); -} diff --git a/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalPolicy.cs b/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalPolicy.cs deleted file mode 100644 index 7353ab52..00000000 --- a/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalPolicy.cs +++ /dev/null @@ -1,162 +0,0 @@ -namespace KArtSell.Modules.ModelOperations.ApprovalWorkflow; - -using System; -using System.Collections.Generic; -using System.Linq; -using KArtSell.BuildingBlocks.Time; - -public class ApprovalPolicy -{ - private readonly IClock _clock; - - public ApprovalPolicy(IClock clock) - { - _clock = clock; - } - - public bool CanCreateProposal(string userEmail, string? userRole) - { - return userRole is "Maker" or "Admin"; - } - - public bool CanProposeApproval(ApprovalProposal proposal, string userEmail) - { - if (proposal.Status != ApprovalStatus.Draft) - return false; - - return proposal.CreatedBy == userEmail; - } - - public bool CanApproveApproval(ApprovalProposal proposal, string checkerEmail, string makerEmail) - { - if (proposal.Status != ApprovalStatus.Proposed) - return false; - - if (checkerEmail == makerEmail) - return false; // Separation of duties: Maker cannot approve own proposal - - return true; - } - - public bool CanActivateApproval(ApprovalProposal proposal, string userRole) - { - if (proposal.Status != ApprovalStatus.Approved) - return false; - - return userRole is "SRE" or "Admin"; - } - - public ApprovalProposal CreateProposal( - Guid modelId, - string createdBy, - string justification, - DateOnly effectiveAt) - { - return new ApprovalProposal - { - Id = Guid.NewGuid(), - ModelId = modelId, - Status = ApprovalStatus.Draft, - CreatedBy = createdBy, - CreatedAt = _clock.UtcNow, - Justification = justification, - EffectiveAt = effectiveAt, - PublishedAt = _clock.UtcNow, - Revision = 1, - CorrelationId = Guid.NewGuid() - }; - } - - public ApprovalProposal ProposeApproval(ApprovalProposal proposal, string makerEmail) - { - if (!CanProposeApproval(proposal, makerEmail)) - throw new InvalidOperationException("Only the creator can propose their own approval"); - - proposal.Status = ApprovalStatus.Proposed; - proposal.ProposedAt = _clock.UtcNow; - proposal.Revision++; - proposal.PublishedAt = _clock.UtcNow; - - return proposal; - } - - public ApprovalProposal ApproveApproval( - ApprovalProposal proposal, - string checkerEmail, - string approvalNotes, - List evidence) - { - if (!CanApproveApproval(proposal, checkerEmail, proposal.CreatedBy)) - throw new InvalidOperationException("Checker cannot approve their own proposals"); - - proposal.Status = ApprovalStatus.Approved; - proposal.ApprovedBy = checkerEmail; - proposal.ApprovedAt = _clock.UtcNow; - proposal.ApprovalNotes = approvalNotes; - proposal.Revision++; - proposal.PublishedAt = _clock.UtcNow; - - // Add evidence - foreach (var evt in evidence) - { - proposal.Evidence.Add(new ApprovalEvidence - { - Id = Guid.NewGuid(), - ApprovalProposalId = proposal.Id, - EvidenceType = evt.Type, - EvidenceUrl = evt.Url, - ReviewerComment = evt.Comment, - PublishedAt = _clock.UtcNow, - CorrelationId = proposal.CorrelationId - }); - } - - return proposal; - } - - public ApprovalProposal ActivateApproval(ApprovalProposal proposal, string sreEmail) - { - if (!CanActivateApproval(proposal, "SRE")) - throw new InvalidOperationException("Only SRE can activate approved proposals"); - - proposal.Status = ApprovalStatus.Active; - proposal.ActivatedBy = sreEmail; - proposal.ActivatedAt = _clock.UtcNow; - proposal.Revision++; - proposal.PublishedAt = _clock.UtcNow; - - return proposal; - } - - public ApprovalProposal RejectApproval(ApprovalProposal proposal, string checkerEmail, string rejectionReason) - { - if (proposal.Status != ApprovalStatus.Proposed) - throw new InvalidOperationException("Only proposed approvals can be rejected"); - - proposal.Status = ApprovalStatus.Rejected; - proposal.ApprovalNotes = $"Rejected: {rejectionReason}"; - proposal.Revision++; - proposal.PublishedAt = _clock.UtcNow; - - return proposal; - } - - public ApprovalEvent CreateProposalEvent( - ApprovalProposal proposal, - string eventType, - string actorEmail, - Dictionary? details = null) - { - return new ApprovalEvent - { - Id = Guid.NewGuid(), - ApprovalProposalId = proposal.Id, - EventType = eventType, - ActorEmail = actorEmail, - EventAt = _clock.UtcNow, - Details = details, - PublishedAt = _clock.UtcNow, - CorrelationId = proposal.CorrelationId - }; - } -} diff --git a/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalProposal.cs b/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalProposal.cs deleted file mode 100644 index e7fc49d9..00000000 --- a/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalProposal.cs +++ /dev/null @@ -1,109 +0,0 @@ -namespace KArtSell.Modules.ModelOperations.ApprovalWorkflow; - -using System; -using System.Collections.Generic; - -public class ApprovalProposal -{ - public Guid Id { get; set; } - public Guid ModelId { get; set; } - public ApprovalStatus Status { get; set; } - public string CreatedBy { get; set; } = null!; - public DateTimeOffset CreatedAt { get; set; } - public string Justification { get; set; } = null!; - public DateOnly EffectiveAt { get; set; } - - public DateTimeOffset? ProposedAt { get; set; } - public string? ApprovedBy { get; set; } - public DateTimeOffset? ApprovedAt { get; set; } - public string? ApprovalNotes { get; set; } - - public string? ActivatedBy { get; set; } - public DateTimeOffset? ActivatedAt { get; set; } - - public DateTimeOffset PublishedAt { get; set; } - public int Revision { get; set; } - public Guid CorrelationId { get; set; } - - public List Evidence { get; set; } = []; - public List Events { get; set; } = []; - - public bool CanBeProposed => Status == ApprovalStatus.Draft && CreatedBy is not null; - public bool CanBeApproved => Status == ApprovalStatus.Proposed; - public bool CanBeActivated => Status == ApprovalStatus.Approved; -} - -public enum ApprovalStatus -{ - Draft, - Proposed, - Approved, - Active, - Rejected -} - -public class ApprovalEvidence -{ - public Guid Id { get; set; } - public Guid ApprovalProposalId { get; set; } - public string EvidenceType { get; set; } = null!; - public string EvidenceUrl { get; set; } = null!; - public string? ReviewerComment { get; set; } - public DateTimeOffset PublishedAt { get; set; } - public Guid CorrelationId { get; set; } -} - -public class ApprovalEvent -{ - public Guid Id { get; set; } - public Guid ApprovalProposalId { get; set; } - public string EventType { get; set; } = null!; - public string ActorEmail { get; set; } = null!; - public DateTimeOffset EventAt { get; set; } - public Dictionary? Details { get; set; } - public DateTimeOffset PublishedAt { get; set; } - public Guid CorrelationId { get; set; } -} - -public class CreateApprovalProposalRequest -{ - public Guid ModelId { get; set; } - public DateOnly EffectiveAt { get; set; } - public string Justification { get; set; } = null!; -} - -public class ApproveApprovalRequest -{ - public string ApprovalNotes { get; set; } = null!; - public List Evidence { get; set; } = []; -} - -public class EvidenceItem -{ - public string Type { get; set; } = null!; - public string Url { get; set; } = null!; - public string? Comment { get; set; } -} - -public class ApprovalProposalResponse -{ - public Guid Id { get; set; } - public Guid ModelId { get; set; } - public string Status { get; set; } = null!; - public string CreatedBy { get; set; } = null!; - public DateTimeOffset CreatedAt { get; set; } - public string Justification { get; set; } = null!; - public DateOnly EffectiveAt { get; set; } - public string? ApprovedBy { get; set; } - public DateTimeOffset? ApprovedAt { get; set; } - public string? ApprovalNotes { get; set; } - public List Evidence { get; set; } = []; -} - -public class ApprovalEvidenceResponse -{ - public Guid Id { get; set; } - public string EvidenceType { get; set; } = null!; - public string EvidenceUrl { get; set; } = null!; - public string? ReviewerComment { get; set; } -} diff --git a/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalSql.cs b/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalSql.cs deleted file mode 100644 index 62ed678c..00000000 --- a/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/ApprovalSql.cs +++ /dev/null @@ -1,216 +0,0 @@ -namespace KArtSell.Modules.ModelOperations.ApprovalWorkflow; - -using System; -using System.Collections.Generic; -using System.Data; -using System.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using Dapper; -using KArtSell.BuildingBlocks.Time; -using Npgsql; - -public class ApprovalSql -{ - private readonly string _connectionString; - private readonly IClock _clock; - - public ApprovalSql(string connectionString, IClock clock) - { - _connectionString = connectionString; - _clock = clock; - } - - public async Task GetProposalByIdAsync(Guid id, DateTimeOffset cutoff) - { - using var conn = new NpgsqlConnection(_connectionString); - const string sql = """ - SELECT - id, model_id, status, created_by, created_at, justification, effective_at, - proposed_at, approved_by, approved_at, approval_notes, activated_by, activated_at, - published_at, revision, correlation_id - FROM model_operations.approval_proposals - WHERE id = @id - AND published_at <= @cutoff - ORDER BY published_at DESC - LIMIT 1 - """; - - var proposal = await conn.QueryFirstOrDefaultAsync(sql, new { id, cutoff }); - if (proposal == null) return null; - - return MapFromRaw(proposal); - } - - public async Task> GetProposalsByStatusAsync(string status, DateTimeOffset cutoff, int pageSize = 100) - { - using var conn = new NpgsqlConnection(_connectionString); - const string sql = """ - SELECT - id, model_id, status, created_by, created_at, justification, effective_at, - proposed_at, approved_by, approved_at, approval_notes, activated_by, activated_at, - published_at, revision, correlation_id - FROM model_operations.approval_proposals - WHERE status = @status - AND published_at <= @cutoff - ORDER BY created_at DESC - LIMIT @pageSize - """; - - var proposals = await conn.QueryAsync(sql, new { status, cutoff, pageSize }); - return proposals.Select(MapFromRaw).ToList(); - } - - public async Task InsertProposalAsync( - Guid id, Guid modelId, string status, string createdBy, string justification, - DateOnly effectiveAt, DateTimeOffset publishedAt, Guid correlationId) - { - using var conn = new NpgsqlConnection(_connectionString); - const string sql = """ - INSERT INTO model_operations.approval_proposals - (id, model_id, status, created_by, created_at, justification, effective_at, published_at, revision, correlation_id) - VALUES (@id, @modelId, @status, @createdBy, @createdAt, @justification, @effectiveAt::date, @publishedAt, 1, @correlationId) - """; - - await conn.ExecuteAsync(sql, new - { - id, - modelId, - status, - createdBy, - createdAt = _clock.UtcNow, - justification, - effectiveAt = effectiveAt.ToString("yyyy-MM-dd"), // Dapper: DateOnly cannot be used as a parameter value directly - publishedAt, - correlationId - }); - } - - public async Task UpdateProposalStatusAsync(Guid id, string newStatus, string approvedBy, string? approvalNotes, DateTimeOffset publishedAt) - { - using var conn = new NpgsqlConnection(_connectionString); - const string sql = """ - INSERT INTO model_operations.approval_proposals - (id, model_id, status, created_by, created_at, justification, effective_at, - approved_by, approved_at, approval_notes, published_at, revision, correlation_id) - SELECT id, model_id, @newStatus, created_by, created_at, justification, effective_at, - @approvedBy, @approvedAt, @approvalNotes, @publishedAt, revision + 1, correlation_id - FROM model_operations.approval_proposals - WHERE id = @id - ORDER BY published_at DESC LIMIT 1 - """; - - await conn.ExecuteAsync(sql, new - { - id, - newStatus, - approvedBy, - approvedAt = _clock.UtcNow, - approvalNotes, - publishedAt - }); - } - - public async Task InsertEvidenceAsync(Guid id, Guid proposalId, string evidenceType, string evidenceUrl, string? comment, Guid correlationId) - { - using var conn = new NpgsqlConnection(_connectionString); - const string sql = """ - INSERT INTO model_operations.approval_evidence - (id, approval_proposal_id, evidence_type, evidence_url, reviewer_comment, published_at, correlation_id) - VALUES (@id, @proposalId, @evidenceType, @evidenceUrl, @comment, @publishedAt, @correlationId) - """; - - await conn.ExecuteAsync(sql, new - { - id, - proposalId, - evidenceType, - evidenceUrl, - comment, - publishedAt = _clock.UtcNow, - correlationId - }); - } - - public async Task InsertEventAsync(Guid id, Guid proposalId, string eventType, string actorEmail, Dictionary? details, Guid correlationId) - { - using var conn = new NpgsqlConnection(_connectionString); - const string sql = """ - INSERT INTO model_operations.approval_events - (id, approval_proposal_id, event_type, actor_email, event_at, details, published_at, correlation_id) - VALUES (@id, @proposalId, @eventType, @actorEmail, @eventAt, @details::jsonb, @publishedAt, @correlationId) - """; - - var detailsJson = details != null ? JsonSerializer.Serialize(details) : null; - - await conn.ExecuteAsync(sql, new - { - id, - proposalId, - eventType, - actorEmail, - eventAt = _clock.UtcNow, - details = detailsJson, - publishedAt = _clock.UtcNow, - correlationId - }); - } - - public async Task> GetEvidenceByProposalAsync(Guid proposalId, DateTimeOffset cutoff) - { - using var conn = new NpgsqlConnection(_connectionString); - const string sql = """ - SELECT id, approval_proposal_id, evidence_type, evidence_url, reviewer_comment, published_at, correlation_id - FROM model_operations.approval_evidence - WHERE approval_proposal_id = @proposalId - AND published_at <= @cutoff - ORDER BY published_at DESC - """; - - var results = await conn.QueryAsync(sql, new { proposalId, cutoff }); - return results.ToList(); - } - - private ApprovalProposal MapFromRaw(ApprovalProposalRaw raw) - { - return new ApprovalProposal - { - Id = raw.Id, - ModelId = raw.ModelId, - Status = Enum.Parse(raw.Status), - CreatedBy = raw.CreatedBy, - CreatedAt = raw.CreatedAt, - Justification = raw.Justification, - EffectiveAt = raw.EffectiveAt, - ProposedAt = raw.ProposedAt, - ApprovedBy = raw.ApprovedBy, - ApprovedAt = raw.ApprovedAt, - ApprovalNotes = raw.ApprovalNotes, - ActivatedBy = raw.ActivatedBy, - ActivatedAt = raw.ActivatedAt, - PublishedAt = raw.PublishedAt, - Revision = raw.Revision, - CorrelationId = raw.CorrelationId - }; - } - - private sealed class ApprovalProposalRaw - { - public Guid Id { get; set; } - public Guid ModelId { get; set; } - public string Status { get; set; } = null!; - public string CreatedBy { get; set; } = null!; - public DateTimeOffset CreatedAt { get; set; } - public string Justification { get; set; } = null!; - public DateOnly EffectiveAt { get; set; } - public DateTimeOffset? ProposedAt { get; set; } - public string? ApprovedBy { get; set; } - public DateTimeOffset? ApprovedAt { get; set; } - public string? ApprovalNotes { get; set; } - public string? ActivatedBy { get; set; } - public DateTimeOffset? ActivatedAt { get; set; } - public DateTimeOffset PublishedAt { get; set; } - public int Revision { get; set; } - public Guid CorrelationId { get; set; } - } -} diff --git a/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/README.md b/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/README.md deleted file mode 100644 index 675fad2f..00000000 --- a/src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/README.md +++ /dev/null @@ -1,208 +0,0 @@ -# VS-26: Model Approval Workflow - -## Overview - -This vertical slice implements a maker-checker approval workflow for model activation. It enforces separation of duties, state machine transitions, and evidence linkage for regulatory compliance. - -**Status:** โœ… Ready for implementation -**Specification:** `docs/CURRENT/SLICE_SPECS/VS-26-SLICE_SPEC.md` - ---- - -## User Story - -As a platform lead/compliance officer, I want to enforce maker-checker approval workflow for model activation so that only reviewed, authorized models reach production (governance compliance). - ---- - -## Key Features - -### 1. Approval State Machine - -``` -DRAFT (Maker creates) - โ†“ -PROPOSED (Maker submits to Checker) - โ”œโ†’ APPROVED (Checker signs off with evidence) - โ”‚ โ†“ - โ”‚ ACTIVE (SRE activates) - โ”‚ - โ””โ†’ REJECTED (Checker rejects, revise to DRAFT) -``` - -### 2. Maker-Checker Separation of Duties - -- **Maker:** Can create and propose approval proposals (own proposals only) -- **Checker:** Can approve any proposal (must be different from Maker) -- **SRE:** Can activate approved proposals -- **System:** Logs all actions with actor identity and correlation_id - -### 3. Evidence Linkage - -- Store PBO/DSR/OOS artifact URLs during approval -- Checker annotates evidence interpretation -- Traceability: approval_id โ†’ evidence_links โ†’ S3 artifacts - -### 4. Immutable Audit Trail - -- All state transitions logged in `approval_events` table -- Correlation_id links related events -- PIT tracking via `published_at` + `revision` - ---- - -## Database Schema - -### approval_proposals -```sql -id, model_id, status, created_by, created_at, justification, effective_at, -proposed_at, approved_by, approved_at, approval_notes, activated_by, activated_at, -published_at, revision, correlation_id -``` - -### approval_evidence -```sql -id, approval_proposal_id, evidence_type, evidence_url, reviewer_comment, -published_at, correlation_id -``` - -### approval_events -```sql -id, approval_proposal_id, event_type, actor_email, event_at, details, -published_at, correlation_id -``` - ---- - -## API Endpoints - -### POST /approvals (Create Proposal) -**Role:** Maker -**Request:** -```json -{ - "modelId": "uuid", - "effectiveAt": "2026-09-15", - "justification": "Model passed OOS testing; PBO score 0.95" -} -``` -**Response (201):** -```json -{ - "id": "approval-uuid", - "modelId": "uuid", - "status": "Draft", - "createdBy": "maker@company.com", - "createdAt": "2026-08-07T10:00:00Z" -} -``` - -### GET /approvals (List Proposals) -**Query Params:** `status=Proposed&modelId=uuid` -**Response (200):** -```json -{ - "items": [ - { - "id": "approval-uuid", - "modelId": "uuid", - "status": "Proposed", - "createdBy": "maker@company.com", - "approvalNotes": null - } - ] -} -``` - -### GET /approvals/{id} (Get Single) -**Response (200):** -```json -{ - "id": "approval-uuid", - "modelId": "uuid", - "status": "Proposed", - "evidence": [ - { - "id": "evidence-uuid", - "evidenceType": "PBO_SCORE", - "evidenceUrl": "s3://evidence/pbo-0.95.json", - "reviewerComment": "Verified" - } - ] -} -``` - -### POST /approvals/{id}/approve (Checker Approval) -**Role:** Checker -**Request:** -```json -{ - "approvalNotes": "PBO verified, OOS metrics acceptable", - "evidence": [ - {"type": "PBO_SCORE", "url": "s3://evidence/pbo-0.95.json", "comment": "Verified"}, - {"type": "OOS_RETURN", "url": "s3://evidence/oos-returns.csv", "comment": "Acceptable"} - ] -} -``` -**Response (200):** -```json -{ - "id": "approval-uuid", - "status": "Approved", - "approvedBy": "checker@company.com", - "approvedAt": "2026-08-07T11:00:00Z" -} -``` - ---- - -## RBAC Enforcement - -| Role | Can Create | Can Approve | Can Activate | -|------|-----------|-----------|------------| -| Maker | โœ… (own) | โŒ | โŒ | -| Checker | โŒ | โœ… (others) | โŒ | -| SRE | โŒ | โŒ | โœ… | -| Admin | โœ… | โœ… | โœ… | - -**Separation of Duties:** Maker โ‰  Checker (same user cannot approve own proposal) - ---- - -## Compliance & Governance - -- โœ… **Separation of Duties:** Enforced at Endpoint level -- โœ… **Evidence Linkage:** All evidence URLs traceable to artifacts -- โœ… **Immutable Audit Trail:** INSERT-only events table -- โœ… **Correlation Tracking:** CorrelationId links related events across slices -- โœ… **PIT Queries:** All reads include `WHERE published_at <= cutoff` - ---- - -## Related Specifications - -- **VS-00:** PIT envelope (published_at, correlation_id, revision) -- **VS-02:** Financial security master (governance foundation) -- **VS-27:** Audit trail (logs all approval events) -- **VS-10:** Sell decision (uses approved models) - ---- - -## Next Steps - -1. โœ… Schema migration (0036_approval_workflow.sql) -2. โœ… Domain entities (ApprovalProposal, ApprovalEvidence, ApprovalEvent) -3. โœ… Dapper queries (Sql.cs) -4. โœ… Business logic (ApprovalPolicy with state machine) -5. โœ… HTTP handlers (ApprovalHandlers.cs) -6. โœ… FastEndpoints (ApprovalEndpoints.cs) -7. โœ… Unit/Integration tests -8. โณ Merge to main (awaiting PR review) -9. โณ Integration with VS-27 (audit trail subscribers) -10. โณ Phase 2 implementation (after Phase 1 data available) - ---- - -**Co-Authored-By:** Claude Haiku 4.5 -**AGENTS.md v16.0:** 13/13 โœ… -**Compliance:** Spec-before-code, no new tech debt diff --git a/src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/README.md b/src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/README.md index 7d8d1ad7..ef1a4aba 100644 --- a/src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/README.md +++ b/src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/README.md @@ -1,9 +1,27 @@ -# VS-03: Model Approval Workflow (Maker-Checker Governance) +# VS-26 (formerly VS-03): Model Approval Workflow (Maker-Checker Governance) ## Overview This slice implements a maker-checker approval workflow for model activation with separation of duties and immutable audit trail. +**This is now the sole implementation of this slice.** A second, functionally-overlapping copy +(`src/KArtSell.Modules.ModelOperations/ApprovalWorkflow/`, no `Features/` prefix) existed +alongside this one from 2026-08-07 to 2026-08-08; it was dead code (all 4 endpoints +`[DontRegister]`'d to avoid a duplicate-route crash at Host startup) despite having 20/20 +passing tests, while *this* implementation โ€” the one actually wired into `Program.cs` and +reachable over HTTP โ€” had no dedicated tests. See `TECH_DEBT_REGISTER.md` DEBT-017 and +`docs/DECISIONS/ADR-WBS-001-slice-renumbering.md` for the full history. The old implementation +and its test file were deleted on 2026-08-08 once this one gained equivalent integration-test +coverage (`tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs`). + +**Bug fixed 2026-08-08 (as part of DEBT-017):** `InsertProposalAsync` in `Sql.cs` passed +`EffectiveAt` (a `DateOnly`) directly as a Dapper parameter. The now-deleted implementation hit +the identical failure against a real database (commit `2ccf74c`) โ€” Npgsql/Dapper in this +environment cannot bind a bare `DateOnly` value; it needs an explicit `::date` cast plus a +`"yyyy-MM-dd"` string parameter. That fix has been ported here. **This has not been re-verified +against a live PostgreSQL instance in this session** (none was reachable) โ€” see "Test status" +below. + ## Architecture ### State Machine @@ -52,6 +70,16 @@ PROPOSED (maker submits) - POST /approvals (create proposal) - GET /approvals (list proposals) - POST /approvals/{id}/approve (approve proposal) + - โš ๏ธ **No `GET /approvals/{id}`.** The deleted duplicate implementation had a single-proposal + fetch endpoint that included the evidence list in its response; this implementation has no + equivalent, so evidence attached during approval is currently unreachable via HTTP (it can + only be read back through `ApprovalWorkflowSql` directly, e.g. in tests). Not fixed here โ€” + out of scope for DEBT-017 (duplicate-implementation cleanup); tracked as **DEBT-025**. + - โš ๏ธ **No wired "submit for review" transition.** `ApprovalWorkflowPolicy.CanProposeForReview` + exists but no `Handler` or `Endpoint` calls it, so nothing in the running application ever + moves a proposal from `Draft` to `Proposed`. `ActivateModelHandler` and `ApproveApprovalHandler` + both require `Proposed`/`Approved` respectively, so as shipped a created proposal cannot + reach `Approved` through the HTTP API alone. Also not fixed here โ€” tracked as **DEBT-026**. ## API Contracts @@ -178,15 +206,25 @@ CREATE TABLE model_operations.approval_events ( ## Tests -Unit tests cover: -- RBAC enforcement (Maker, Checker, SRE roles) -- Separation of duties (Checker โ‰  Maker) -- State machine transitions -- RBAC violations +- `tests/KArtSell.Integration.Tests/ApprovalWorkflowPolicyTests.cs` โ€” pure `ApprovalWorkflowPolicy` + unit tests (no DB): RBAC enforcement (Maker/Checker/SRE), separation of duties, valid/invalid + state transitions. Fast, deterministic. +- `tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs` โ€” Handler + Sql + + real PostgreSQL integration tests: create (role-gated), approve (makerโ‰ checker, evidence + attachment), activate (SRE-gated), `EffectiveAt` `DateOnly` round-trip through a real `date` + column, list filtering. + +**Test status as of 2026-08-08 (DEBT-017 resolution session): written but not run against a live +database.** No PostgreSQL was reachable at `127.0.0.1:5432` in that session (no SSH tunnel to +178.104.200.7 open). `dotnet build -c Release` was confirmed green; `dotnet test --filter +"FullyQualifiedName~ApprovalWorkflow"` was run and its actual outcome (pass, fail, or DB +connection error) is recorded in `TECH_DEBT_REGISTER.md` DEBT-017 and +`docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv` row `AEG-VS-26-01` โ€” check those before treating +this slice as verified. Run tests: ```bash -dotnet test --filter "ApprovalWorkflowPolicyTests" +dotnet test --filter "FullyQualifiedName~ApprovalWorkflow" -c Release ``` ## AGENTS.md v16.0 Compliance @@ -194,25 +232,37 @@ dotnet test --filter "ApprovalWorkflowPolicyTests" - โœ… **SOLID:** Separate Endpoint/Handler/Policy/Sql per operation - โœ… **Complexity:** Each handler โ‰ค200 lines - โœ… **Audit:** All state changes logged with correlation_id -- โœ… **Necessity:** Grounded in VS-03 SLICE_SPEC -- โœ… **Normalization:** 3NF schema, append-only events +- โœ… **Necessity:** Grounded in VS-26 (formerly VS-03) SLICE_SPEC +- โš ๏ธ **Normalization:** Writes mutate `approval_proposals` in place (`UPDATE ... revision = + revision + 1`) rather than appending a new revision row, because `id` is the sole `PRIMARY KEY` + in migration `0036_approval_workflow.sql` (no `(id, published_at)` composite key) โ€” an + append-only INSERT would violate that constraint on the second write. This is a real deviation + from CLAUDE.md's "new state appended as new revision" rule; it is pre-existing (present before + this session) and schema-level, so fixing it is out of scope for DEBT-017. `GetProposalAsync` + correspondingly has no `published_at <= cutoff` PIT filter, since there is only ever one row. - โœ… **Simplicity:** State machine clearly visible - โœ… **Pattern:** Vertical Slice standard - โœ… **Guardrails:** RBAC enforced, no privilege escalation - โœ… **Traceability:** Correlation_id + evidence linking -- โœ… **Safety:** Idempotent, rollback-safe +- โš ๏ธ **Safety:** DateOnly parameter binding bug fixed 2026-08-08; unverified against a live DB + this session (see "Test status" above) - โœ… **Maturity:** Spec complete before code -- โœ… **Right-Way:** No shortcuts, formal approval workflow -- โœ… **Debt:** No new tech debt +- โœ… **Right-Way:** Duplicate implementation resolved per DEBT-017, not worked around +- โš ๏ธ **Debt:** DEBT-017 (duplicate implementation) resolved; two residual gaps discovered by this + cleanup (predate it, not introduced by it) are registered as DEBT-025 (no `GET /approvals/{id}`) + and DEBT-026 (no wired Draftโ†’Proposed transition) ## Related Specifications - **VS-00:** PIT envelope (published_at, correlation_id, revision) - **VS-02:** Governance foundation (data sources, policies) -- **VS-04:** Audit trail (events logged by this slice) +- **VS-27 (formerly VS-04):** Audit trail (events logged by this slice) - **Compliance:** Maker-checker separation, evidence linkage --- -**Status:** โœ… IMPLEMENTATION COMPLETE -**Co-Authored-By:** Claude Haiku 4.5 +**Status:** Backend implementation is the canonical (sole) copy of this slice as of 2026-08-08; +integration tests exist but are unverified against a live database (see "Test status"). Not +"IMPLEMENTATION COMPLETE" until that verification runs and the two residual gaps above are +resolved or explicitly accepted. +**Co-Authored-By:** Claude Sonnet 5 diff --git a/src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/Sql.cs b/src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/Sql.cs index 774dbd8d..3b8f8b12 100644 --- a/src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/Sql.cs +++ b/src/KArtSell.Modules.ModelOperations/Features/ApprovalWorkflow/Sql.cs @@ -55,7 +55,7 @@ public class ApprovalWorkflowSql INSERT INTO model_operations.approval_proposals (id, model_id, status, created_by, created_at, justification, effective_at, published_at, revision, correlation_id) - VALUES (@id, @modelId, @status, @createdBy, @createdAt, @justification, @effectiveAt, + VALUES (@id, @modelId, @status, @createdBy, @createdAt, @justification, @effectiveAt::date, @publishedAt, @revision, @correlationId) RETURNING id """; @@ -69,7 +69,11 @@ public class ApprovalWorkflowSql proposal.CreatedBy, proposal.CreatedAt, proposal.Justification, - proposal.EffectiveAt, + // Dapper cannot bind DateOnly directly as an Npgsql parameter value (see TECH_DEBT_REGISTER.md + // DEBT-017); the identical failure was found and fixed the same way in the now-deleted + // ApprovalSql.cs (commit 2ccf74c) after it crashed 100% of proposal-creation calls against a + // real database. + effectiveAt = proposal.EffectiveAt.ToString("yyyy-MM-dd"), proposal.PublishedAt, proposal.Revision, proposal.CorrelationId diff --git a/tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs b/tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs index ee9440fb..89a62f20 100644 --- a/tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs +++ b/tests/KArtSell.Integration.Tests/ApprovalWorkflow/ApprovalWorkflowTests.cs @@ -2,209 +2,231 @@ namespace KArtSell.Integration.Tests.ApprovalWorkflow; using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using Dapper; -using Xunit; using KArtSell.BuildingBlocks.Time; -using KArtSell.Modules.ModelOperations.ApprovalWorkflow; +using KArtSell.Modules.ModelOperations.Domain.ApprovalWorkflow; +using KArtSell.Modules.ModelOperations.Features.ApprovalWorkflow; +using Npgsql; +using Xunit; +/// +/// Integration coverage for the CANONICAL maker-checker approval slice +/// (KArtSell.Modules.ModelOperations.Features.ApprovalWorkflow โ€” the one wired into +/// Program.cs and reachable over HTTP). Exercises Handler + Sql + a real PostgreSQL +/// database, per CLAUDE.md's Vertical Slice testing rules (no mocks for this layer). +/// +/// Replaces the old KArtSell.Modules.ModelOperations.ApprovalWorkflow namespace's test +/// coverage, deleted as part of resolving TECH_DEBT_REGISTER.md DEBT-017: that implementation +/// was dead code (all 4 endpoints [DontRegister]'d) even though it had 20/20 passing +/// tests, while this one โ€” the implementation actually reachable at runtime โ€” had none. +/// +/// Requires KARTSELL_POSTGRES (or appsettings.Development.json ConnectionStrings:Postgres) to +/// point at a reachable PostgreSQL instance with migrations 0035/0036 applied. See +/// TestDatabaseConnection.GetConnectionString(). +/// +[Collection("Database")] public class ApprovalWorkflowTests : IAsyncLifetime { private readonly string _connectionString; - private readonly ApprovalSql _sql; - private readonly ApprovalPolicy _policy; - private readonly IOutbox _outbox; + private readonly ApprovalWorkflowSql _sql; + private readonly IClock _clock = new SystemClock(); public ApprovalWorkflowTests() { - _connectionString = "Host=localhost;Port=5432;Database=kartselldb;Username=kartsell;Password=kartsell4321@!"; - _sql = new ApprovalSql(_connectionString, new SystemClock()); - _policy = new ApprovalPolicy(new SystemClock()); - _outbox = new InMemoryOutbox(); + _connectionString = TestDatabaseConnection.GetConnectionString(); + _sql = new ApprovalWorkflowSql(_connectionString); } public async Task InitializeAsync() { - // Ensure database is ready - await Task.CompletedTask; + await using var conn = new NpgsqlConnection(_connectionString); + await conn.OpenAsync(); + await conn.ExecuteAsync("SELECT 1"); } - public async Task DisposeAsync() + public Task DisposeAsync() => Task.CompletedTask; + + private async Task SeedModelAsync() { - await Task.CompletedTask; - } - - [Fact] - public void CanCreateProposal_WithMakerRole_ReturnsTrue() - { - // Arrange - var makerEmail = "maker@company.com"; - var makerRole = "Maker"; - - // Act - var result = _policy.CanCreateProposal(makerEmail, makerRole); - - // Assert - Assert.True(result); - } - - [Fact] - public void CanCreateProposal_WithoutMakerRole_ReturnsFalse() - { - // Arrange - var email = "user@company.com"; - var role = "Viewer"; - - // Act - var result = _policy.CanCreateProposal(email, role); - - // Assert - Assert.False(result); - } - - [Fact] - public void CanApproveApproval_WithDifferentChecker_ReturnsTrue() - { - // Arrange - var maker = "maker@company.com"; - var checker = "checker@company.com"; - var proposal = new ApprovalProposal { CreatedBy = maker, Status = ApprovalStatus.Proposed }; - - // Act - var result = _policy.CanApproveApproval(proposal, checker, maker); - - // Assert - Assert.True(result); - } - - [Fact] - public void CanApproveApproval_WithSameMaker_ReturnsFalse() - { - // Arrange - var maker = "maker@company.com"; - var proposal = new ApprovalProposal { CreatedBy = maker, Status = ApprovalStatus.Proposed }; - - // Act - var result = _policy.CanApproveApproval(proposal, maker, maker); - - // Assert - Assert.False(result); - } - - [Fact] - public void CreateProposal_SetsCorrectDefaults() - { - // Arrange var modelId = Guid.NewGuid(); - var maker = "maker@company.com"; - var justification = "Model passed OOS testing"; - var effectiveAt = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7)); - - // Act - var proposal = _policy.CreateProposal(modelId, maker, justification, effectiveAt); - - // Assert - Assert.Equal(modelId, proposal.ModelId); - Assert.Equal(maker, proposal.CreatedBy); - Assert.Equal(ApprovalStatus.Draft, proposal.Status); - Assert.Equal(justification, proposal.Justification); - Assert.Equal(effectiveAt, proposal.EffectiveAt); - } - - [Fact] - public void ProposeApproval_TransitionsToProposed() - { - // Arrange - var proposal = new ApprovalProposal - { - Id = Guid.NewGuid(), - CreatedBy = "maker@company.com", - Status = ApprovalStatus.Draft, - Justification = "Test", - EffectiveAt = DateOnly.FromDateTime(DateTime.UtcNow) - }; - - // Act - var updated = _policy.ProposeApproval(proposal, "maker@company.com"); - - // Assert - Assert.Equal(ApprovalStatus.Proposed, updated.Status); - Assert.NotNull(updated.ProposedAt); - } - - [Fact] - public void ApproveApproval_AddsEvidence() - { - // Arrange - var proposal = new ApprovalProposal - { - Id = Guid.NewGuid(), - CreatedBy = "maker@company.com", - Status = ApprovalStatus.Proposed, - Evidence = [], - CorrelationId = Guid.NewGuid() - }; - var evidence = new List - { - new() { Type = "PBO_SCORE", Url = "s3://pbo-0.95.json", Comment = "Verified" } - }; - - // Act - var updated = _policy.ApproveApproval(proposal, "checker@company.com", "Looks good", evidence); - - // Assert - Assert.Equal(ApprovalStatus.Approved, updated.Status); - Assert.Equal("checker@company.com", updated.ApprovedBy); - Assert.Single(updated.Evidence); - Assert.Equal("PBO_SCORE", updated.Evidence[0].EvidenceType); - } - - [Fact] - public async Task InsertAndRetrieveProposal_RoundTrips() - { - // Arrange - var id = Guid.NewGuid(); - var modelId = Guid.NewGuid(); - var correlationId = Guid.NewGuid(); - await SeedModelAsync(modelId); - - // Act - await _sql.InsertProposalAsync( - id, - modelId, - "Draft", - "maker@company.com", - "Test justification", - DateOnly.FromDateTime(DateTime.UtcNow), - DateTimeOffset.UtcNow, - correlationId); - - var retrieved = await _sql.GetProposalByIdAsync(id, DateTimeOffset.UtcNow.AddDays(1)); - - // Assert - Assert.NotNull(retrieved); - Assert.Equal(id, retrieved.Id); - Assert.Equal(modelId, retrieved.ModelId); - Assert.Equal(correlationId, retrieved.CorrelationId); - } - - private async Task SeedModelAsync(Guid modelId) - { - await using var conn = new Npgsql.NpgsqlConnection(_connectionString); + await using var conn = new NpgsqlConnection(_connectionString); await conn.OpenAsync(); await conn.ExecuteAsync( "INSERT INTO model_operations.models (id, ticker, correlation_id) VALUES (@Id, @Ticker, @CorrelationId)", new { Id = modelId, Ticker = "TEST", CorrelationId = Guid.NewGuid() }); + return modelId; } -} -public class InMemoryOutbox : IOutbox -{ - public List<(string EventType, Guid CorrelationId, object Data)> Events { get; } = []; - - public Task PublishAsync(string eventType, Guid correlationId, object data) + [Fact] + public async Task CreateProposal_WithMakerRole_InsertsDraftProposal_AndEffectiveAtRoundTrips() { - Events.Add((eventType, correlationId, data)); - return Task.CompletedTask; + // Arrange: DateOnly binding is the specific regression this test guards against โ€” the + // previously-deleted ApprovalSql.cs could not INSERT a DateOnly parameter through Dapper + // at all (fixed in commit 2ccf74c). Use a date with no "today" coincidence risk. + var modelId = await SeedModelAsync(); + var handler = new CreateApprovalProposalHandler(_sql, _clock); + var effectiveAt = new DateOnly(2028, 2, 29); // leap day: exercises DateOnly edge case too + + // Act + var proposalId = await handler.Handle( + "maker@company.com", "Maker", modelId, effectiveAt, "Model passed OOS testing", Guid.NewGuid()); + + // Assert + var proposal = await _sql.GetProposalAsync(proposalId); + Assert.NotNull(proposal); + Assert.Equal(ApprovalStatus.Draft, proposal!.Status); + Assert.Equal(modelId, proposal.ModelId); + Assert.Equal("maker@company.com", proposal.CreatedBy); + Assert.Equal(effectiveAt, proposal.EffectiveAt); + } + + [Fact] + public async Task CreateProposal_WithNonMakerRole_ThrowsUnauthorized() + { + // Arrange + var modelId = await SeedModelAsync(); + var handler = new CreateApprovalProposalHandler(_sql, _clock); + + // Act & Assert + await Assert.ThrowsAsync(() => + handler.Handle("viewer@company.com", "Viewer", modelId, DateOnly.FromDateTime(DateTime.UtcNow), "no role", Guid.NewGuid())); + } + + [Fact] + public async Task Approve_WithDifferentChecker_TransitionsToApproved_AndAttachesEvidence() + { + // Arrange + var modelId = await SeedModelAsync(); + var createHandler = new CreateApprovalProposalHandler(_sql, _clock); + var proposalId = await createHandler.Handle( + "maker@company.com", "Maker", modelId, DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7)), + "Model passed OOS testing; PBO score 0.95", Guid.NewGuid()); + await _sql.UpdateProposalStatusAsync(proposalId, ApprovalStatus.Proposed); + + var approveHandler = new ApproveApprovalHandler(_sql, _clock); + var evidence = new List<(string Type, string Url, string? Comment)> + { + ("PBO_SCORE", "s3://evidence/pbo-0.95.json", "Verified"), + ("OOS_RETURN", "s3://evidence/oos-returns.csv", "Acceptable"), + }; + + // Act + await approveHandler.Handle(proposalId, "checker@company.com", "Checker", "PBO verified, OOS metrics acceptable", evidence, Guid.NewGuid()); + + // Assert + var proposal = await _sql.GetProposalAsync(proposalId); + Assert.NotNull(proposal); + Assert.Equal(ApprovalStatus.Approved, proposal!.Status); + Assert.Equal("checker@company.com", proposal.ApprovedBy); + Assert.NotNull(proposal.ApprovedAt); + + await using var conn = new NpgsqlConnection(_connectionString); + await conn.OpenAsync(); + var evidenceCount = await conn.QuerySingleAsync( + "SELECT COUNT(*) FROM model_operations.approval_evidence WHERE approval_proposal_id = @ProposalId", + new { ProposalId = proposalId }); + Assert.Equal(2, evidenceCount); + } + + [Fact] + public async Task Approve_BySameMakerAsChecker_ThrowsUnauthorized_SeparationOfDuties() + { + // Arrange + var modelId = await SeedModelAsync(); + var createHandler = new CreateApprovalProposalHandler(_sql, _clock); + var proposalId = await createHandler.Handle( + "maker@company.com", "Maker", modelId, DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7)), + "Self-approval attempt", Guid.NewGuid()); + await _sql.UpdateProposalStatusAsync(proposalId, ApprovalStatus.Proposed); + + var approveHandler = new ApproveApprovalHandler(_sql, _clock); + + // Act & Assert: same user, even with Checker role, cannot approve their own proposal + await Assert.ThrowsAsync(() => + approveHandler.Handle(proposalId, "maker@company.com", "Checker", "self-approved", [], Guid.NewGuid())); + } + + [Fact] + public async Task Approve_WithNonCheckerRole_ThrowsUnauthorized() + { + // Arrange + var modelId = await SeedModelAsync(); + var createHandler = new CreateApprovalProposalHandler(_sql, _clock); + var proposalId = await createHandler.Handle( + "maker@company.com", "Maker", modelId, DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7)), + "Needs a Checker role, not just a different user", Guid.NewGuid()); + await _sql.UpdateProposalStatusAsync(proposalId, ApprovalStatus.Proposed); + + var approveHandler = new ApproveApprovalHandler(_sql, _clock); + + // Act & Assert: different user, but wrong role + await Assert.ThrowsAsync(() => + approveHandler.Handle(proposalId, "other-maker@company.com", "Maker", "wrong role", [], Guid.NewGuid())); + } + + [Fact] + public async Task Activate_BySreAfterApproval_TransitionsToActive() + { + // Arrange + var modelId = await SeedModelAsync(); + var createHandler = new CreateApprovalProposalHandler(_sql, _clock); + var proposalId = await createHandler.Handle( + "maker@company.com", "Maker", modelId, DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7)), + "Ready for activation", Guid.NewGuid()); + await _sql.UpdateProposalStatusAsync(proposalId, ApprovalStatus.Proposed); + await _sql.UpdateProposalStatusAsync(proposalId, ApprovalStatus.Approved, "checker@company.com", "Approved"); + + var activateHandler = new ActivateModelHandler(_sql, _clock); + + // Act + await activateHandler.Handle(proposalId, "sre@company.com", "SRE", Guid.NewGuid()); + + // Assert + var proposal = await _sql.GetProposalAsync(proposalId); + Assert.NotNull(proposal); + Assert.Equal(ApprovalStatus.Active, proposal!.Status); + } + + [Fact] + public async Task Activate_WithNonSreRole_ThrowsUnauthorized() + { + // Arrange + var modelId = await SeedModelAsync(); + var createHandler = new CreateApprovalProposalHandler(_sql, _clock); + var proposalId = await createHandler.Handle( + "maker@company.com", "Maker", modelId, DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7)), + "Should not activate without SRE", Guid.NewGuid()); + await _sql.UpdateProposalStatusAsync(proposalId, ApprovalStatus.Proposed); + await _sql.UpdateProposalStatusAsync(proposalId, ApprovalStatus.Approved, "checker@company.com", "Approved"); + + var activateHandler = new ActivateModelHandler(_sql, _clock); + + // Act & Assert + await Assert.ThrowsAsync(() => + activateHandler.Handle(proposalId, "maker@company.com", "Maker", Guid.NewGuid())); + } + + [Fact] + public async Task ListProposalsAsync_FiltersByStatusAndModelId() + { + // Arrange + var modelId = await SeedModelAsync(); + var createHandler = new CreateApprovalProposalHandler(_sql, _clock); + var draftId = await createHandler.Handle( + "maker@company.com", "Maker", modelId, DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7)), + "Stays in Draft", Guid.NewGuid()); + var proposedId = await createHandler.Handle( + "maker@company.com", "Maker", modelId, DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7)), + "Moves to Proposed", Guid.NewGuid()); + await _sql.UpdateProposalStatusAsync(proposedId, ApprovalStatus.Proposed); + + // Act + var proposedOnly = await _sql.ListProposalsAsync(ApprovalStatus.Proposed, modelId); + + // Assert + Assert.Contains(proposedOnly, p => p.Id == proposedId); + Assert.DoesNotContain(proposedOnly, p => p.Id == draftId); } } diff --git a/tests/KArtSell.Integration.Tests/ApprovalWorkflowPolicyTests.cs b/tests/KArtSell.Integration.Tests/ApprovalWorkflowPolicyTests.cs index 21675747..8321a8bc 100644 --- a/tests/KArtSell.Integration.Tests/ApprovalWorkflowPolicyTests.cs +++ b/tests/KArtSell.Integration.Tests/ApprovalWorkflowPolicyTests.cs @@ -13,6 +13,13 @@ public class ApprovalWorkflowPolicyTests Assert.True(result); } + [Fact] + public void CanCreateProposal_NonMakerRole_ReturnsFalse() + { + var result = ApprovalWorkflowPolicy.CanCreateProposal("user@test.com", "Viewer"); + Assert.False(result); + } + [Fact] public void CanApprove_CheckerDifferentFromMaker_ReturnsTrue() { @@ -29,6 +36,38 @@ public class ApprovalWorkflowPolicyTests Assert.False(result); } + [Fact] + public void CanApprove_NonCheckerRole_ReturnsFalse() + { + var proposal = new ApprovalProposal { CreatedBy = "maker@test.com", Justification = "test", Status = ApprovalStatus.Proposed }; + var result = ApprovalWorkflowPolicy.CanApprove(proposal, "other@test.com", "Maker"); + Assert.False(result); + } + + [Fact] + public void CanActivate_SreRoleWithApprovedStatus_ReturnsTrue() + { + var proposal = new ApprovalProposal { CreatedBy = "maker@test.com", Justification = "test", Status = ApprovalStatus.Approved }; + var result = ApprovalWorkflowPolicy.CanActivate(proposal, "sre@test.com", "SRE"); + Assert.True(result); + } + + [Fact] + public void CanActivate_NonSreRole_ReturnsFalse() + { + var proposal = new ApprovalProposal { CreatedBy = "maker@test.com", Justification = "test", Status = ApprovalStatus.Approved }; + var result = ApprovalWorkflowPolicy.CanActivate(proposal, "maker@test.com", "Maker"); + Assert.False(result); + } + + [Fact] + public void CanActivate_ApprovedRequired_NonApprovedStatus_ReturnsFalse() + { + var proposal = new ApprovalProposal { CreatedBy = "maker@test.com", Justification = "test", Status = ApprovalStatus.Proposed }; + var result = ApprovalWorkflowPolicy.CanActivate(proposal, "sre@test.com", "SRE"); + Assert.False(result); + } + [Fact] public void ValidateProposalState_ValidTransition_Succeeds() {