diff --git a/tests/KArtSell.Integration.Tests/DbUpMigrationTests.cs b/tests/KArtSell.Integration.Tests/DbUpMigrationTests.cs
new file mode 100644
index 00000000..85d2ddc0
--- /dev/null
+++ b/tests/KArtSell.Integration.Tests/DbUpMigrationTests.cs
@@ -0,0 +1,571 @@
+using Xunit;
+using Npgsql;
+using NpgsqlTypes;
+using System.Data;
+using System.Data.Common;
+
+namespace KArtSell.Integration.Tests;
+
+///
+/// DbUp Migration Validation Tests
+/// Covers: Fresh Install, Idempotency, Schema Validation, Trigger/Constraint Enforcement
+/// Following AGENTS.md v16.0: Necessity-driven, Complete evidence preservation, Reproducible
+///
+public sealed class DbUpMigrationTests : IAsyncLifetime
+{
+ private NpgsqlDataSource _dataSource = null!;
+ private const string DefaultConnString = "Host=localhost;Port=5432;Database=kartsell_migration_test;Username=kartsell;Password=kartsell";
+
+ public async Task InitializeAsync()
+ {
+ var connString = Environment.GetEnvironmentVariable("KARTSELL_POSTGRES") ?? DefaultConnString;
+
+ // Create test database if needed
+ var adminConnString = connString.Replace("kartsell_migration_test", "postgres");
+ await using var adminConn = new NpgsqlConnection(adminConnString);
+ await adminConn.OpenAsync();
+
+ try
+ {
+ await using var cmd = adminConn.CreateCommand();
+ cmd.CommandText = "DROP DATABASE IF EXISTS kartsell_migration_test WITH (FORCE);";
+ await cmd.ExecuteNonQueryAsync();
+ }
+ catch (PostgresException ex) when (ex.SqlState == "3D000") { /* DB doesn't exist */ }
+
+ await using var createCmd = adminConn.CreateCommand();
+ createCmd.CommandText = "CREATE DATABASE kartsell_migration_test;";
+ await createCmd.ExecuteNonQueryAsync();
+
+ await adminConn.CloseAsync();
+
+ // Connect to test database
+ _dataSource = new NpgsqlDataSourceBuilder(connString).Build();
+
+ // Apply prerequisite migrations (0000-0007)
+ await ApplyPrerequisiteMigrationsAsync();
+ }
+
+ public async Task DisposeAsync()
+ {
+ await _dataSource.DisposeAsync();
+
+ // Cleanup test database
+ var adminConnString = Environment.GetEnvironmentVariable("KARTSELL_POSTGRES") ?? DefaultConnString;
+ adminConnString = adminConnString.Replace("kartsell_migration_test", "postgres");
+
+ await using var adminConn = new NpgsqlConnection(adminConnString);
+ await adminConn.OpenAsync();
+
+ await using var dropCmd = adminConn.CreateCommand();
+ dropCmd.CommandText = "DROP DATABASE IF EXISTS kartsell_migration_test WITH (FORCE);";
+ await dropCmd.ExecuteNonQueryAsync();
+
+ await adminConn.CloseAsync();
+ }
+
+ ///
+ /// Prerequisite: Apply migrations 0000-0007 to prepare for testing 0008, 0009, 0010
+ ///
+ private async Task ApplyPrerequisiteMigrationsAsync()
+ {
+ await using var connection = await _dataSource.OpenConnectionAsync();
+
+ // Create __dbup_schema_history table
+ await using var createHistoryCmd = connection.CreateCommand();
+ createHistoryCmd.CommandText = """
+ CREATE TABLE IF NOT EXISTS __dbup_schema_history (
+ id SERIAL PRIMARY KEY,
+ scriptname VARCHAR(255) NOT NULL,
+ applied TIMESTAMP NOT NULL DEFAULT NOW()
+ );
+ """;
+ await createHistoryCmd.ExecuteNonQueryAsync();
+
+ // Apply building blocks schema (0000)
+ await using var bbCmd = connection.CreateCommand();
+ bbCmd.CommandText = """
+ CREATE SCHEMA IF NOT EXISTS building_blocks;
+ CREATE TABLE IF NOT EXISTS building_blocks.outbox_message (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ event_type VARCHAR(256) NOT NULL,
+ payload JSONB NOT NULL,
+ published BOOLEAN DEFAULT FALSE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ published_at TIMESTAMP
+ );
+ CREATE SCHEMA IF NOT EXISTS outbox;
+ CREATE TABLE IF NOT EXISTS outbox.outbox (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ event_type VARCHAR(256) NOT NULL,
+ payload JSONB NOT NULL,
+ published BOOLEAN DEFAULT FALSE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ published_at TIMESTAMP
+ );
+ """;
+ await bbCmd.ExecuteNonQueryAsync();
+ }
+
+ ///
+ /// Gate 1: Fresh Install - Migrations run in order with no errors
+ ///
+ [Fact]
+ public async Task Migration0008_FreshInstall_CreatesValidShadowRunSchema()
+ {
+ // Act: Apply migration 0008
+ await ApplyMigration0008();
+
+ // Assert: Table exists with correct structure
+ await using var connection = await _dataSource.OpenConnectionAsync();
+
+ var tableExists = await TableExistsAsync(connection, "model_operations", "shadow_run");
+ Assert.True(tableExists, "shadow_run table should exist");
+
+ var columns = await GetTableColumnsAsync(connection, "model_operations", "shadow_run");
+ Assert.Contains("run_id", columns);
+ Assert.Contains("model_id", columns);
+ Assert.Contains("status", columns);
+ Assert.Contains("published_at", columns);
+ Assert.Contains("metrics_json", columns);
+ Assert.Contains("phase_analysis_json", columns);
+ Assert.Contains("cost_analysis_json", columns);
+ Assert.Contains("false_exit_analysis_json", columns);
+ Assert.Contains("validation_gates_json", columns);
+ }
+
+ ///
+ /// Gate 1: Fresh Install - All 3 migrations run in sequence
+ ///
+ [Fact]
+ public async Task Migration0009_0010_FreshInstall_CreatesCompleteSchema()
+ {
+ // Act: Apply all three migrations
+ await ApplyMigration0008();
+ await ApplyMigration0009();
+ await ApplyMigration0010();
+
+ await using var connection = await _dataSource.OpenConnectionAsync();
+
+ // Assert: All three tables exist
+ Assert.True(await TableExistsAsync(connection, "model_operations", "shadow_run"));
+ Assert.True(await TableExistsAsync(connection, "outbox", "inbox"));
+ Assert.True(await TableExistsAsync(connection, "model_operations", "approval_queue"));
+
+ // Assert: Foreign keys exist
+ var fkCount = await CountForeignKeysAsync(connection, "outbox", "inbox");
+ Assert.True(fkCount > 0, "inbox should have FK to outbox");
+
+ var aqFkCount = await CountForeignKeysAsync(connection, "model_operations", "approval_queue");
+ Assert.True(aqFkCount > 0, "approval_queue should have FK to shadow_run");
+ }
+
+ ///
+ /// Gate 2: Idempotency - Re-running migrations is safe
+ ///
+ [Fact]
+ public async Task Migration0008_Idempotency_ReRunningIsSafe()
+ {
+ // Arrange: Apply migration once
+ await ApplyMigration0008();
+
+ await using var connection = await _dataSource.OpenConnectionAsync();
+
+ // Insert a test record
+ var runId = Guid.NewGuid();
+ var modelId = Guid.NewGuid();
+
+ await using var insertCmd = connection.CreateCommand();
+ insertCmd.CommandText = """
+ INSERT INTO model_operations.shadow_run (run_id, model_id, window_start, window_end, status)
+ VALUES (@runId, @modelId, @start, @end, 'Pending');
+ """;
+ insertCmd.Parameters.AddWithValue("@runId", runId);
+ insertCmd.Parameters.AddWithValue("@modelId", modelId);
+ insertCmd.Parameters.AddWithValue("@start", new DateOnly(2024, 1, 2));
+ insertCmd.Parameters.AddWithValue("@end", new DateOnly(2024, 8, 31));
+ await insertCmd.ExecuteNonQueryAsync();
+
+ // Act: Re-run migration
+ await ApplyMigration0008();
+
+ // Assert: Data survived, table is unchanged
+ await using var selectCmd = connection.CreateCommand();
+ selectCmd.CommandText = "SELECT COUNT(*) FROM model_operations.shadow_run;";
+ var count = (long?)await selectCmd.ExecuteScalarAsync();
+
+ Assert.Equal(1, count);
+ }
+
+ ///
+ /// Gate 3: Constraints - Status transitions enforced
+ ///
+ [Fact]
+ public async Task Migration0008_Constraint_StatusValuesEnforced()
+ {
+ await ApplyMigration0008();
+ await using var connection = await _dataSource.OpenConnectionAsync();
+
+ // Act: Try to insert invalid status
+ var runId = Guid.NewGuid();
+ var modelId = Guid.NewGuid();
+
+ await using var invalidCmd = connection.CreateCommand();
+ invalidCmd.CommandText = """
+ INSERT INTO model_operations.shadow_run (run_id, model_id, window_start, window_end, status)
+ VALUES (@runId, @modelId, @start, @end, 'InvalidStatus');
+ """;
+ invalidCmd.Parameters.AddWithValue("@runId", runId);
+ invalidCmd.Parameters.AddWithValue("@modelId", modelId);
+ invalidCmd.Parameters.AddWithValue("@start", new DateOnly(2024, 1, 2));
+ invalidCmd.Parameters.AddWithValue("@end", new DateOnly(2024, 8, 31));
+
+ // Assert: Constraint violation
+ await Assert.ThrowsAsync(
+ () => invalidCmd.ExecuteNonQueryAsync());
+ }
+
+ ///
+ /// Gate 3: Constraints - Window order enforced (start <= end)
+ ///
+ [Fact]
+ public async Task Migration0008_Constraint_WindowOrderEnforced()
+ {
+ await ApplyMigration0008();
+ await using var connection = await _dataSource.OpenConnectionAsync();
+
+ // Act: Try to insert with end < start
+ var runId = Guid.NewGuid();
+ var modelId = Guid.NewGuid();
+
+ await using var invalidCmd = connection.CreateCommand();
+ invalidCmd.CommandText = """
+ INSERT INTO model_operations.shadow_run (run_id, model_id, window_start, window_end, status)
+ VALUES (@runId, @modelId, @start, @end, 'Pending');
+ """;
+ invalidCmd.Parameters.AddWithValue("@runId", runId);
+ invalidCmd.Parameters.AddWithValue("@modelId", modelId);
+ invalidCmd.Parameters.AddWithValue("@start", new DateOnly(2024, 8, 31));
+ invalidCmd.Parameters.AddWithValue("@end", new DateOnly(2024, 1, 2));
+
+ // Assert: Constraint violation
+ await Assert.ThrowsAsync(
+ () => invalidCmd.ExecuteNonQueryAsync());
+ }
+
+ ///
+ /// Gate 3: Trigger - Inbox processed_at validation
+ ///
+ [Fact]
+ public async Task Migration0009_Trigger_InboxProcessedAtRequired()
+ {
+ await ApplyMigration0008();
+ await ApplyMigration0009();
+
+ await using var connection = await _dataSource.OpenConnectionAsync();
+
+ // Insert outbox message first
+ var outboxId = Guid.NewGuid();
+ await using var outboxCmd = connection.CreateCommand();
+ outboxCmd.CommandText = """
+ INSERT INTO outbox.outbox (id, event_type, payload)
+ VALUES (@id, 'TestEvent', '{"test":"data"}'::jsonb);
+ """;
+ outboxCmd.Parameters.AddWithValue("@id", outboxId);
+ await outboxCmd.ExecuteNonQueryAsync();
+
+ // Act: Try to set status=Processed without processed_at
+ var inboxId = Guid.NewGuid();
+ await using var invalidCmd = connection.CreateCommand();
+ invalidCmd.CommandText = """
+ INSERT INTO outbox.inbox (id, outbox_id, consumer_id, event_type, payload, status)
+ VALUES (@id, @outboxId, 'TestConsumer', 'TestEvent', '{"test":"data"}'::jsonb, 'Processed');
+ """;
+ invalidCmd.Parameters.AddWithValue("@id", inboxId);
+ invalidCmd.Parameters.AddWithValue("@outboxId", outboxId);
+
+ // Assert: Trigger violation
+ await Assert.ThrowsAsync(
+ () => invalidCmd.ExecuteNonQueryAsync());
+ }
+
+ ///
+ /// Gate 3: Idempotency - Inbox dedup constraint
+ ///
+ [Fact]
+ public async Task Migration0009_Constraint_InboxIdempotencyEnforced()
+ {
+ await ApplyMigration0008();
+ await ApplyMigration0009();
+
+ await using var connection = await _dataSource.OpenConnectionAsync();
+
+ // Insert outbox message
+ var outboxId = Guid.NewGuid();
+ await using var outboxCmd = connection.CreateCommand();
+ outboxCmd.CommandText = """
+ INSERT INTO outbox.outbox (id, event_type, payload)
+ VALUES (@id, 'TestEvent', '{"test":"data"}'::jsonb);
+ """;
+ outboxCmd.Parameters.AddWithValue("@id", outboxId);
+ await outboxCmd.ExecuteNonQueryAsync();
+
+ // Insert first inbox record
+ await using var insertCmd = connection.CreateCommand();
+ insertCmd.CommandText = """
+ INSERT INTO outbox.inbox (outbox_id, consumer_id, event_type, payload, status)
+ VALUES (@outboxId, 'Consumer1', 'TestEvent', '{"test":"data"}'::jsonb, 'Pending');
+ """;
+ insertCmd.Parameters.AddWithValue("@outboxId", outboxId);
+ await insertCmd.ExecuteNonQueryAsync();
+
+ // Act: Try to insert duplicate (same outbox_id + consumer_id)
+ await using var duplicateCmd = connection.CreateCommand();
+ duplicateCmd.CommandText = """
+ INSERT INTO outbox.inbox (outbox_id, consumer_id, event_type, payload, status)
+ VALUES (@outboxId, 'Consumer1', 'TestEvent', '{"test":"data"}'::jsonb, 'Pending');
+ """;
+ duplicateCmd.Parameters.AddWithValue("@outboxId", outboxId);
+
+ // Assert: UNIQUE constraint violation
+ await Assert.ThrowsAsync(
+ () => duplicateCmd.ExecuteNonQueryAsync());
+ }
+
+ ///
+ /// Gate 3: Trigger - Approval workflow validation
+ ///
+ [Fact]
+ public async Task Migration0010_Trigger_ApprovalRequiresApprovedBy()
+ {
+ await ApplyMigration0008();
+ await ApplyMigration0010();
+
+ await using var connection = await _dataSource.OpenConnectionAsync();
+
+ // Insert shadow_run first
+ var runId = Guid.NewGuid();
+ var modelId = Guid.NewGuid();
+ await using var srCmd = connection.CreateCommand();
+ srCmd.CommandText = """
+ INSERT INTO model_operations.shadow_run (run_id, model_id, window_start, window_end, status)
+ VALUES (@runId, @modelId, @start, @end, 'Pending');
+ """;
+ srCmd.Parameters.AddWithValue("@runId", runId);
+ srCmd.Parameters.AddWithValue("@modelId", modelId);
+ srCmd.Parameters.AddWithValue("@start", new DateOnly(2024, 1, 2));
+ srCmd.Parameters.AddWithValue("@end", new DateOnly(2024, 8, 31));
+ await srCmd.ExecuteNonQueryAsync();
+
+ // Act: Try to set status=Approved without approved_by
+ await using var invalidCmd = connection.CreateCommand();
+ invalidCmd.CommandText = """
+ INSERT INTO model_operations.approval_queue (run_id, model_id, status)
+ VALUES (@runId, @modelId, 'Approved');
+ """;
+ invalidCmd.Parameters.AddWithValue("@runId", runId);
+ invalidCmd.Parameters.AddWithValue("@modelId", modelId);
+
+ // Assert: Trigger violation
+ await Assert.ThrowsAsync(
+ () => invalidCmd.ExecuteNonQueryAsync());
+ }
+
+ ///
+ /// Gate 3: Trigger - Rejection requires reason
+ ///
+ [Fact]
+ public async Task Migration0010_Trigger_RejectionRequiresReason()
+ {
+ await ApplyMigration0008();
+ await ApplyMigration0010();
+
+ await using var connection = await _dataSource.OpenConnectionAsync();
+
+ // Insert shadow_run
+ var runId = Guid.NewGuid();
+ var modelId = Guid.NewGuid();
+ await using var srCmd = connection.CreateCommand();
+ srCmd.CommandText = """
+ INSERT INTO model_operations.shadow_run (run_id, model_id, window_start, window_end, status)
+ VALUES (@runId, @modelId, @start, @end, 'Pending');
+ """;
+ srCmd.Parameters.AddWithValue("@runId", runId);
+ srCmd.Parameters.AddWithValue("@modelId", modelId);
+ srCmd.Parameters.AddWithValue("@start", new DateOnly(2024, 1, 2));
+ srCmd.Parameters.AddWithValue("@end", new DateOnly(2024, 8, 31));
+ await srCmd.ExecuteNonQueryAsync();
+
+ // Act: Try to set status=Rejected without rejection_reason
+ await using var invalidCmd = connection.CreateCommand();
+ invalidCmd.CommandText = """
+ INSERT INTO model_operations.approval_queue (run_id, model_id, status)
+ VALUES (@runId, @modelId, 'Rejected');
+ """;
+ invalidCmd.Parameters.AddWithValue("@runId", runId);
+ invalidCmd.Parameters.AddWithValue("@modelId", modelId);
+
+ // Assert: Trigger violation
+ await Assert.ThrowsAsync(
+ () => invalidCmd.ExecuteNonQueryAsync());
+ }
+
+ ///
+ /// Gate 4: Failure Recovery - FK constraint prevents orphaned approvals
+ ///
+ [Fact]
+ public async Task Migration0010_ForeignKey_PreventsShadowRunDeletion()
+ {
+ await ApplyMigration0008();
+ await ApplyMigration0010();
+
+ await using var connection = await _dataSource.OpenConnectionAsync();
+
+ // Insert shadow_run and approval
+ var runId = Guid.NewGuid();
+ var modelId = Guid.NewGuid();
+ var approverId = Guid.NewGuid();
+
+ await using var srCmd = connection.CreateCommand();
+ srCmd.CommandText = """
+ INSERT INTO model_operations.shadow_run (run_id, model_id, window_start, window_end, status)
+ VALUES (@runId, @modelId, @start, @end, 'Pending');
+ """;
+ srCmd.Parameters.AddWithValue("@runId", runId);
+ srCmd.Parameters.AddWithValue("@modelId", modelId);
+ srCmd.Parameters.AddWithValue("@start", new DateOnly(2024, 1, 2));
+ srCmd.Parameters.AddWithValue("@end", new DateOnly(2024, 8, 31));
+ await srCmd.ExecuteNonQueryAsync();
+
+ await using var aqCmd = connection.CreateCommand();
+ aqCmd.CommandText = """
+ INSERT INTO model_operations.approval_queue (run_id, model_id, status, approved_by, approval_reason)
+ VALUES (@runId, @modelId, 'Approved', @approverId, 'Test approval');
+ """;
+ aqCmd.Parameters.AddWithValue("@runId", runId);
+ aqCmd.Parameters.AddWithValue("@modelId", modelId);
+ aqCmd.Parameters.AddWithValue("@approverId", approverId);
+ await aqCmd.ExecuteNonQueryAsync();
+
+ // Act: Try to delete shadow_run (should fail due to FK)
+ await using var deleteCmd = connection.CreateCommand();
+ deleteCmd.CommandText = "DELETE FROM model_operations.shadow_run WHERE run_id = @runId;";
+ deleteCmd.Parameters.AddWithValue("@runId", runId);
+
+ // Assert: FK violation prevents deletion
+ await Assert.ThrowsAsync(
+ () => deleteCmd.ExecuteNonQueryAsync());
+ }
+
+ ///
+ /// Gate 4: Indexes - Common queries are indexed
+ ///
+ [Fact]
+ public async Task Migration0008_Indexes_ExistForCommonQueries()
+ {
+ await ApplyMigration0008();
+ await using var connection = await _dataSource.OpenConnectionAsync();
+
+ var indexes = await GetIndexesAsync(connection, "model_operations", "shadow_run");
+
+ Assert.Contains("idx_shadow_run_model_created", indexes);
+ Assert.Contains("idx_shadow_run_status", indexes);
+ Assert.Contains("idx_shadow_run_published_at", indexes);
+ }
+
+ // ========== Helpers ==========
+
+ private async Task ApplyMigration0008()
+ {
+ await using var connection = await _dataSource.OpenConnectionAsync();
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = File.ReadAllText("src/KArtSell.DbMigrator/0008_CreateShadowRunTable.sql");
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ private async Task ApplyMigration0009()
+ {
+ await using var connection = await _dataSource.OpenConnectionAsync();
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = File.ReadAllText("src/KArtSell.DbMigrator/0009_CreateInboxTable.sql");
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ private async Task ApplyMigration0010()
+ {
+ await using var connection = await _dataSource.OpenConnectionAsync();
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = File.ReadAllText("src/KArtSell.DbMigrator/0010_CreateApprovalQueueTable.sql");
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ private static async Task TableExistsAsync(DbConnection connection, string schema, string table)
+ {
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = """
+ SELECT EXISTS (
+ SELECT 1 FROM information_schema.tables
+ WHERE table_schema = @schema AND table_name = @table
+ );
+ """;
+ cmd.Parameters.Add(new NpgsqlParameter("@schema", schema));
+ cmd.Parameters.Add(new NpgsqlParameter("@table", table));
+
+ var result = await cmd.ExecuteScalarAsync();
+ return result is true or (long)1;
+ }
+
+ private static async Task> GetTableColumnsAsync(DbConnection connection, string schema, string table)
+ {
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = """
+ SELECT column_name FROM information_schema.columns
+ WHERE table_schema = @schema AND table_name = @table
+ ORDER BY ordinal_position;
+ """;
+ cmd.Parameters.Add(new NpgsqlParameter("@schema", schema));
+ cmd.Parameters.Add(new NpgsqlParameter("@table", table));
+
+ var columns = new List();
+ await using var reader = await cmd.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ columns.Add(reader.GetString(0));
+ }
+ return columns;
+ }
+
+ private static async Task CountForeignKeysAsync(DbConnection connection, string schema, string table)
+ {
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = """
+ SELECT COUNT(*) FROM information_schema.table_constraints
+ WHERE table_schema = @schema AND table_name = @table
+ AND constraint_type = 'FOREIGN KEY';
+ """;
+ cmd.Parameters.Add(new NpgsqlParameter("@schema", schema));
+ cmd.Parameters.Add(new NpgsqlParameter("@table", table));
+
+ var result = await cmd.ExecuteScalarAsync();
+ return Convert.ToInt32(result);
+ }
+
+ private static async Task> GetIndexesAsync(DbConnection connection, string schema, string table)
+ {
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = """
+ SELECT indexname FROM pg_indexes
+ WHERE schemaname = @schema AND tablename = @table;
+ """;
+ cmd.Parameters.Add(new NpgsqlParameter("@schema", schema));
+ cmd.Parameters.Add(new NpgsqlParameter("@table", table));
+
+ var indexes = new List();
+ await using var reader = await cmd.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ indexes.Add(reader.GetString(0));
+ }
+ return indexes;
+ }
+}