fix: missing model_operations.models table + compliance schema breaking every fresh DB (live deploy failure)
The SCP/DbMigrator deploy to the production target (178.104.200.7)
failed today with `relation "model_operations.models" does not
exist` at migration 0036 — the exact same failure I'd already hit
against a local test database, confirming this isn't environment
drift but a real, deterministic bug: no migration ever created
model_operations.models, only referenced it via FK (0036, 0038) and
queried it directly (OpenDartDailyBatchJob.cs). Migration 0037 had
the same class of bug for the `compliance` schema itself.
- Add 0035_model_operations_models.sql (must sort before 0036).
Scope is intentionally minimal — id/ticker/published_at/
correlation_id/revision, i.e. only what's actually referenced
today. The full Model Card/lifecycle schema is separate, larger
work and isn't guessed at here.
- Add `CREATE SCHEMA IF NOT EXISTS compliance;` to 0037, plus
IF NOT EXISTS on its indexes for re-run idempotency (matching the
rest of this migration set).
- Verified: full chain 0000->0040 applies to a fresh DB
("Upgrade successful") and re-run is a clean no-op
("No new scripts need to be executed").
Fixing the schema far enough to actually run queries against it
surfaced 3 more real, previously untested bugs in already-merged
code (none reachable before because the tables/schema didn't exist):
- Dapper was never configured for snake_case<->PascalCase column
mapping (`Dapper.DefaultTypeMap.MatchNamesWithUnderscores`), so
every Sql class's result-set queries were silently returning
null/default for every property instead of throwing. Fixed once,
centrally, via a `[ModuleInitializer]` in
KArtSell.BuildingBlocks/Data/DapperBootstrap.cs so it's set before
the first query regardless of entry point (Host/DbMigrator/tests).
- jsonb/inet columns written without an explicit cast
(`42804: column "x" is of type jsonb but expression is of type
text`) in AuditSql (details, ip_address), TradeSql (kis_response),
SellDecisionSql (oos_performance) — fixed with `::jsonb`/`::inet`
casts. AuditSql's jsonb read-back into Dictionary<string,object>
also needed a raw-DTO + JsonSerializer.Deserialize mapping.
- AuditSql.RedactAuditEventDetailsAsync had a literal duplicate
`SET details = ... details = ...` (invalid SQL) — nested the two
jsonb_set calls into one assignment.
Verified: dotnet build 0/0; architecture 13/13; unit 54/54+18/18;
Host boots cleanly and registers all 34 endpoints against the
now-complete schema.
New tech debt recorded: DEBT-020 (this fix), DEBT-021 (Dapper
snake_case fix), DEBT-022 (jsonb/inet casts, partial — not yet
audited beyond what surfaced), DEBT-023 (ApprovalSql.
InsertProposalAsync still fails on a raw DateOnly parameter — same
class of issue as DEBT-021, not yet fixed), DEBT-024 (TradeExecution
tests don't insert FK parent rows; one pure-logic ranker test
returned 1000 instead of 950 under the full suite, not yet
root-caused; DbUpMigrationTests fail locally on a Postgres role
permission gap unrelated to this fix).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,7 +43,7 @@ public class AuditSql
|
||||
error_message, details, evidence_links, ip_address, user_agent, published_at,
|
||||
correlation_id, revision)
|
||||
VALUES (@Id, @EventType, @EntityType, @EntityId, @ActorEmail, @ActorRole, @EventAt,
|
||||
@Result, @ErrorMessage, @Details, @EvidenceLinks, @IpAddress, @UserAgent,
|
||||
@Result, @ErrorMessage, @Details::jsonb, @EvidenceLinks, @IpAddress::inet, @UserAgent,
|
||||
NOW(), @CorrelationId, 1)
|
||||
""";
|
||||
|
||||
@@ -146,7 +146,8 @@ public class AuditSql
|
||||
parameters.Add("@Skip", skip);
|
||||
parameters.Add("@Take", take);
|
||||
|
||||
var events = (await db.QueryAsync<AuditEvent>(sql, parameters)).ToList();
|
||||
var raw = await db.QueryAsync<AuditEventRaw>(sql, parameters);
|
||||
var events = raw.Select(ToAuditEvent).ToList();
|
||||
|
||||
return (events, total);
|
||||
}
|
||||
@@ -167,7 +168,48 @@ public class AuditSql
|
||||
WHERE id = @EventId
|
||||
""";
|
||||
|
||||
return await db.QuerySingleOrDefaultAsync<AuditEvent>(sql, new { EventId = eventId });
|
||||
var raw = await db.QuerySingleOrDefaultAsync<AuditEventRaw>(sql, new { EventId = eventId });
|
||||
return raw == null ? null : ToAuditEvent(raw);
|
||||
}
|
||||
|
||||
private static AuditEvent ToAuditEvent(AuditEventRaw raw) => new()
|
||||
{
|
||||
Id = raw.Id,
|
||||
EventType = raw.EventType,
|
||||
EntityType = raw.EntityType,
|
||||
EntityId = raw.EntityId,
|
||||
ActorEmail = raw.ActorEmail,
|
||||
ActorRole = raw.ActorRole,
|
||||
EventAt = raw.EventAt,
|
||||
Result = raw.Result,
|
||||
ErrorMessage = raw.ErrorMessage,
|
||||
Details = raw.Details == null ? null : JsonSerializer.Deserialize<Dictionary<string, object>>(raw.Details),
|
||||
EvidenceLinks = raw.EvidenceLinks,
|
||||
IpAddress = raw.IpAddress,
|
||||
UserAgent = raw.UserAgent,
|
||||
PublishedAt = raw.PublishedAt,
|
||||
CorrelationId = raw.CorrelationId,
|
||||
Revision = raw.Revision
|
||||
};
|
||||
|
||||
private sealed class AuditEventRaw
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public required string EventType { get; set; }
|
||||
public required string EntityType { get; set; }
|
||||
public Guid EntityId { get; set; }
|
||||
public required string ActorEmail { get; set; }
|
||||
public string? ActorRole { get; set; }
|
||||
public DateTime EventAt { get; set; }
|
||||
public required string Result { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public string? Details { get; set; }
|
||||
public string[]? EvidenceLinks { get; set; }
|
||||
public string? IpAddress { get; set; }
|
||||
public string? UserAgent { get; set; }
|
||||
public DateTime PublishedAt { get; set; }
|
||||
public Guid CorrelationId { get; set; }
|
||||
public int Revision { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -255,12 +297,11 @@ public class AuditSql
|
||||
const string sql = """
|
||||
UPDATE compliance.audit_events
|
||||
SET details = jsonb_set(
|
||||
COALESCE(details, '{}'::jsonb),
|
||||
'{actor_email}',
|
||||
'"<redacted>"'::jsonb
|
||||
),
|
||||
details = jsonb_set(
|
||||
details,
|
||||
jsonb_set(
|
||||
COALESCE(details, '{}'::jsonb),
|
||||
'{actor_email}',
|
||||
'"<redacted>"'::jsonb
|
||||
),
|
||||
'{customer_id}',
|
||||
'"<purged>"'::jsonb
|
||||
),
|
||||
|
||||
@@ -24,7 +24,7 @@ public class SellDecisionSql : ISellDecisionSql
|
||||
sell_priority, target_quantity, target_price, approval_id, execution_id,
|
||||
created_at, created_by, created_justification, published_at, correlation_id, revision
|
||||
) VALUES (
|
||||
@id, @modelId, @status, @pboScore, @dsrMetric, @oosPerformance,
|
||||
@id, @modelId, @status, @pboScore, @dsrMetric, @oosPerformance::jsonb,
|
||||
@sellPriority, @targetQuantity, @targetPrice, @approvalId, @executionId,
|
||||
@createdAt, @createdBy, @createdJustification, @publishedAt, @correlationId, @revision
|
||||
)
|
||||
|
||||
@@ -126,7 +126,7 @@ public class TradeSql : ITradeSql
|
||||
unit_price, total_amount, commission, net_proceeds, error_message, kis_response,
|
||||
execution_timestamp, settlement_timestamp, published_at, correlation_id, revision)
|
||||
VALUES (@id, @sellDecisionId, @kisOrderId, @status, @quantity, @executedQuantity,
|
||||
@unitPrice, @totalAmount, @commission, @netProceeds, @errorMessage, @kisResponse,
|
||||
@unitPrice, @totalAmount, @commission, @netProceeds, @errorMessage, @kisResponse::jsonb,
|
||||
@executionTimestamp, @settlementTimestamp, @publishedAt, @correlationId, @revision)
|
||||
""";
|
||||
|
||||
@@ -167,13 +167,13 @@ public class TradeSql : ITradeSql
|
||||
const string sql = """
|
||||
INSERT INTO model_operations.trade_status_history
|
||||
(id, trade_id, old_status, new_status, transitioned_at, kis_response, error_message, published_at, correlation_id)
|
||||
SELECT @id, id, status, @newStatus, NOW(), @kisResponse, @errorMessage, NOW(), @correlationId
|
||||
SELECT @id, id, status, @newStatus, NOW(), @kisResponse::jsonb, @errorMessage, NOW(), @correlationId
|
||||
FROM model_operations.trades
|
||||
WHERE id = @tradeId;
|
||||
|
||||
UPDATE model_operations.trades
|
||||
SET status = @newStatus,
|
||||
kis_response = COALESCE(@kisResponse, kis_response),
|
||||
kis_response = COALESCE(@kisResponse::jsonb, kis_response),
|
||||
error_message = COALESCE(@errorMessage, error_message),
|
||||
revision = revision + 1
|
||||
WHERE id = @tradeId
|
||||
|
||||
Reference in New Issue
Block a user