4059828abf
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>
87 lines
4.4 KiB
SQL
87 lines
4.4 KiB
SQL
-- Workstream I: VS-04 Audit Trail (Immutable events + GDPR compliance)
|
|
-- Creates compliance audit trail for model operations, regulatory reporting, and GDPR redaction
|
|
|
|
CREATE SCHEMA IF NOT EXISTS compliance;
|
|
|
|
-- Audit events (immutable, INSERT-only)
|
|
CREATE TABLE IF NOT EXISTS compliance.audit_events (
|
|
id UUID PRIMARY KEY,
|
|
event_type VARCHAR(100) NOT NULL, -- MODEL_CREATED, APPROVAL_PROPOSED, APPROVAL_APPROVED, MODEL_ACTIVATED, SELL_DECISION_MADE, SELL_EXECUTED, BACKTEST_COMPLETED, DATA_CORRECTION, etc.
|
|
entity_type VARCHAR(50) NOT NULL, -- MODEL, APPROVAL, SELL_DECISION, TRADE_EXECUTION
|
|
entity_id UUID NOT NULL,
|
|
actor_email VARCHAR(255) NOT NULL,
|
|
actor_role VARCHAR(50), -- MAKER, CHECKER, SRE, SYSTEM
|
|
event_at TIMESTAMPTZ NOT NULL,
|
|
result VARCHAR(50) NOT NULL, -- SUCCESS, FAILURE, PARTIAL
|
|
error_message TEXT,
|
|
details JSONB, -- Event-specific metadata
|
|
evidence_links TEXT[], -- S3 artifact URLs (PBO scores, OOS returns, backtest reports)
|
|
ip_address INET, -- Source IP for forensics
|
|
user_agent TEXT, -- Client identifier
|
|
published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
correlation_id UUID NOT NULL, -- Links related events
|
|
revision INT NOT NULL DEFAULT 1
|
|
);
|
|
|
|
-- Indexes for compliance querying
|
|
CREATE INDEX IF NOT EXISTS idx_audit_events_entity_id ON compliance.audit_events(entity_id);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_events_event_type ON compliance.audit_events(event_type);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_events_actor_email ON compliance.audit_events(actor_email);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_events_event_at ON compliance.audit_events(event_at);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_events_correlation_id ON compliance.audit_events(correlation_id);
|
|
|
|
-- GDPR retention tracking (personal data retention policy)
|
|
CREATE TABLE IF NOT EXISTS compliance.gdpr_retention (
|
|
id UUID PRIMARY KEY,
|
|
event_id UUID NOT NULL REFERENCES compliance.audit_events(id),
|
|
customer_id UUID, -- Links to personal data
|
|
data_categories VARCHAR(50)[], -- PII, EMAIL, TRADING_HISTORY, PORTFOLIO_DATA, etc.
|
|
retention_ends_at DATE, -- When to purge
|
|
purge_status VARCHAR(50) NOT NULL DEFAULT 'PENDING', -- PENDING, PURGED, EXCEPTION
|
|
purged_at TIMESTAMPTZ,
|
|
exception_reason TEXT,
|
|
published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
revision INT NOT NULL DEFAULT 1
|
|
);
|
|
|
|
-- Indexes for GDPR processing
|
|
CREATE INDEX IF NOT EXISTS idx_gdpr_retention_customer_id ON compliance.gdpr_retention(customer_id);
|
|
CREATE INDEX IF NOT EXISTS idx_gdpr_retention_purge_status ON compliance.gdpr_retention(purge_status);
|
|
|
|
-- Event types enumeration (reference, not enforced at DB level)
|
|
CREATE TABLE IF NOT EXISTS compliance.audit_event_types (
|
|
event_type VARCHAR(100) PRIMARY KEY,
|
|
description TEXT,
|
|
entity_type VARCHAR(50), -- MODEL, APPROVAL, SELL_DECISION, TRADE_EXECUTION
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
-- Seed event types
|
|
INSERT INTO compliance.audit_event_types (event_type, description, entity_type) VALUES
|
|
('MODEL_CREATED', 'New model version created', 'MODEL'),
|
|
('MODEL_ARCHIVED', 'Model retired from use', 'MODEL'),
|
|
('APPROVAL_PROPOSED', 'Maker submitted activation proposal', 'APPROVAL'),
|
|
('APPROVAL_APPROVED', 'Checker approved proposal', 'APPROVAL'),
|
|
('APPROVAL_REJECTED', 'Checker rejected proposal', 'APPROVAL'),
|
|
('MODEL_ACTIVATED', 'SRE activated model in production', 'MODEL'),
|
|
('MODEL_DEACTIVATED', 'SRE deactivated model', 'MODEL'),
|
|
('SELL_DECISION_MADE', 'Signal engine generated sell signal', 'SELL_DECISION'),
|
|
('SELL_EXECUTED', 'Trade executed based on signal', 'TRADE_EXECUTION'),
|
|
('BACKTEST_COMPLETED', 'Shadow run/backtest finished', 'MODEL'),
|
|
('DATA_CORRECTION', 'Source data corrected retroactively', 'MODEL'),
|
|
('COMPLIANCE_AUDIT', 'Auditor reviewed trail', 'MODEL')
|
|
ON CONFLICT (event_type) DO NOTHING;
|
|
|
|
-- Schema ownership
|
|
ALTER TABLE compliance.audit_events OWNER TO kartsell;
|
|
ALTER TABLE compliance.gdpr_retention OWNER TO kartsell;
|
|
ALTER TABLE compliance.audit_event_types OWNER TO kartsell;
|
|
|
|
-- Immutability constraints (enforced via code, not DB triggers)
|
|
-- INSERT-only: no UPDATE, no DELETE permitted on audit_events
|
|
-- Timestamps: immutable after insertion (enforced in application layer)
|
|
-- Correlation_id: immutable for traceability
|
|
|
|
-- 7-year retention policy (FSS requirement)
|
|
-- retention_ends_at defaults to now() + 7 years (enforced in application)
|