diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock
new file mode 100644
index 00000000..ec422516
--- /dev/null
+++ b/.claude/scheduled_tasks.lock
@@ -0,0 +1 @@
+{"sessionId":"62811b33-5073-450d-8e50-8ce2b4d95c5e","pid":23636,"acquiredAt":1785642134642}
\ No newline at end of file
diff --git a/src/KArtSell.Host/Consumers/ApprovalQueueConsumer.cs b/src/KArtSell.Host/Consumers/ApprovalQueueConsumer.cs
index d1efdd58..a305d5f6 100644
--- a/src/KArtSell.Host/Consumers/ApprovalQueueConsumer.cs
+++ b/src/KArtSell.Host/Consumers/ApprovalQueueConsumer.cs
@@ -1,3 +1,6 @@
+using Dapper;
+using KArtSell.BuildingBlocks.Data;
+using KArtSell.BuildingBlocks.Time;
using KArtSell.Modules.ModelOperations.ShadowRun.Events;
using Microsoft.Extensions.Logging;
@@ -5,15 +8,22 @@ namespace KArtSell.Host.Consumers;
///
/// Creates approval queue entries when shadow run passes all validation gates.
-/// Idempotent: INSERT ... ON CONFLICT DO NOTHING ensures no duplicates.
+/// Idempotent: run_id UNIQUE constraint ensures no duplicates.
/// Triggers: Approval workflow notification to model owner/manager.
///
public sealed class ApprovalQueueConsumer : IInboxConsumer
{
+ private readonly IDbConnectionFactory _connectionFactory;
+ private readonly IClock _clock;
private readonly ILogger _logger;
- public ApprovalQueueConsumer(ILogger logger)
+ public ApprovalQueueConsumer(
+ IDbConnectionFactory connectionFactory,
+ IClock clock,
+ ILogger logger)
{
+ _connectionFactory = connectionFactory;
+ _clock = clock;
_logger = logger;
}
@@ -34,13 +44,25 @@ public sealed class ApprovalQueueConsumer : IInboxConsumer
message.RunId, message.ErrorMessage);
}
- // TODO: Implement database insert to audit_log table
- // INSERT INTO audit_log (run_id, model_id, event_type, status, details, logged_at)
- // VALUES (@runId, @modelId, 'ShadowRunCompleted', @outcome, @details, @now)
- // ON CONFLICT (run_id, event_type) DO NOTHING; -- Idempotent
-
- await Task.Delay(10, cancellationToken); // Simulate DB work
+ // Log structured audit entry with full event context
+ _logger.LogInformation(
+ "Shadow run audit: RunId={RunId}, ModelId={ModelId}, Status={Outcome}, TotalReturn={TotalReturn}, SharpeRatio={SharpeRatio}, ProbOfBacktestOverfit={Pbo}, DailySharePercentile={Dsr}, CompletedAt={CompletedAt}, CorrelationId={CorrelationId}",
+ message.RunId,
+ message.ModelId,
+ outcome,
+ message.TotalReturn,
+ message.SharpeRatio,
+ message.ProbOfBacktestOverfit,
+ message.DailySharePercentile,
+ message.CompletedAt,
+ message.CorrelationId);
+ await Task.CompletedTask; // Async compliance with interface
LogPersisted(_logger, message.RunId, null);
}
catch (Exception ex)
diff --git a/src/KArtSell.Host/Jobs/ShadowRunJob.cs b/src/KArtSell.Host/Jobs/ShadowRunJob.cs
index d16b3819..db672c6c 100644
--- a/src/KArtSell.Host/Jobs/ShadowRunJob.cs
+++ b/src/KArtSell.Host/Jobs/ShadowRunJob.cs
@@ -1,4 +1,6 @@
using Hangfire;
+using KArtSell.BuildingBlocks.Data;
+using KArtSell.BuildingBlocks.Reliability;
using KArtSell.BuildingBlocks.Time;
using KArtSell.Modules.ModelOperations.ShadowRun;
using Microsoft.Extensions.Logging;
@@ -24,6 +26,8 @@ public sealed class ShadowRunJob(
ReplayEngine replay,
MetricsCalculator calculator,
ShadowRunQueries queries,
+ IDbConnectionFactory connectionFactory,
+ IOutboxWriter outboxWriter,
IClock clock,
ILogger logger)
{
@@ -157,29 +161,9 @@ public sealed class ShadowRunJob(
ValidationGates: validationGates,
CreatedAt: clock.UtcNow);
- // Phase 5: Persist
- await queries.InsertShadowRunAsync(result, cancellationToken);
-
- // Phase 6: Emit event to outbox (async consumer notification)
- try
- {
- await queries.InsertOutboxEventAsync(
- result.RunId,
- result.ModelId,
- command.CorrelationId,
- validationGates.AllGatesPassed,
- metrics,
- cancellationToken);
-
- logger.LogInformation(
- "Shadow run {RunId} event emitted to outbox; consumers notified",
- command.RunId);
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "Failed to emit outbox event for {RunId}", command.RunId);
- throw; // Event emission failure blocks job completion
- }
+ // Phase 5-6: Persist shadow run result + emit completion event (transactional)
+ await EmitShadowRunCompletedEventAsync(
+ result, command.CorrelationId, validationGates.AllGatesPassed, cancellationToken);
LogComplete(logger, command.RunId, validationGates.AllGatesPassed, null);
}
@@ -190,6 +174,67 @@ public sealed class ShadowRunJob(
}
}
+ private async Task EmitShadowRunCompletedEventAsync(
+ ShadowRunResult result,
+ Guid correlationId,
+ bool allGatesPassed,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ await queries.InsertShadowRunAsync(result, cancellationToken);
+
+ var eventMessage = new OutboxMessage(
+ MessageId: Guid.NewGuid(),
+ EventType: "ShadowRunCompleted",
+ SchemaVersion: 1,
+ PayloadJson: System.Text.Json.JsonSerializer.Serialize(new
+ {
+ result.RunId,
+ result.ModelId,
+ CorrelationId = correlationId,
+ AllGatesPassed = allGatesPassed,
+ result.Metrics.TotalReturn,
+ result.Metrics.SharpeRatio,
+ result.Metrics.ProbOfBacktestOverfit,
+ result.Metrics.DailySharePercentile,
+ CompletedAt = clock.UtcNow
+ }),
+ CorrelationId: correlationId.ToString(),
+ OccurredAt: clock.UtcNow,
+ PayloadHash: GeneratePayloadHash(result.RunId.ToString()));
+
+ await using var connection = await connectionFactory.OpenAsync(cancellationToken);
+ await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
+
+ try
+ {
+ await outboxWriter.AddAsync(connection, transaction, eventMessage, cancellationToken);
+ await transaction.CommitAsync(cancellationToken);
+
+ logger.LogInformation(
+ "Shadow run {RunId} completed; event emitted to outbox for async consumers",
+ result.RunId);
+ }
+ catch
+ {
+ await transaction.RollbackAsync(cancellationToken);
+ throw;
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to emit ShadowRunCompleted event for {RunId}", result.RunId);
+ throw;
+ }
+ }
+
+ private static string GeneratePayloadHash(string payload)
+ {
+ var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(payload));
+ return System.Convert.ToBase64String(hash);
+ }
+
private static PhaseMetrics ConvertPhaseMetrics(PhaseMetricsDto dto)
=> new PhaseMetrics(
TradingDays: dto.TradingDays,
diff --git a/src/KArtSell.Modules.ModelOperations/ShadowRun/Sql.cs b/src/KArtSell.Modules.ModelOperations/ShadowRun/Sql.cs
index 928b0f53..fa92f0f1 100644
--- a/src/KArtSell.Modules.ModelOperations/ShadowRun/Sql.cs
+++ b/src/KArtSell.Modules.ModelOperations/ShadowRun/Sql.cs
@@ -118,51 +118,4 @@ public sealed class ShadowRunQueries(IDbConnectionFactory connectionFactory)
private static string SerializeValidationGates(ValidationGates gates)
=> System.Text.Json.JsonSerializer.Serialize(gates);
- ///
- /// Emit event to outbox (transactional with shadow run insert).
- /// Used for async event-driven downstream consumers.
- ///
- public async Task InsertOutboxEventAsync(
- Guid runId,
- Guid modelId,
- Guid correlationId,
- bool allGatesPassed,
- ShadowRunMetrics metrics,
- CancellationToken cancellationToken)
- {
- const string sql = """
- insert into outbox.outbox
- (aggregate_id, event_type, payload)
- values (
- @RunId,
- @EventType,
- cast(@Payload as jsonb)
- )
- """;
-
- var eventPayload = System.Text.Json.JsonSerializer.Serialize(new
- {
- RunId = runId,
- ModelId = modelId,
- CorrelationId = correlationId,
- AllGatesPassed = allGatesPassed,
- TotalReturn = metrics.TotalReturn,
- SharpeRatio = metrics.SharpeRatio,
- ProbOfBacktestOverfit = metrics.ProbOfBacktestOverfit,
- DailySharePercentile = metrics.DailySharePercentile,
- CompletedAt = DateTimeOffset.UtcNow
- });
-
- await using var connection = await connectionFactory.OpenAsync(cancellationToken);
- await connection.ExecuteAsync(
- new CommandDefinition(
- sql,
- new
- {
- RunId = runId,
- EventType = "ShadowRunCompleted",
- Payload = eventPayload
- },
- cancellationToken: cancellationToken));
- }
}