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:
2026-08-07 20:23:56 +09:00
parent b1e38ac374
commit 4059828abf
7 changed files with 111 additions and 20 deletions
@@ -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