diff --git a/.github/sql/ci_execute_report_views.sql b/.github/sql/ci_execute_report_views.sql
new file mode 100644
index 000000000..df1c7488e
--- /dev/null
+++ b/.github/sql/ci_execute_report_views.sql
@@ -0,0 +1,76 @@
+/*
+Copyright 2026 Darling Data, LLC
+https://www.erikdarling.com/
+
+CI: execute every report.* view against the seeded rows (#1669).
+
+Existence checks (OBJECT_ID) let two view bugs ship: #1635's bit -> nvarchar conversion (Msg 245)
+and #1666's binary(8) boxed raw into sql_variant. Both compile fine and only fail (or corrupt)
+when rows flow through the projection. This sweep runs AFTER ci_seed_report_sources.sql so rows
+exist, and materializes each view with SELECT * INTO - a plain COUNT(*) would let the optimizer
+prune the projected columns and skip exactly the per-row conversions this exists to catch.
+
+Enumerates sys.views dynamically: a new report view is covered the day it ships, and views that
+only exist post-collection (the dynamically-built report.query_snapshots pair) are simply absent
+on a fresh install rather than hardcoded failures. Views whose WHERE excludes every seed row still
+execute their plan shape; they just prove less - the seeds aim rows at the windows the views read.
+*/
+
+SET ANSI_NULLS ON;
+SET QUOTED_IDENTIFIER ON;
+SET NOCOUNT ON;
+GO
+
+USE PerformanceMonitor;
+GO
+
+DECLARE
+ @view_name sysname,
+ @sql nvarchar(max),
+ @failed integer = 0,
+ @executed integer = 0,
+ @failures nvarchar(max) = N'';
+
+DECLARE view_sweep CURSOR LOCAL FAST_FORWARD FOR
+ SELECT
+ v.name
+ FROM sys.views AS v
+ WHERE SCHEMA_NAME(v.schema_id) = N'report'
+ ORDER BY v.name;
+
+OPEN view_sweep;
+FETCH NEXT FROM view_sweep INTO @view_name;
+
+WHILE @@FETCH_STATUS = 0
+BEGIN
+ /* SELECT * INTO forces every projected column to be evaluated for every row -
+ the temp table is scoped to the EXEC batch and vanishes with it. */
+ SET @sql = N'SELECT * INTO #ci_sink FROM report.' + QUOTENAME(@view_name) + N';';
+
+ BEGIN TRY
+ EXECUTE sys.sp_executesql @sql;
+ SET @executed += 1;
+ END TRY
+ BEGIN CATCH
+ SET @failed += 1;
+ SET @failures +=
+ NCHAR(10) + N' report.' + @view_name +
+ N' -> Msg ' + CAST(ERROR_NUMBER() AS nvarchar(10)) +
+ N': ' + ERROR_MESSAGE();
+ PRINT N'FAIL: report.' + @view_name + N' -> Msg ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N': ' + ERROR_MESSAGE();
+ END CATCH;
+
+ FETCH NEXT FROM view_sweep INTO @view_name;
+END;
+
+CLOSE view_sweep;
+DEALLOCATE view_sweep;
+
+PRINT N'Report view execution sweep: ' + CAST(@executed AS nvarchar(10)) + N' executed clean, ' + CAST(@failed AS nvarchar(10)) + N' failed.';
+
+IF @failed > 0
+BEGIN
+ DECLARE @error_message nvarchar(2048) = N'Report view execution sweep failed for ' + CAST(@failed AS nvarchar(10)) + N' view(s):' + LEFT(@failures, 1900);
+ THROW 50069, @error_message, 1;
+END;
+GO
diff --git a/.github/sql/ci_generate_seed_rows.sql b/.github/sql/ci_generate_seed_rows.sql
new file mode 100644
index 000000000..f940d22ed
--- /dev/null
+++ b/.github/sql/ci_generate_seed_rows.sql
@@ -0,0 +1,58 @@
+/*
+Copyright 2026 Darling Data, LLC
+https://www.erikdarling.com/
+
+DEV TOOL - not run by CI. Regenerates the generated section of ci_seed_report_sources.sql from a
+database carrying the current install schema: run it there, paste the PRINTed INSERTs between the
+"generated" markers, and keep the hand-authored history pairs at the bottom of that file (#1669).
+*/
+
+SET NOCOUNT ON;
+/* Generate one INSERT per collect/config base table: NOT NULL, non-identity, non-computed columns.
+ Values are type-driven; time-ish columns land 5 minutes ago so "today"/"last hour" views see them. */
+DECLARE @sql nvarchar(max);
+DECLARE t CURSOR LOCAL FAST_FORWARD FOR
+ SELECT s.name, tb.name
+ FROM sys.tables AS tb
+ JOIN sys.schemas AS s ON s.schema_id = tb.schema_id
+ WHERE s.name IN (N'collect', N'config')
+ AND tb.is_ms_shipped = 0
+ ORDER BY s.name, tb.name;
+DECLARE @s sysname, @tb sysname;
+OPEN t;
+FETCH NEXT FROM t INTO @s, @tb;
+WHILE @@FETCH_STATUS = 0
+BEGIN
+ DECLARE @cols nvarchar(max) = N'', @vals nvarchar(max) = N'';
+ SELECT
+ @cols += CASE WHEN @cols = N'' THEN N'' ELSE N', ' END + QUOTENAME(c.name),
+ @vals += CASE WHEN @vals = N'' THEN N'' ELSE N', ' END +
+ CASE
+ WHEN c.name = N'severity' THEN N'N''CRITICAL'''
+ WHEN tp.name = N'datetimeoffset' THEN N'SYSDATETIMEOFFSET()'
+ WHEN c.name IN (N'query_text', N'query_plan_text', N'query_sql_text', N'statement_text', N'plan_xml_compressed') AND tp.name IN (N'varbinary')
+ THEN N'COMPRESS(N''SELECT 1 AS ci_seed;'')'
+ WHEN tp.name IN (N'nvarchar', N'varchar', N'sysname', N'nchar', N'char') THEN N'N''ci'''
+ WHEN tp.name IN (N'datetime2', N'datetime', N'smalldatetime') THEN N'DATEADD(MINUTE, -5, SYSDATETIME())'
+ WHEN tp.name = N'date' THEN N'CONVERT(date, SYSDATETIME())'
+ WHEN tp.name = N'time' THEN N'CONVERT(time, SYSDATETIME())'
+ WHEN tp.name IN (N'bit') THEN N'1'
+ WHEN tp.name IN (N'int', N'bigint', N'smallint', N'tinyint', N'decimal', N'numeric', N'float', N'real', N'money') THEN N'1'
+ WHEN tp.name IN (N'varbinary', N'binary') THEN N'0x00'
+ WHEN tp.name = N'uniqueidentifier' THEN N'NEWID()'
+ WHEN tp.name = N'xml' THEN N'CONVERT(xml, N'''')'
+ ELSE N'NULL /* unhandled: ' + tp.name + N' */'
+ END
+ FROM sys.columns AS c
+ JOIN sys.types AS tp ON tp.user_type_id = c.user_type_id
+ WHERE c.object_id = OBJECT_ID(QUOTENAME(@s) + N'.' + QUOTENAME(@tb))
+ AND c.is_identity = 0
+ AND c.is_computed = 0
+ AND c.is_nullable = 0;
+ IF @cols <> N''
+ PRINT N'IF NOT EXISTS (SELECT 1/0 FROM ' + QUOTENAME(@s) + N'.' + QUOTENAME(@tb) + N' WHERE 1 = 1) INSERT INTO ' + QUOTENAME(@s) + N'.' + QUOTENAME(@tb) + N' (' + @cols + N') VALUES (' + @vals + N');';
+ ELSE
+ PRINT N'/* ' + QUOTENAME(@s) + N'.' + QUOTENAME(@tb) + N': all columns nullable/identity - DEFAULT VALUES */';
+ FETCH NEXT FROM t INTO @s, @tb;
+END;
+CLOSE t; DEALLOCATE t;
diff --git a/.github/sql/ci_seed_report_sources.sql b/.github/sql/ci_seed_report_sources.sql
new file mode 100644
index 000000000..ba356c468
--- /dev/null
+++ b/.github/sql/ci_seed_report_sources.sql
@@ -0,0 +1,121 @@
+/*
+Copyright 2026 Darling Data, LLC
+https://www.erikdarling.com/
+
+CI seed rows for the report.* view execution sweep (#1669).
+
+The existence check alone let two view bugs ship (#1635's bit->nvarchar Msg 245, #1666's binary
+boxed into sql_variant): a view can compile and exist yet throw only when rows flow through its
+projection. These seeds put one representative row in every collect/config table (generated from
+the installed schema: every NOT NULL, non-identity, non-computed column gets a type-appropriate
+value; nullable columns stay NULL so NULL branches execute too), timestamped a few minutes back so
+"today"/"last hour" windows include them. History tables that feed LAG/toggle views get a
+hand-authored correlated PAIR below, so the change-detection paths produce actual output rows.
+
+Each INSERT is guarded on the table being empty: re-runnable anywhere, inert on a store that
+already has data. CI runs this after a fresh install, immediately before ci_execute_report_views.sql.
+
+Regenerating after schema changes: the generator lives in the PR that added this (see #1669) -
+metadata-driven, so adding a table means re-running it or hand-adding one guarded INSERT here.
+*/
+
+SET ANSI_NULLS ON;
+SET QUOTED_IDENTIFIER ON;
+SET NOCOUNT ON;
+GO
+
+USE PerformanceMonitor;
+GO
+
+/* ---------------- generated: one guarded row per collect/config table ---------------- */
+
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[blocked_process_xml] WHERE 1 = 1) INSERT INTO [collect].[blocked_process_xml] ([collection_time], [blocked_process_xml], [is_processed]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), CONVERT(xml, N''), 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[blocking_BlockedProcessReport] WHERE 1 = 1) INSERT INTO [collect].[blocking_BlockedProcessReport] ([collection_time], [blocked_process_report]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), N'ci');
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[blocking_deadlock_stats] WHERE 1 = 1) INSERT INTO [collect].[blocking_deadlock_stats] ([collection_time], [database_name], [blocking_event_count], [total_blocking_duration_ms], [max_blocking_duration_ms], [deadlock_count], [total_deadlock_wait_time_ms], [victim_count]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', 1, 1, 1, 1, 1, 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[cpu_scheduler_stats] WHERE 1 = 1) INSERT INTO [collect].[cpu_scheduler_stats] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()));
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[cpu_utilization_stats] WHERE 1 = 1) INSERT INTO [collect].[cpu_utilization_stats] ([collection_time], [sample_time], [sqlserver_cpu_utilization]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[database_size_stats] WHERE 1 = 1) INSERT INTO [collect].[database_size_stats] ([collection_time], [database_name], [database_id], [file_id], [file_type_desc], [file_name], [physical_name], [total_size_mb]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', 1, 1, N'ci', N'ci', N'ci', 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[deadlock_xml] WHERE 1 = 1) INSERT INTO [collect].[deadlock_xml] ([collection_time], [deadlock_xml], [is_processed]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), CONVERT(xml, N''), 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[deadlocks] WHERE 1 = 1) INSERT INTO [collect].[deadlocks] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()));
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[default_trace_events] WHERE 1 = 1) INSERT INTO [collect].[default_trace_events] ([collection_time], [event_time], [event_name], [event_class]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[dmv_blocking_snapshots] WHERE 1 = 1) INSERT INTO [collect].[dmv_blocking_snapshots] ([collection_time], [monitor_loop], [event_time], [spid], [ecid], [blocking_spid], [blocking_ecid]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), 1, DATEADD(MINUTE, -5, SYSDATETIME()), 1, 1, 1, 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[file_io_stats] WHERE 1 = 1) INSERT INTO [collect].[file_io_stats] ([collection_time], [server_start_time], [database_id], [file_id]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), 1, 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[HealthParser_CPUTasks] WHERE 1 = 1) INSERT INTO [collect].[HealthParser_CPUTasks] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()));
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[HealthParser_IOIssues] WHERE 1 = 1) INSERT INTO [collect].[HealthParser_IOIssues] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()));
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[HealthParser_MemoryBroker] WHERE 1 = 1) INSERT INTO [collect].[HealthParser_MemoryBroker] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()));
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[HealthParser_MemoryConditions] WHERE 1 = 1) INSERT INTO [collect].[HealthParser_MemoryConditions] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()));
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[HealthParser_MemoryNodeOOM] WHERE 1 = 1) INSERT INTO [collect].[HealthParser_MemoryNodeOOM] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()));
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[HealthParser_SchedulerIssues] WHERE 1 = 1) INSERT INTO [collect].[HealthParser_SchedulerIssues] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()));
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[HealthParser_SevereErrors] WHERE 1 = 1) INSERT INTO [collect].[HealthParser_SevereErrors] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()));
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[HealthParser_SignificantWaits] WHERE 1 = 1) INSERT INTO [collect].[HealthParser_SignificantWaits] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()));
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[HealthParser_SystemHealth] WHERE 1 = 1) INSERT INTO [collect].[HealthParser_SystemHealth] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()));
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[HealthParser_WaitsByCount] WHERE 1 = 1) INSERT INTO [collect].[HealthParser_WaitsByCount] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()));
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[HealthParser_WaitsByDuration] WHERE 1 = 1) INSERT INTO [collect].[HealthParser_WaitsByDuration] ([collection_time]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()));
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[index_object_stats] WHERE 1 = 1) INSERT INTO [collect].[index_object_stats] ([collection_time], [database_name], [database_id], [schema_name], [object_id], [table_name], [index_id]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', 1, N'ci', 1, N'ci', 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[latch_stats] WHERE 1 = 1) INSERT INTO [collect].[latch_stats] ([collection_time], [server_start_time], [latch_class], [waiting_requests_count], [wait_time_ms], [max_wait_time_ms]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', 1, 1, 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[memory_clerks_stats] WHERE 1 = 1) INSERT INTO [collect].[memory_clerks_stats] ([collection_time], [server_start_time], [clerk_type], [memory_node_id]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[memory_grant_stats] WHERE 1 = 1) INSERT INTO [collect].[memory_grant_stats] ([collection_time], [server_start_time], [resource_semaphore_id], [pool_id]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), 1, 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[memory_pressure_events] WHERE 1 = 1) INSERT INTO [collect].[memory_pressure_events] ([collection_time], [sample_time], [memory_notification], [memory_indicators_process], [memory_indicators_system]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', 1, 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[memory_stats] WHERE 1 = 1) INSERT INTO [collect].[memory_stats] ([collection_time], [buffer_pool_mb], [plan_cache_mb], [other_memory_mb], [total_memory_mb], [physical_memory_in_use_mb], [available_physical_memory_mb], [memory_utilization_percentage], [buffer_pool_pressure_warning], [plan_cache_pressure_warning]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), 1, 1, 1, 1, 1, 1, 1, 1, 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[perfmon_stats] WHERE 1 = 1) INSERT INTO [collect].[perfmon_stats] ([collection_time], [server_start_time], [object_name], [counter_name], [instance_name], [cntr_value], [cntr_type]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', N'ci', N'ci', 1, 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[plan_cache_stats] WHERE 1 = 1) INSERT INTO [collect].[plan_cache_stats] ([collection_time], [cacheobjtype], [objtype], [total_plans], [total_size_mb], [single_use_plans], [single_use_size_mb], [multi_use_plans], [multi_use_size_mb], [avg_use_count], [avg_size_kb]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', N'ci', 1, 1, 1, 1, 1, 1, 1, 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[procedure_stats] WHERE 1 = 1) INSERT INTO [collect].[procedure_stats] ([collection_time], [server_start_time], [object_type], [database_name], [object_id], [sql_handle], [plan_handle], [cached_time], [last_execution_time], [execution_count], [total_worker_time], [min_worker_time], [max_worker_time], [total_elapsed_time], [min_elapsed_time], [max_elapsed_time], [total_logical_reads], [min_logical_reads], [max_logical_reads], [total_physical_reads], [min_physical_reads], [max_physical_reads], [total_logical_writes], [min_logical_writes], [max_logical_writes]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', N'ci', 1, 0x00, 0x00, DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[procedure_stats_latest_hash] WHERE 1 = 1) INSERT INTO [collect].[procedure_stats_latest_hash] ([database_name], [object_id], [plan_handle], [row_hash], [last_seen]) VALUES (N'ci', 1, 0x00, 0x00, DATEADD(MINUTE, -5, SYSDATETIME()));
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[query_stats] WHERE 1 = 1) INSERT INTO [collect].[query_stats] ([collection_time], [server_start_time], [object_type], [database_name], [sql_handle], [statement_start_offset], [statement_end_offset], [plan_generation_num], [plan_handle], [creation_time], [last_execution_time], [execution_count], [total_worker_time], [min_worker_time], [max_worker_time], [total_physical_reads], [min_physical_reads], [max_physical_reads], [total_logical_writes], [total_logical_reads], [total_clr_time], [total_elapsed_time], [min_elapsed_time], [max_elapsed_time], [total_rows], [min_rows], [max_rows], [min_dop], [max_dop], [min_grant_kb], [max_grant_kb], [min_used_grant_kb], [max_used_grant_kb], [min_ideal_grant_kb], [max_ideal_grant_kb], [min_reserved_threads], [max_reserved_threads], [min_used_threads], [max_used_threads], [total_spills], [min_spills], [max_spills]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', N'ci', 0x00, 1, 1, 1, 0x00, DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[query_stats_latest_hash] WHERE 1 = 1) INSERT INTO [collect].[query_stats_latest_hash] ([sql_handle], [statement_start_offset], [statement_end_offset], [plan_handle], [row_hash], [last_seen]) VALUES (0x00, 1, 1, 0x00, 0x00, DATEADD(MINUTE, -5, SYSDATETIME()));
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[query_store_data] WHERE 1 = 1) INSERT INTO [collect].[query_store_data] ([collection_time], [database_name], [query_id], [plan_id], [utc_first_execution_time], [utc_last_execution_time], [server_first_execution_time], [server_last_execution_time], [count_executions], [avg_duration], [min_duration], [max_duration], [avg_cpu_time], [min_cpu_time], [max_cpu_time], [avg_logical_io_reads], [min_logical_io_reads], [max_logical_io_reads], [avg_logical_io_writes], [min_logical_io_writes], [max_logical_io_writes], [avg_physical_io_reads], [min_physical_io_reads], [max_physical_io_reads], [avg_clr_time], [min_clr_time], [max_clr_time], [min_dop], [max_dop], [avg_query_max_used_memory], [min_query_max_used_memory], [max_query_max_used_memory], [avg_rowcount], [min_rowcount], [max_rowcount], [is_forced_plan]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', 1, 1, SYSDATETIMEOFFSET(), SYSDATETIMEOFFSET(), DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[query_store_data_latest_hash] WHERE 1 = 1) INSERT INTO [collect].[query_store_data_latest_hash] ([database_name], [query_id], [plan_id], [row_hash], [last_seen]) VALUES (N'ci', 1, 1, 0x00, DATEADD(MINUTE, -5, SYSDATETIME()));
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[running_jobs] WHERE 1 = 1) INSERT INTO [collect].[running_jobs] ([collection_time], [server_start_time], [job_name], [job_id], [job_enabled], [start_time], [current_duration_seconds], [is_running_long]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', NEWID(), 1, DATEADD(MINUTE, -5, SYSDATETIME()), 1, 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[server_properties] WHERE 1 = 1) INSERT INTO [collect].[server_properties] ([collection_time], [server_name], [edition], [product_version], [product_level], [engine_edition], [cpu_count], [hyperthread_ratio], [physical_memory_mb]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', N'ci', N'ci', N'ci', 1, 1, 1, 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[session_stats] WHERE 1 = 1) INSERT INTO [collect].[session_stats] ([collection_time], [total_sessions], [running_sessions], [sleeping_sessions], [background_sessions], [dormant_sessions], [idle_sessions_over_30min], [sessions_waiting_for_memory], [databases_with_connections]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), 1, 1, 1, 1, 1, 1, 1, 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[spinlock_stats] WHERE 1 = 1) INSERT INTO [collect].[spinlock_stats] ([collection_time], [server_start_time], [spinlock_name], [collisions], [spins], [spins_per_collision], [sleep_time], [backoffs]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', 1, 1, 1, 1, 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[tempdb_stats] WHERE 1 = 1) INSERT INTO [collect].[tempdb_stats] ([collection_time], [user_object_reserved_page_count], [internal_object_reserved_page_count], [version_store_reserved_page_count], [mixed_extent_page_count], [unallocated_extent_page_count], [total_sessions_using_tempdb], [sessions_with_user_objects], [sessions_with_internal_objects], [version_store_high_warning], [allocation_contention_warning]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), 1, 1, 1, 1, 1, 1, 1, 1, 1, 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[trace_analysis] WHERE 1 = 1) INSERT INTO [collect].[trace_analysis] ([collection_time], [trace_file_name], [event_class], [event_name]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', 1, N'ci');
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[wait_stats] WHERE 1 = 1) INSERT INTO [collect].[wait_stats] ([collection_time], [server_start_time], [wait_type], [waiting_tasks_count], [wait_time_ms], [signal_wait_time_ms]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', 1, 1, 1);
+IF NOT EXISTS (SELECT 1/0 FROM [collect].[waiting_tasks] WHERE 1 = 1) INSERT INTO [collect].[waiting_tasks] ([collection_time], [session_id], [wait_type], [wait_duration_ms], [blocking_session_id]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), 1, N'ci', 1, 1);
+IF NOT EXISTS (SELECT 1/0 FROM [config].[collection_log] WHERE 1 = 1) INSERT INTO [config].[collection_log] ([collection_time], [collector_name], [collection_status], [rows_collected], [duration_ms]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', N'ci', 1, 1);
+IF NOT EXISTS (SELECT 1/0 FROM [config].[collection_schedule] WHERE 1 = 1) INSERT INTO [config].[collection_schedule] ([collector_name], [enabled], [frequency_minutes], [max_duration_minutes], [retention_days], [collect_query], [collect_plan], [created_date], [modified_date]) VALUES (N'ci', 1, 1, 1, 1, 1, 1, DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()));
+IF NOT EXISTS (SELECT 1/0 FROM [config].[collector_database_exclusions] WHERE 1 = 1) INSERT INTO [config].[collector_database_exclusions] ([database_name], [excluded_at]) VALUES (N'ci', DATEADD(MINUTE, -5, SYSDATETIME()));
+IF NOT EXISTS (SELECT 1/0 FROM [config].[critical_issues] WHERE 1 = 1) INSERT INTO [config].[critical_issues] ([log_date], [severity], [problem_area], [source_collector], [message]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), N'CRITICAL', N'ci', N'ci', N'ci');
+IF NOT EXISTS (SELECT 1/0 FROM [config].[database_configuration_history] WHERE 1 = 1) INSERT INTO [config].[database_configuration_history] ([collection_time], [database_id], [database_name], [setting_type], [setting_name]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), 1, N'ci', N'ci', N'ci');
+IF NOT EXISTS (SELECT 1/0 FROM [config].[ignored_wait_types] WHERE 1 = 1) INSERT INTO [config].[ignored_wait_types] ([wait_type], [is_enabled], [created_date], [modified_date]) VALUES (N'ci', 1, DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()));
+IF NOT EXISTS (SELECT 1/0 FROM [config].[installation_history] WHERE 1 = 1) INSERT INTO [config].[installation_history] ([installation_date], [installer_version], [sql_server_version], [sql_server_edition], [installation_type], [installation_status]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', N'ci', N'ci', N'ci', N'ci');
+IF NOT EXISTS (SELECT 1/0 FROM [config].[server_configuration_history] WHERE 1 = 1) INSERT INTO [config].[server_configuration_history] ([collection_time], [configuration_id], [configuration_name], [is_dynamic], [is_advanced]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), 1, N'ci', 1, 1);
+IF NOT EXISTS (SELECT 1/0 FROM [config].[server_info_history] WHERE 1 = 1) INSERT INTO [config].[server_info_history] ([collection_time], [sqlserver_start_time], [server_name], [sql_version], [edition], [physical_memory_mb], [cpu_count], [environment_type]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), DATEADD(MINUTE, -5, SYSDATETIME()), N'ci', N'ci', N'ci', 1, 1, N'ci');
+IF NOT EXISTS (SELECT 1/0 FROM [config].[trace_flags_history] WHERE 1 = 1) INSERT INTO [config].[trace_flags_history] ([collection_time], [trace_flag], [status], [is_global], [is_session]) VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), 1, 1, 1, 1);
+
+GO
+
+/* ---------------- hand-authored history PAIRS for the LAG/toggle views ---------------- */
+
+/* Trace flag toggled OFF -> ON across two collections: report.trace_flag_changes' LAG path emits a
+ row, executing the bit -> status-text projection that regressed in #1635/#1660. */
+IF NOT EXISTS (SELECT 1/0 FROM config.trace_flags_history WHERE trace_flag = 8675)
+BEGIN
+ INSERT INTO config.trace_flags_history (collection_time, trace_flag, status, is_global, is_session)
+ VALUES (DATEADD(MINUTE, -10, SYSDATETIME()), 8675, 0, 1, 0);
+
+ INSERT INTO config.trace_flags_history (collection_time, trace_flag, status, is_global, is_session)
+ VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), 8675, 1, 1, 0);
+END;
+
+/* Server-level sp_configure value changed between collections (sql_variant old/new projection). */
+IF NOT EXISTS (SELECT 1/0 FROM config.server_configuration_history WHERE configuration_name = N'ci seed knob')
+BEGIN
+ INSERT INTO config.server_configuration_history (collection_time, configuration_id, configuration_name, value_configured, value_in_use, is_dynamic, is_advanced)
+ VALUES (DATEADD(MINUTE, -10, SYSDATETIME()), 999, N'ci seed knob', CONVERT(sql_variant, 0), CONVERT(sql_variant, 0), 1, 0);
+
+ INSERT INTO config.server_configuration_history (collection_time, configuration_id, configuration_name, value_configured, value_in_use, is_dynamic, is_advanced)
+ VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), 999, N'ci seed knob', CONVERT(sql_variant, 1), CONVERT(sql_variant, 1), 1, 0);
+END;
+
+/* Database-scoped setting changed between collections. */
+IF NOT EXISTS (SELECT 1/0 FROM config.database_configuration_history WHERE database_name = N'ci_seed_db')
+BEGIN
+ INSERT INTO config.database_configuration_history (collection_time, database_id, database_name, setting_type, setting_name, setting_value)
+ VALUES (DATEADD(MINUTE, -10, SYSDATETIME()), 999, N'ci_seed_db', 'SCOPED', N'ci seed setting', CONVERT(sql_variant, 0));
+
+ INSERT INTO config.database_configuration_history (collection_time, database_id, database_name, setting_type, setting_name, setting_value)
+ VALUES (DATEADD(MINUTE, -5, SYSDATETIME()), 999, N'ci_seed_db', 'SCOPED', N'ci seed setting', CONVERT(sql_variant, 1));
+END;
+GO
diff --git a/.github/workflows/sql-validation.yml b/.github/workflows/sql-validation.yml
index 42a4b9dfb..c6fc01fc7 100644
--- a/.github/workflows/sql-validation.yml
+++ b/.github/workflows/sql-validation.yml
@@ -13,6 +13,11 @@ jobs:
runs-on: ubuntu-latest
strategy:
fail-fast: false
+ # SQL Server 2016 is the documented minimum but cannot appear here: SQL Server on Linux
+ # begins at 2017, so no 2016 container image exists for the ubuntu service-container shape.
+ # 2016 gets the SAME install + seed + view-execution validation manually against the local
+ # SQL2016 box as part of release testing (last full pass: 2026-07-26, 13.0.6300 - install
+ # clean, 41 views executed, 0 failed).
matrix:
include:
- version: '2017'
@@ -77,3 +82,20 @@ jobs:
SA_PASSWORD: CI_Test#2026!
run: |
/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -i .github/sql/ci_validate_installation.sql
+
+ # Existence alone let two view bugs ship (#1635, #1666): a view can compile yet throw only
+ # when rows flow through its projection. Seed one representative row per source table
+ # (history tables get a correlated pair so LAG/toggle paths emit rows), then materialize
+ # every report.* view with SELECT * INTO - COUNT(*) would prune the projected columns and
+ # skip exactly the per-row conversions this exists to catch (#1669).
+ - name: Seed report view sources
+ env:
+ SA_PASSWORD: CI_Test#2026!
+ run: |
+ /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -i .github/sql/ci_seed_report_sources.sql
+
+ - name: Execute report views against seeded rows
+ env:
+ SA_PASSWORD: CI_Test#2026!
+ run: |
+ /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -i .github/sql/ci_execute_report_views.sql
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4bf905137..07332cdbc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- **CI: every `report.*` view now EXECUTES against seeded rows, not just exists** ([#1677]) - the SQL-validation workflow checked the report views by `OBJECT_ID` only, which let two bugs of the same class ship: #1635's per-row bit->nvarchar conversion error and #1666's binary(8) boxed raw into sql_variant - both compile fine and only misbehave when rows flow through the projection. A generated seed script now puts one representative row in every collect/config table after the fresh CI install (NOT NULL columns get type-appropriate values, nullable columns stay NULL so those branches run, compressed-LOB columns get real COMPRESS() payloads, and the change-history tables get correlated toggle PAIRS so the LAG-based views emit actual rows), and a sweep then materializes every `report.*` view with `SELECT * INTO` - COUNT(*) would let the optimizer prune the projection and skip exactly the per-row conversions the sweep exists to catch. Views are enumerated from `sys.views` at run time, so new views are covered the day they ship and the dynamically-built `report.query_snapshots` pair is simply absent on a fresh install rather than a hardcoded failure. Failures list every broken view with its error and fail the job. Detection power proven with a planted per-row Msg 245 view: green sweep without it, red with it. Runs across the whole 2017/2019/2022/2025 matrix; a regeneration dev-tool script rides along for future schema changes.
- **Connection alerts for servers that are already down, and re-alerts during a standing outage** ([#1674]) - closes #1659, the gap split out of #1535: connection alerts were pure edge detection, so an app or service that started while a server was already unreachable never announced the outage (no edge existed), and a standing outage produced exactly one alert however long it lasted - which silently broke the reporter's webhook-driven auto-heal loop the day the app restarted mid-outage. Two OPT-INS, both default-off so the classic one-alert-per-outage behavior is untouched: **alert at first sight** (announce a server already down on the first-ever observation) and **re-alert every N minutes while still down** (0 = off). Re-fires deliver under the SAME `Server Unreachable` metric name deliberately - webhook automation keyed on the metric re-triggers, which is the whole point - with the detail text marking the flavor (`Already unreachable when monitoring started` / `Still unreachable (re-alerting every N min)`). The decision is ONE shared definition (`ConnectionAlertPolicy` in PerformanceMonitor.Common, replacing Lite's `ConnectionEdgeDetector` and the inline machine in Darling's `DarlingSelfAlertEvaluator` - the `SqlErrorClassification` discipline, pinned from both test suites), and the two opt-ins interlock: even with the startup announcement off, re-fire alone re-announces after a mid-outage restart, because the re-baselined outage has no recorded down alert and is due immediately. The re-fire clock stamps on DELIVERY only, so an alert suppressed by the notify toggles never consumes the window. Lite: two settings beside the existing connection toggle (settings.json + Settings window). Darling: V33 store columns on `config_alert_settings` (read live like the V20 toggle; refire clamped 0-1440), editable from the viewer's Settings window; no ACL/provisioning change (the table carries table-level grants, no column carve).
- **Darling: `--configure-network` can now expose the WEB DASHBOARD** ([#1617]) - the wizard offered Store and MCP but not the web dashboard, even though `--enable-web`'s own output told operators to run `--configure-network` to expose it on the LAN - a dead end that forced hand-editing `web.network` into darling.json. Web is now a first-class third surface, fully symmetric with Store/MCP: its own menu choice (plus comma combinations like `1,3`, and `4` = all three), a keep-or-generate DPAPI access token, listen/CIDR inputs validated by the SAME bind resolver the web host fail-closes on (extracted as `ResolveWebBind`, the web twin of `ResolveMcpBind` - never a reimplementation), the comment-preserving `web.network` write, a one-time token print, and next-steps text including the browser login URL (`http://:/?token=...`, exchanged for a session cookie). Disable now removes all three network blocks. After the wizard, `--enable-web` opens the scoped firewall rule on the first try - no hand-editing required.
- **Darling Web: adaptive `auto` time bucket for composed time-series panels** ([#1619]) - a compose custom-view time-series panel can set `timeBucket: "auto"`, and the compiler resolves it to a concrete grain from the panel's window (minute up to 2 days, hour up to 60 days, day beyond) so any range from 1h to 90d renders a readable line and never trips the 5,000-bucket ceiling. Previously a fixed `hour` bucket collapsed a sub-hour workload (e.g. a 30-minute HammerDB run) into a single invisible point, while a fixed `minute` bucket errored past ~3.5 days. The composer now defaults new time-series panels to `auto` and the MCP `describe_custom_view_catalog` recommends it; non-auto buckets compile byte-for-byte as before. Pinned by `DarlingComposeTests` (auto boundary resolution + compile-by-window + non-auto passthrough).
@@ -576,6 +577,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#1668]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1668
[#1670]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1670
[#1675]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1675
+[#1677]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1677
[#1676]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1676
[#1640]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1640
[#1642]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1642