diff --git a/Dashboard.Tests/RecommendationDeduperTests.cs b/Dashboard.Tests/RecommendationDeduperTests.cs
new file mode 100644
index 00000000..f493605b
--- /dev/null
+++ b/Dashboard.Tests/RecommendationDeduperTests.cs
@@ -0,0 +1,443 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using PerformanceMonitor.Analysis;
+using PerformanceMonitorDashboard.Models;
+using PerformanceMonitorDashboard.Services.Recommendations;
+using Xunit;
+
+namespace PerformanceMonitorDashboard.Tests;
+
+///
+/// WS1a coverage for the PURE Recommendations data layer (no DB): the cross-store de-dupe
+/// (C3) and canonical severity mapping. Every test synthesizes
+/// lists (or scalars) and calls the static / the
+/// mappers directly. The actual two-store read is NOT
+/// unit-tested here (it needs a live DB — see the PR's integration list).
+///
+public class RecommendationDeduperTests
+{
+ // ---- builders ---------------------------------------------------------
+
+ private static RecommendationItem EngineItem(
+ RecommendationSetting setting,
+ string? db,
+ double rawSeverity = 0.3,
+ string title = "engine")
+ {
+ var band = RecommendationDeduper.FromEngineSeverity(rawSeverity);
+ return new RecommendationItem
+ {
+ Source = RecommendationSource.Engine,
+ CanonicalSeverity = band,
+ RawSeverity = rawSeverity,
+ Database = db,
+ Title = title,
+ ProblemArea = "db_config",
+ Setting = setting,
+ StoryPathHash = "hash-" + title
+ };
+ }
+
+ private static RecommendationItem LegacyItem(
+ RecommendationSetting setting,
+ string? db,
+ CanonicalSeverity band = CanonicalSeverity.Warning,
+ string title = "legacy")
+ {
+ return new RecommendationItem
+ {
+ Source = RecommendationSource.Legacy,
+ CanonicalSeverity = band,
+ RawSeverity = RecommendationDeduper.LegacyRawSeverity(band),
+ Database = db,
+ Title = title,
+ ProblemArea = "Database Configuration",
+ Setting = setting
+ };
+ }
+
+ // ---- de-dupe: collisions ---------------------------------------------
+
+ [Fact]
+ public void Merge_AutoShrink_SameDb_BothStores_KeepsEngineOnly()
+ {
+ var engine = new[] { EngineItem(RecommendationSetting.AutoShrink, "MyDb") };
+ var legacy = new[] { LegacyItem(RecommendationSetting.AutoShrink, "MyDb") };
+
+ var result = RecommendationDeduper.Merge(engine, legacy);
+
+ var row = Assert.Single(result);
+ Assert.Equal(RecommendationSetting.AutoShrink, row.Setting);
+ Assert.Equal(RecommendationSource.Engine, row.Source);
+ }
+
+ [Fact]
+ public void Merge_AutoClose_SameDb_BothStores_KeepsEngineOnly()
+ {
+ var engine = new[] { EngineItem(RecommendationSetting.AutoClose, "MyDb") };
+ var legacy = new[] { LegacyItem(RecommendationSetting.AutoClose, "MyDb") };
+
+ var result = RecommendationDeduper.Merge(engine, legacy);
+
+ var row = Assert.Single(result);
+ Assert.Equal(RecommendationSetting.AutoClose, row.Setting);
+ Assert.Equal(RecommendationSource.Engine, row.Source);
+ }
+
+ [Fact]
+ public void Merge_QueryStore_SameDb_BothStores_KeepsLegacyOnly()
+ {
+ // Synthesize an engine QS row even though prod does not emit one today — the de-dupe
+ // must still prefer the legacy row for QueryStore (the engine has no QS Apply).
+ var engine = new[] { EngineItem(RecommendationSetting.QueryStore, "MyDb") };
+ var legacy = new[] { LegacyItem(RecommendationSetting.QueryStore, "MyDb") };
+
+ var result = RecommendationDeduper.Merge(engine, legacy);
+
+ var row = Assert.Single(result);
+ Assert.Equal(RecommendationSetting.QueryStore, row.Setting);
+ Assert.Equal(RecommendationSource.Legacy, row.Source);
+ }
+
+ [Fact]
+ public void Merge_SameSetting_DifferentDatabases_KeepsBoth()
+ {
+ var engine = new[] { EngineItem(RecommendationSetting.AutoShrink, "DbOne") };
+ var legacy = new[] { LegacyItem(RecommendationSetting.AutoShrink, "DbTwo") };
+
+ var result = RecommendationDeduper.Merge(engine, legacy);
+
+ Assert.Equal(2, result.Count);
+ Assert.Contains(result, r => r.Source == RecommendationSource.Engine && r.Database == "DbOne");
+ Assert.Contains(result, r => r.Source == RecommendationSource.Legacy && r.Database == "DbTwo");
+ }
+
+ [Fact]
+ public void Merge_DatabaseNameMatch_IsCaseAndWhitespaceInsensitive()
+ {
+ // " MyDb " (engine) vs "mydb" (legacy) must be treated as the SAME database.
+ var engine = new[] { EngineItem(RecommendationSetting.AutoShrink, " MyDb ") };
+ var legacy = new[] { LegacyItem(RecommendationSetting.AutoShrink, "mydb") };
+
+ var result = RecommendationDeduper.Merge(engine, legacy);
+
+ var row = Assert.Single(result);
+ Assert.Equal(RecommendationSource.Engine, row.Source);
+ }
+
+ // ---- de-dupe: pass-through (Setting=None) ----------------------------
+
+ [Fact]
+ public void Merge_NoneSettingRows_NeverDedupe()
+ {
+ // A memory-pressure legacy row (None) + an engine CPU finding (None) both pass through.
+ var legacyMemory = LegacyItem(RecommendationSetting.None, "MyDb",
+ band: CanonicalSeverity.Critical, title: "Memory Pressure");
+ var engineCpu = EngineItem(RecommendationSetting.None, "MyDb",
+ rawSeverity: 1.6, title: "High CPU");
+
+ var result = RecommendationDeduper.Merge(new[] { engineCpu }, new[] { legacyMemory });
+
+ Assert.Equal(2, result.Count);
+ Assert.Contains(result, r => r.Title == "Memory Pressure" && r.Source == RecommendationSource.Legacy);
+ Assert.Contains(result, r => r.Title == "High CPU" && r.Source == RecommendationSource.Engine);
+ }
+
+ [Fact]
+ public void Merge_TwoNoneRows_SameDb_AreNotCollapsed()
+ {
+ // Even with identical (db) the None setting must never key together.
+ var a = LegacyItem(RecommendationSetting.None, "MyDb", title: "A");
+ var b = LegacyItem(RecommendationSetting.None, "MyDb", title: "B");
+
+ var result = RecommendationDeduper.Merge(Array.Empty(), new[] { a, b });
+
+ Assert.Equal(2, result.Count);
+ }
+
+ [Fact]
+ public void Merge_NonOverlappingSettings_AllKept()
+ {
+ var engine = new[]
+ {
+ EngineItem(RecommendationSetting.Rcsi, "MyDb"),
+ EngineItem(RecommendationSetting.PageVerify, "MyDb"),
+ EngineItem(RecommendationSetting.AutoShrink, "MyDb")
+ };
+ var legacy = new[]
+ {
+ LegacyItem(RecommendationSetting.QueryStore, "MyDb")
+ };
+
+ var result = RecommendationDeduper.Merge(engine, legacy);
+
+ Assert.Equal(4, result.Count);
+ Assert.Contains(result, r => r.Setting == RecommendationSetting.Rcsi);
+ Assert.Contains(result, r => r.Setting == RecommendationSetting.PageVerify);
+ Assert.Contains(result, r => r.Setting == RecommendationSetting.AutoShrink && r.Source == RecommendationSource.Engine);
+ Assert.Contains(result, r => r.Setting == RecommendationSetting.QueryStore && r.Source == RecommendationSource.Legacy);
+ }
+
+ // ---- de-dupe: ordering ----------------------------------------------
+
+ [Fact]
+ public void Merge_SortsBySeverityDescending()
+ {
+ var info = EngineItem(RecommendationSetting.None, "DbA", rawSeverity: 0.3, title: "info");
+ var critical = EngineItem(RecommendationSetting.None, "DbB", rawSeverity: 1.8, title: "critical");
+ var warning = LegacyItem(RecommendationSetting.None, "DbC",
+ band: CanonicalSeverity.Warning, title: "warning");
+
+ var result = RecommendationDeduper.Merge(new[] { info, critical }, new[] { warning });
+
+ Assert.Equal(CanonicalSeverity.Critical, result[0].CanonicalSeverity);
+ Assert.Equal(CanonicalSeverity.Warning, result[1].CanonicalSeverity);
+ Assert.Equal(CanonicalSeverity.Info, result[2].CanonicalSeverity);
+ }
+
+ [Fact]
+ public void Merge_OrderIndependent_SameWinnerRegardlessOfInputOrder()
+ {
+ var engine = EngineItem(RecommendationSetting.AutoShrink, "MyDb");
+ var legacy = LegacyItem(RecommendationSetting.AutoShrink, "MyDb");
+
+ var forward = RecommendationDeduper.Merge(new[] { engine }, new[] { legacy });
+ var legacyOnly = RecommendationDeduper.Merge(Array.Empty(),
+ new[] { legacy }); // legacy alone -> legacy survives (no engine to prefer)
+
+ Assert.Equal(RecommendationSource.Engine, Assert.Single(forward).Source);
+ // With only the legacy row present, it must still surface (collision resolver falls back).
+ Assert.Equal(RecommendationSource.Legacy, Assert.Single(legacyOnly).Source);
+ }
+
+ [Fact]
+ public void Merge_EmptyInputs_ReturnsEmpty()
+ {
+ var result = RecommendationDeduper.Merge(
+ Array.Empty(), Array.Empty());
+ Assert.Empty(result);
+ }
+
+ // ---- canonical severity: engine boundaries ---------------------------
+
+ [Theory]
+ [InlineData(0.0, CanonicalSeverity.Info)]
+ [InlineData(0.74, CanonicalSeverity.Info)]
+ [InlineData(0.75, CanonicalSeverity.Warning)]
+ [InlineData(1.49, CanonicalSeverity.Warning)]
+ [InlineData(1.5, CanonicalSeverity.Critical)]
+ [InlineData(2.0, CanonicalSeverity.Critical)]
+ public void FromEngineSeverity_BoundaryCases(double severity, CanonicalSeverity expected)
+ {
+ Assert.Equal(expected, RecommendationDeduper.FromEngineSeverity(severity));
+ }
+
+ // ---- canonical severity: legacy text ---------------------------------
+
+ [Theory]
+ [InlineData("CRITICAL", CanonicalSeverity.Critical)]
+ [InlineData("critical", CanonicalSeverity.Critical)]
+ [InlineData(" WARNING ", CanonicalSeverity.Warning)]
+ [InlineData("INFO", CanonicalSeverity.Info)]
+ [InlineData("", CanonicalSeverity.Info)]
+ [InlineData(null, CanonicalSeverity.Info)]
+ [InlineData("something-unknown", CanonicalSeverity.Info)]
+ public void FromLegacySeverity_TextMapping(string? severity, CanonicalSeverity expected)
+ {
+ Assert.Equal(expected, RecommendationDeduper.FromLegacySeverity(severity));
+ }
+
+ // ---- mapper-side setting derivation (feeds the de-dupe) --------------
+
+ [Fact]
+ public void SettingFromAction_DbConfigAutoShrink_MapsAutoShrink()
+ {
+ var action = new RemediationAction(
+ "DB_CONFIG", "set", Array.Empty(),
+ new[] { new DbConfigTarget("MyDb", DbConfigSetting.AutoShrinkOff, "ON") });
+
+ Assert.Equal(RecommendationSetting.AutoShrink, RecommendationsReader.SettingFromAction(action));
+ }
+
+ [Fact]
+ public void SettingFromAction_DbConfigAutoClose_MapsAutoClose()
+ {
+ var action = new RemediationAction(
+ "DB_CONFIG", "set", Array.Empty(),
+ new[] { new DbConfigTarget("MyDb", DbConfigSetting.AutoCloseOff, "ON") });
+
+ Assert.Equal(RecommendationSetting.AutoClose, RecommendationsReader.SettingFromAction(action));
+ }
+
+ [Fact]
+ public void SettingFromAction_RcsiFactKey_MapsRcsi()
+ {
+ var action = new RemediationAction(
+ "RCSI", "set", Array.Empty(),
+ new[] { new DbConfigTarget("MyDb", DbConfigSetting.ReadCommittedSnapshotOn, "OFF") });
+
+ Assert.Equal(RecommendationSetting.Rcsi, RecommendationsReader.SettingFromAction(action));
+ }
+
+ [Fact]
+ public void SettingFromAction_ForcePlan_MapsNone()
+ {
+ var action = new RemediationAction(
+ "PLAN_REGRESSION", "force",
+ new[] { new ForcePlanTarget("MyDb", 1, 2) });
+
+ Assert.Equal(RecommendationSetting.None, RecommendationsReader.SettingFromAction(action));
+ }
+
+ [Fact]
+ public void SettingFromAction_Null_MapsNone()
+ {
+ Assert.Equal(RecommendationSetting.None, RecommendationsReader.SettingFromAction(null));
+ }
+
+ [Theory]
+ [InlineData("Database Configuration", "ALTER DATABASE [x] SET AUTO_SHRINK OFF;", RecommendationSetting.AutoShrink)]
+ [InlineData("Database Configuration", "ALTER DATABASE [x] SET AUTO_CLOSE OFF;", RecommendationSetting.AutoClose)]
+ [InlineData("Query Store Configuration", "ALTER DATABASE [x] SET QUERY_STORE = ON;", RecommendationSetting.QueryStore)]
+ [InlineData("Query Store Configuration", "ALTER DATABASE [x] SET QUERY_STORE (OPERATION_MODE = READ_WRITE);", RecommendationSetting.QueryStore)]
+ public void SettingFromLegacy_ConfigArea_ParsesAlterKeyword(
+ string problemArea, string investigateQuery, RecommendationSetting expected)
+ {
+ Assert.Equal(expected, RecommendationsReader.SettingFromLegacy(problemArea, investigateQuery));
+ }
+
+ [Fact]
+ public void SettingFromLegacy_NonConfigArea_MapsNone()
+ {
+ // A memory-pressure row whose SQL incidentally mentions a keyword must NOT de-dupe:
+ // only the two config problem-areas are eligible.
+ Assert.Equal(RecommendationSetting.None,
+ RecommendationsReader.SettingFromLegacy("Memory", "SELECT 'AUTO_SHRINK';"));
+ }
+
+ [Fact]
+ public void SettingFromLegacy_ConfigArea_NoKeyword_MapsNone()
+ {
+ Assert.Equal(RecommendationSetting.None,
+ RecommendationsReader.SettingFromLegacy("Database Configuration", "SELECT 1;"));
+ }
+
+ [Fact]
+ public void SettingFromLegacy_ConfigArea_NullSql_MapsNone()
+ {
+ Assert.Equal(RecommendationSetting.None,
+ RecommendationsReader.SettingFromLegacy("Database Configuration", null));
+ }
+
+ // ---- mapper-side copy-paste + full engine mapping --------------------
+
+ [Fact]
+ public void BuildCopyPasteFromAction_DbConfig_RendersAlterStatements()
+ {
+ var action = new RemediationAction(
+ "DB_CONFIG", "set", Array.Empty(),
+ new[]
+ {
+ new DbConfigTarget("My]Db", DbConfigSetting.AutoShrinkOff, "ON"),
+ new DbConfigTarget("My]Db", DbConfigSetting.AutoCloseOff, "ON")
+ });
+
+ var sql = RecommendationsReader.BuildCopyPasteFromAction(action);
+
+ Assert.NotNull(sql);
+ // Identifier bracket-doubling (QUOTENAME-equivalent) is applied.
+ Assert.Contains("ALTER DATABASE [My]]Db] SET AUTO_SHRINK OFF;", sql);
+ Assert.Contains("ALTER DATABASE [My]]Db] SET AUTO_CLOSE OFF;", sql);
+ }
+
+ [Fact]
+ public void BuildCopyPasteFromAction_ForcePlan_ReturnsNull()
+ {
+ var action = new RemediationAction(
+ "PLAN_REGRESSION", "force", new[] { new ForcePlanTarget("MyDb", 1, 2) });
+
+ Assert.Null(RecommendationsReader.BuildCopyPasteFromAction(action));
+ }
+
+ [Fact]
+ public void MapEngineFinding_DbConfig_CarriesSettingActionAndHash()
+ {
+ var action = new RemediationAction(
+ "DB_CONFIG", "set", Array.Empty(),
+ new[] { new DbConfigTarget("MyDb", DbConfigSetting.AutoShrinkOff, "ON") });
+
+ var finding = new AnalysisFinding
+ {
+ ServerId = 1,
+ ServerName = "S",
+ DatabaseName = "MyDb",
+ Severity = 0.3,
+ Category = "db_config",
+ StoryText = "AUTO_SHRINK is on",
+ RootFactKey = "DB_CONFIG",
+ StoryPathHash = "abc123",
+ Remediation = action
+ };
+
+ var item = RecommendationsReader.MapEngineFinding(finding);
+
+ Assert.Equal(RecommendationSource.Engine, item.Source);
+ Assert.Equal(RecommendationSetting.AutoShrink, item.Setting);
+ Assert.Equal(CanonicalSeverity.Info, item.CanonicalSeverity); // 0.3 -> Info
+ Assert.Same(action, item.Remediation);
+ Assert.Equal("abc123", item.StoryPathHash);
+ Assert.NotNull(item.CopyPasteSql);
+ Assert.Contains("AUTO_SHRINK OFF", item.CopyPasteSql);
+ }
+
+ [Fact]
+ public void MapLegacyIssue_MemoryPressure_IsNoneAndAdviseOnly()
+ {
+ var issue = new CriticalIssueItem
+ {
+ Severity = "CRITICAL",
+ ProblemArea = "Memory",
+ AffectedDatabase = string.Empty,
+ Message = "Memory pressure detected",
+ InvestigateQuery = "SELECT * FROM sys.dm_os_memory_clerks;"
+ };
+
+ var item = RecommendationsReader.MapLegacyIssue(issue);
+
+ Assert.Equal(RecommendationSource.Legacy, item.Source);
+ Assert.Equal(RecommendationSetting.None, item.Setting);
+ Assert.Equal(CanonicalSeverity.Critical, item.CanonicalSeverity);
+ Assert.Null(item.Remediation);
+ Assert.Null(item.StoryPathHash);
+ Assert.Null(item.Database); // empty AffectedDatabase -> null
+ Assert.Equal("Memory pressure detected", item.AdviceText);
+ Assert.Equal("SELECT * FROM sys.dm_os_memory_clerks;", item.CopyPasteSql);
+ }
+
+ [Fact]
+ public void MapLegacyIssue_DatabaseConfiguration_AutoShrink_DerivesSetting()
+ {
+ var issue = new CriticalIssueItem
+ {
+ Severity = "WARNING",
+ ProblemArea = "Database Configuration",
+ AffectedDatabase = "MyDb",
+ Message = "Auto shrink is enabled",
+ InvestigateQuery = "ALTER DATABASE [MyDb] SET AUTO_SHRINK OFF;"
+ };
+
+ var item = RecommendationsReader.MapLegacyIssue(issue);
+
+ Assert.Equal(RecommendationSetting.AutoShrink, item.Setting);
+ Assert.Equal("MyDb", item.Database);
+ }
+}
diff --git a/Dashboard/Services/Recommendations/RecommendationDeduper.cs b/Dashboard/Services/Recommendations/RecommendationDeduper.cs
new file mode 100644
index 00000000..960d1be4
--- /dev/null
+++ b/Dashboard/Services/Recommendations/RecommendationDeduper.cs
@@ -0,0 +1,195 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace PerformanceMonitorDashboard.Services.Recommendations
+{
+ ///
+ /// The PURE, database-free core of the Recommendations surface (C3): canonical severity
+ /// mapping + cross-store de-dupe. Every method here is a static function over already-
+ /// mapped lists (or scalars), so the merge/collision
+ /// rules can be unit-tested with synthesized rows — no DB, no UI. The
+ /// reads the two stores, maps each row to a
+ /// , then hands the two lists to .
+ ///
+ public static class RecommendationDeduper
+ {
+ ///
+ /// Maps an engine finding's double severity (0–~2.0) onto the canonical band.
+ /// Cutoffs (D1): >= 1.5 → Critical, >= 0.75 → Warning, else Info.
+ ///
+ public static CanonicalSeverity FromEngineSeverity(double severity)
+ {
+ if (severity >= 1.5)
+ return CanonicalSeverity.Critical;
+ if (severity >= 0.75)
+ return CanonicalSeverity.Warning;
+ return CanonicalSeverity.Info;
+ }
+
+ ///
+ /// Maps the legacy config.critical_issues text severity onto the canonical
+ /// band. CRITICAL → Critical, WARNING → Warning, anything else (INFO / unknown) →
+ /// Info. Case-insensitive; tolerates surrounding whitespace.
+ ///
+ public static CanonicalSeverity FromLegacySeverity(string? severity)
+ {
+ var s = severity?.Trim();
+ if (string.Equals(s, "CRITICAL", StringComparison.OrdinalIgnoreCase))
+ return CanonicalSeverity.Critical;
+ if (string.Equals(s, "WARNING", StringComparison.OrdinalIgnoreCase))
+ return CanonicalSeverity.Warning;
+ return CanonicalSeverity.Info;
+ }
+
+ ///
+ /// A synthesized raw-severity stand-in for a legacy row (which has no numeric score),
+ /// so the engine + legacy rows sort together within and across bands: Critical → 2.0,
+ /// Warning → 1.0, Info → 0.0. Used only for the secondary ordering.
+ ///
+ public static double LegacyRawSeverity(CanonicalSeverity band) => band switch
+ {
+ CanonicalSeverity.Critical => 2.0,
+ CanonicalSeverity.Warning => 1.0,
+ _ => 0.0
+ };
+
+ ///
+ /// Merges the engine + legacy mapped lists into one de-duped, severity-sorted list
+ /// (C3). PURE: no DB, no UI, no ordering dependence on the inputs beyond what the
+ /// collision rule dictates. The de-dupe key is
+ /// ((),
+ /// ). Rows whose
+ /// is
+ /// NEVER de-dupe (they are not per-DB config-setting recs) — every such row passes
+ /// through. For a colliding (db, setting) pair the winner is chosen by
+ /// ; the loser is dropped. The result is sorted by
+ /// canonical severity desc, then raw severity desc, then database, then title — a
+ /// total, deterministic order.
+ ///
+ public static List Merge(
+ IEnumerable engineItems,
+ IEnumerable legacyItems)
+ {
+ // Bucket the dedupe-eligible rows (Setting != None) by (db, setting); collect the
+ // pass-through rows (Setting == None) separately so they are never collapsed.
+ var byKey = new Dictionary<(string Db, RecommendationSetting Setting), RecommendationItem>();
+ var passthrough = new List();
+
+ // Engine rows first, then legacy: the collision resolver is explicit about which
+ // source wins per setting, so iteration order does not change the winner — but a
+ // stable, source-grouped traversal keeps the logic easy to reason about.
+ foreach (var item in Concat(engineItems, legacyItems))
+ {
+ if (item is null)
+ continue;
+
+ if (item.Setting == RecommendationSetting.None)
+ {
+ passthrough.Add(item);
+ continue;
+ }
+
+ var key = (NormalizeDatabase(item.Database), item.Setting);
+ if (!byKey.TryGetValue(key, out var existing))
+ {
+ byKey[key] = item;
+ continue;
+ }
+
+ // Same (db, setting) from both stores: keep the winner for this setting.
+ byKey[key] = ResolveCollision(existing, item);
+ }
+
+ var merged = new List(byKey.Count + passthrough.Count);
+ merged.AddRange(byKey.Values);
+ merged.AddRange(passthrough);
+
+ merged.Sort(CompareForDisplay);
+ return merged;
+ }
+
+ ///
+ /// Whether the ENGINE row wins a (db, setting) collision. AutoShrink/AutoClose →
+ /// engine wins (it is Apply-capable; the legacy row is advise-only). QueryStore →
+ /// legacy wins (the engine has no Query-Store finding/Apply today). PageVerify/Rcsi
+ /// are engine-only and cannot collide, but are treated as engine-wins for
+ /// completeness. never reaches here.
+ ///
+ public static bool PreferEngineFor(RecommendationSetting setting) => setting switch
+ {
+ RecommendationSetting.QueryStore => false,
+ _ => true
+ };
+
+ ///
+ /// Picks the surviving row for a (db, setting) collision per
+ /// . When the preferred source is present among the two
+ /// it wins; otherwise the other row survives (so a collision between two rows of the
+ /// non-preferred source — which should not occur in practice — still yields a row).
+ ///
+ private static RecommendationItem ResolveCollision(RecommendationItem a, RecommendationItem b)
+ {
+ var preferEngine = PreferEngineFor(a.Setting);
+ var wantSource = preferEngine ? RecommendationSource.Engine : RecommendationSource.Legacy;
+
+ if (a.Source == wantSource)
+ return a;
+ if (b.Source == wantSource)
+ return b;
+
+ // Neither row is the preferred source — keep the first-seen one deterministically.
+ return a;
+ }
+
+ ///
+ /// Normalizes a database name for the de-dupe key: trim + lower-invariant. Null/empty
+ /// collapse to "" so two server-scoped config rows for the same setting would still
+ /// key together (in practice config-setting recs are always per-database).
+ ///
+ public static string NormalizeDatabase(string? database) =>
+ (database ?? string.Empty).Trim().ToLowerInvariant();
+
+ ///
+ /// Total display order: canonical severity desc, then raw severity desc, then
+ /// database (ordinal, case-insensitive), then title — deterministic regardless of
+ /// input order.
+ ///
+ private static int CompareForDisplay(RecommendationItem x, RecommendationItem y)
+ {
+ // Higher band first (enum ordinals are Info
+ /// Concatenates two possibly-null sequences without allocating an intermediate array,
+ /// guarding either being null (the reader always passes non-null lists, but the pure
+ /// function tolerates either).
+ ///
+ private static IEnumerable Concat(
+ IEnumerable? first, IEnumerable? second) =>
+ (first ?? Enumerable.Empty())
+ .Concat(second ?? Enumerable.Empty());
+ }
+}
diff --git a/Dashboard/Services/Recommendations/RecommendationItem.cs b/Dashboard/Services/Recommendations/RecommendationItem.cs
new file mode 100644
index 00000000..39f99046
--- /dev/null
+++ b/Dashboard/Services/Recommendations/RecommendationItem.cs
@@ -0,0 +1,139 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using PerformanceMonitor.Analysis;
+
+namespace PerformanceMonitorDashboard.Services.Recommendations
+{
+ ///
+ /// Canonical, three-band severity for the unified Recommendations surface. The two
+ /// producers score on different scales (engine findings carry a double 0–~2.0;
+ /// the legacy config.critical_issues store carries the text CRITICAL/WARNING/INFO)
+ /// — both are mapped onto this single enum so one list can be sorted and rendered
+ /// consistently. The numeric ordinals are deliberately Critical > Warning > Info
+ /// so a descending sort on the enum matches a descending sort on severity.
+ ///
+ public enum CanonicalSeverity
+ {
+ Info = 0,
+ Warning = 1,
+ Critical = 2
+ }
+
+ ///
+ /// Which producer a came from.
+ /// = config.analysis_findings (the fact/story engine; may
+ /// carry an Apply-capable ). =
+ /// config.critical_issues (the frozen configuration-issues proc; advise +
+ /// copy-paste only, never Apply).
+ ///
+ public enum RecommendationSource
+ {
+ Engine,
+ Legacy
+ }
+
+ ///
+ /// The canonical de-dupe key's setting component (C3). A (normalized database, setting)
+ /// pair identifies the SAME underlying misconfiguration regardless of which producer
+ /// surfaced it, so the de-dupe can collapse a duplicate that both stores report.
+ /// means the row is NOT a per-database config-setting rec (memory
+ /// pressure, server-level MAXDOP/CTFP, dumps, plan/index advice, …) and therefore NEVER
+ /// de-dupes — every row passes through untouched.
+ ///
+ ///
+ /// Only , and
+ /// can collide across the two stores today. and
+ /// are engine-only (the legacy proc emits no per-DB rec for
+ /// them), so they get their own values for clarity but never actually collide.
+ ///
+ ///
+ public enum RecommendationSetting
+ {
+ None = 0,
+ AutoShrink,
+ AutoClose,
+ QueryStore,
+ Rcsi,
+ PageVerify
+ }
+
+ ///
+ /// A single unified Recommendations row — the view-model the surface renders. This is a
+ /// plain DTO with NO WPF dependency so the reader's mapping + the pure de-dupe function
+ /// over it are unit-testable without a UI or a database.
+ ///
+ ///
+ /// Built by from either an
+ /// () or a
+ /// legacy critical-issue row (). The
+ /// action is present ONLY for engine rows that carry one
+ /// (it drives Apply + the two-sided consent gate, wired in a later WS); legacy rows
+ /// are advise/copy-paste only and leave it null.
+ ///
+ ///
+ public sealed class RecommendationItem
+ {
+ /// The three-band severity used for sorting and the badge/icon.
+ public CanonicalSeverity CanonicalSeverity { get; set; }
+
+ ///
+ /// The raw severity used as the secondary sort within a band. Engine rows carry the
+ /// finding's double (0–~2.0); legacy rows carry a synthesized stand-in derived
+ /// from the text band (2.0 / 1.0 / 0.0) so the two scales sort together sensibly.
+ ///
+ public double RawSeverity { get; set; }
+
+ /// The affected database, or null/empty for a server-scoped rec.
+ public string? Database { get; set; }
+
+ /// The one-line title / problem area shown as the row heading.
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// The problem-area grouping (engine: category; legacy: problem_area).
+ /// Kept distinct from for grouping/filtering.
+ ///
+ public string ProblemArea { get; set; } = string.Empty;
+
+ ///
+ /// Operator-facing advice prose (engine: for the root fact;
+ /// legacy: the row's message). Null when no advice is available.
+ ///
+ public string? AdviceText { get; set; }
+
+ ///
+ /// Copy-paste-ready SQL the operator can run themselves (engine: the
+ /// ALTER DATABASE statements rebuilt from the persisted action's DB-config
+ /// targets; legacy: the row's investigate_query). Null when none applies.
+ ///
+ public string? CopyPasteSql { get; set; }
+
+ ///
+ /// The built, persisted remediation action for Apply (engine only; null for legacy
+ /// and for engine rows with no executable shape). Drives the in-app Apply button +
+ /// the informed-consent gate. Lives in PerformanceMonitor.Analysis.
+ ///
+ public RemediationAction? Remediation { get; set; }
+
+ /// Which producer this row came from.
+ public RecommendationSource Source { get; set; }
+
+ ///
+ /// The engine finding's story_path_hash, used to mute the pattern. Engine rows
+ /// only; null for legacy rows (the legacy store has no mute concept).
+ ///
+ public string? StoryPathHash { get; set; }
+
+ ///
+ /// The canonical de-dupe setting (or ). See
+ /// . Drives the (database, setting) de-dupe key.
+ ///
+ public RecommendationSetting Setting { get; set; }
+ }
+}
diff --git a/Dashboard/Services/Recommendations/RecommendationsReader.cs b/Dashboard/Services/Recommendations/RecommendationsReader.cs
new file mode 100644
index 00000000..539d5c0f
--- /dev/null
+++ b/Dashboard/Services/Recommendations/RecommendationsReader.cs
@@ -0,0 +1,290 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Threading.Tasks;
+using PerformanceMonitor.Analysis;
+using PerformanceMonitorDashboard.Analysis;
+using PerformanceMonitorDashboard.Models;
+
+namespace PerformanceMonitorDashboard.Services.Recommendations
+{
+ ///
+ /// The data layer for the unified Recommendations surface (WS1a). Reads BOTH producers
+ /// for a server — engine findings from config.analysis_findings (via the existing
+ /// ) and legacy config recs from
+ /// config.critical_issues (via the existing
+ /// ) — maps each row to a unified
+ /// , then de-dupes + sorts via the pure
+ /// . No new raw SQL is written for the reads; this class
+ /// reuses the two existing read methods so it inherits their parameterization.
+ ///
+ ///
+ /// Nothing renders here — this is the foundational data layer (the XAML control + tab
+ /// wiring are a later workstream). The per-row mappers are internal static so the
+ /// engine/legacy → setting + copy-paste mapping can be unit-tested directly.
+ ///
+ ///
+ public sealed class RecommendationsReader
+ {
+ private readonly DatabaseService _databaseService;
+ private readonly SqlServerFindingStore _findingStore;
+
+ /// The two legacy problem-areas that carry per-database config-setting recs.
+ internal const string ProblemAreaDatabaseConfiguration = "Database Configuration";
+ internal const string ProblemAreaQueryStoreConfiguration = "Query Store Configuration";
+
+ public RecommendationsReader(DatabaseService databaseService, SqlServerFindingStore findingStore)
+ {
+ _databaseService = databaseService ?? throw new ArgumentNullException(nameof(databaseService));
+ _findingStore = findingStore ?? throw new ArgumentNullException(nameof(findingStore));
+ }
+
+ ///
+ /// Reads both stores for the server, maps each row, de-dupes (C3), and returns the
+ /// unified list sorted by severity desc. is carried for
+ /// display only (the reads key on for findings and on the
+ /// connection's own server for the legacy store). bounds
+ /// both reads to the same window.
+ ///
+ public async Task> GetRecommendationsAsync(
+ int serverId, string serverName, int hoursBack = 24, int limit = 100)
+ {
+ var findings = await _findingStore.GetRecentFindingsAsync(serverId, hoursBack, limit);
+ var legacy = await _databaseService.GetCriticalIssuesAsync(hoursBack);
+
+ var engineItems = new List(findings.Count);
+ foreach (var finding in findings)
+ engineItems.Add(MapEngineFinding(finding));
+
+ var legacyItems = new List(legacy.Count);
+ foreach (var issue in legacy)
+ legacyItems.Add(MapLegacyIssue(issue));
+
+ return RecommendationDeduper.Merge(engineItems, legacyItems);
+ }
+
+ ///
+ /// Maps one engine to a .
+ /// Advice comes from for the root fact key; the de-dupe
+ /// and the copy-paste SQL come from the PERSISTED
+ /// action — the drill-down that the live
+ /// builders consume is ephemeral and is NOT returned by the store read, so the built
+ /// action (which IS persisted) is the authoritative source on read.
+ ///
+ internal static RecommendationItem MapEngineFinding(AnalysisFinding finding)
+ {
+ var band = RecommendationDeduper.FromEngineSeverity(finding.Severity);
+ var advice = FactAdvice.GetForFactKey(finding.RootFactKey);
+
+ return new RecommendationItem
+ {
+ Source = RecommendationSource.Engine,
+ CanonicalSeverity = band,
+ RawSeverity = finding.Severity,
+ Database = finding.DatabaseName,
+ Title = !string.IsNullOrEmpty(advice?.Headline) ? advice!.Headline : finding.StoryText,
+ ProblemArea = finding.Category,
+ AdviceText = ComposeEngineAdvice(advice),
+ CopyPasteSql = BuildCopyPasteFromAction(finding.Remediation),
+ Remediation = finding.Remediation,
+ StoryPathHash = finding.StoryPathHash,
+ Setting = SettingFromAction(finding.Remediation)
+ };
+ }
+
+ ///
+ /// Maps one legacy to a
+ /// . Advice is the row's message; copy-paste is
+ /// the row's investigate_query; the de-dupe setting is derived ONLY for the two
+ /// config problem-areas, from the investigate_query ALTER keyword (never the
+ /// free-text message). Every other legacy row gets
+ /// and is non-deduping pass-through.
+ ///
+ internal static RecommendationItem MapLegacyIssue(CriticalIssueItem issue)
+ {
+ var band = RecommendationDeduper.FromLegacySeverity(issue.Severity);
+ var database = string.IsNullOrEmpty(issue.AffectedDatabase) ? null : issue.AffectedDatabase;
+ var sql = string.IsNullOrEmpty(issue.InvestigateQuery) ? null : issue.InvestigateQuery;
+
+ return new RecommendationItem
+ {
+ Source = RecommendationSource.Legacy,
+ CanonicalSeverity = band,
+ RawSeverity = RecommendationDeduper.LegacyRawSeverity(band),
+ Database = database,
+ Title = issue.ProblemArea,
+ ProblemArea = issue.ProblemArea,
+ AdviceText = string.IsNullOrEmpty(issue.Message) ? null : issue.Message,
+ CopyPasteSql = sql,
+ Remediation = null, // legacy rows are advise/copy-paste only — never Apply
+ StoryPathHash = null, // the legacy store has no mute concept
+ Setting = SettingFromLegacy(issue.ProblemArea, sql)
+ };
+ }
+
+ ///
+ /// Derives the de-dupe for an ENGINE finding from
+ /// its persisted . RCSI is its own fact-key (and its own
+ /// setting); DB_CONFIG carries one or more s whose
+ /// maps to a setting per flagged option. Only the FIRST
+ /// flagged DB-config option contributes the key (a finding's database is single, and
+ /// AUTO_SHRINK/AUTO_CLOSE are the only cross-store collisions — distinct per-DB
+ /// findings keep distinct rows). Returns when
+ /// there is no action or no recognized config target (so non-config findings —
+ /// CPU/waits/etc. — never de-dupe).
+ ///
+ internal static RecommendationSetting SettingFromAction(RemediationAction? action)
+ {
+ if (action is null)
+ return RecommendationSetting.None;
+
+ // RCSI is routed through a distinct fact key with a single ReadCommittedSnapshotOn
+ // target; surface it as the Rcsi setting (engine-only — it never collides).
+ if (string.Equals(action.FactKey, "RCSI", StringComparison.Ordinal))
+ return RecommendationSetting.Rcsi;
+
+ if (action.DbConfigTargets is { Count: > 0 } targets)
+ {
+ foreach (var target in targets)
+ {
+ var setting = SettingFromDbConfig(target.Setting);
+ if (setting != RecommendationSetting.None)
+ return setting;
+ }
+ }
+
+ return RecommendationSetting.None;
+ }
+
+ ///
+ /// Maps a single to its canonical de-dupe
+ /// .
+ ///
+ internal static RecommendationSetting SettingFromDbConfig(DbConfigSetting setting) => setting switch
+ {
+ DbConfigSetting.AutoShrinkOff => RecommendationSetting.AutoShrink,
+ DbConfigSetting.AutoCloseOff => RecommendationSetting.AutoClose,
+ DbConfigSetting.PageVerifyChecksum => RecommendationSetting.PageVerify,
+ DbConfigSetting.ReadCommittedSnapshotOn => RecommendationSetting.Rcsi,
+ _ => RecommendationSetting.None
+ };
+
+ ///
+ /// Derives the de-dupe for a LEGACY row. ONLY the
+ /// two config problem-areas are eligible; for them the setting is read from the
+ /// investigate_query ALTER keyword (AUTO_SHRINK → AutoShrink, AUTO_CLOSE →
+ /// AutoClose, QUERY_STORE → QueryStore). The free-text message is never parsed.
+ /// Any other problem-area → (pass-through).
+ ///
+ internal static RecommendationSetting SettingFromLegacy(string? problemArea, string? investigateQuery)
+ {
+ var area = problemArea?.Trim();
+ bool isConfigArea =
+ string.Equals(area, ProblemAreaDatabaseConfiguration, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(area, ProblemAreaQueryStoreConfiguration, StringComparison.OrdinalIgnoreCase);
+
+ if (!isConfigArea || string.IsNullOrEmpty(investigateQuery))
+ return RecommendationSetting.None;
+
+ // Match on the ALTER keyword in the copy-paste SQL only. AUTO_SHRINK / AUTO_CLOSE
+ // are substrings of distinct keywords; QUERY_STORE covers both SET QUERY_STORE and
+ // ALTER DATABASE ... SET QUERY_STORE forms.
+ if (ContainsToken(investigateQuery, "AUTO_SHRINK"))
+ return RecommendationSetting.AutoShrink;
+ if (ContainsToken(investigateQuery, "AUTO_CLOSE"))
+ return RecommendationSetting.AutoClose;
+ if (ContainsToken(investigateQuery, "QUERY_STORE"))
+ return RecommendationSetting.QueryStore;
+
+ return RecommendationSetting.None;
+ }
+
+ ///
+ /// Rebuilds the copy-paste ALTER DATABASE statements for an engine row from the
+ /// PERSISTED action's DB-config targets (the live drill-down preview is not persisted).
+ /// Mirrors the executor's SET-clause literals exactly. Returns null when the action has
+ /// no DB-config targets (force-plan / clear-plan / null → no DB-config copy-paste here).
+ ///
+ internal static string? BuildCopyPasteFromAction(RemediationAction? action)
+ {
+ if (action?.DbConfigTargets is not { Count: > 0 } targets)
+ return null;
+
+ var sb = new StringBuilder();
+ foreach (var target in targets)
+ {
+ if (sb.Length > 0)
+ sb.AppendLine();
+ sb.Append(AlterStatementFor(target));
+ }
+
+ return sb.Length == 0 ? null : sb.ToString();
+ }
+
+ ///
+ /// One ALTER DATABASE [db] SET ...; statement for a persisted
+ /// . The bracketed identifier doubles any embedded
+ /// close-bracket (QUOTENAME-equivalent); the SET-clause literal is selected by the
+ /// hardcoded enum, matching the executor and FactRemediation's renderer.
+ ///
+ private static string AlterStatementFor(DbConfigTarget target)
+ {
+ var setClause = target.Setting switch
+ {
+ DbConfigSetting.AutoShrinkOff => "SET AUTO_SHRINK OFF",
+ DbConfigSetting.AutoCloseOff => "SET AUTO_CLOSE OFF",
+ DbConfigSetting.PageVerifyChecksum => "SET PAGE_VERIFY CHECKSUM",
+ DbConfigSetting.ReadCommittedSnapshotOn => "SET READ_COMMITTED_SNAPSHOT ON",
+ _ => null
+ };
+
+ if (setClause is null)
+ return string.Empty;
+
+ return $"ALTER DATABASE {QuoteName(target.Database)} {setClause};";
+ }
+
+ ///
+ /// Composes the engine advice prose from a block: the
+ /// remediation line, with the investigation line appended when present. Null when no
+ /// advice matched the root fact key.
+ ///
+ private static string? ComposeEngineAdvice(AdviceBlock? advice)
+ {
+ if (advice is null)
+ return null;
+
+ var sb = new StringBuilder();
+ if (!string.IsNullOrEmpty(advice.Remediation))
+ sb.Append(advice.Remediation);
+ if (!string.IsNullOrEmpty(advice.Investigation))
+ {
+ if (sb.Length > 0)
+ sb.Append(' ');
+ sb.Append(advice.Investigation);
+ }
+
+ return sb.Length == 0 ? null : sb.ToString();
+ }
+
+ ///
+ /// Case-insensitive substring test for an ALTER keyword in the copy-paste SQL.
+ ///
+ private static bool ContainsToken(string text, string token) =>
+ text.Contains(token, StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// QUOTENAME-equivalent: bracket an identifier, doubling any embedded close-bracket.
+ ///
+ private static string QuoteName(string identifier) =>
+ "[" + (identifier ?? string.Empty).Replace("]", "]]") + "]";
+ }
+}