diff --git a/Darling/Darling.Tests/McpToolGuideHeads.Latch.cs b/Darling/Darling.Tests/McpToolGuideHeads.Latch.cs
new file mode 100644
index 0000000000..aedc40fb41
--- /dev/null
+++ b/Darling/Darling.Tests/McpToolGuideHeads.Latch.cs
@@ -0,0 +1,89 @@
+/*
+ * 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 PerformanceMonitor.Common;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #3898 D3 head pins for get_latch_stats (wave E, lane e4). Darling ranks the TOP latch classes by a
+/// window total; Lite pages the newest SNAPSHOT. That is a real behavior difference (D6), so the shared head
+/// states both in one clause rather than picking one product's framing. Follows the pattern in
+/// . Lite's twin is Lite.Tests/McpToolGuideHeadsLatchTests.
+///
+public sealed class McpToolGuideHeadsLatchTests
+{
+ private static readonly string[] ConvertedTools = ["get_latch_stats"];
+
+ /// The per-tool guardrail phrases the shared head must state.
+ private static readonly string[] HeadFacts =
+ [
+ "Darling sums waits over the whole hours_back window and returns the top classes by that total",
+ "Lite: LATEST IS A TIME, only the newest snapshot within hours_back, paged to limit, heaviest last-interval wait first",
+ "interval_seconds, both per-second rates and (on Darling) severity come from the LATEST interval only",
+ "they are null, never 0 or LOW",
+ "Zero rows: not_collected on a non-SQL Server target, else unavailable, never empty",
+ ];
+
+ [Fact]
+ public void GetLatchStatsHead_CarriesEveryGuardrailFact_AndThePointer()
+ {
+ var served = McpToolGuideTests.Served("get_latch_stats");
+ Assert.NotNull(served.Tail);
+ Assert.EndsWith(McpToolGuide.GuidePointer, served.Served, StringComparison.Ordinal);
+ Assert.True(served.Served.Length <= 620, $"get_latch_stats: served head {served.Served.Length} is over the 620 target");
+ Assert.All(served.ParameterDescriptionLengths, p => Assert.True(p.Length <= 200, $"get_latch_stats.{p.Parameter}: {p.Length} > 200"));
+
+ foreach (var fact in HeadFacts)
+ {
+ Assert.Contains(fact, served.Served, StringComparison.Ordinal);
+ }
+ }
+
+ /// D6: the shared head names neither product's exclusive field (Darling's severity_banded_from
+ /// block, Lite's truncated/captured_at paging) — those stay in each product's own tail, unchanged, the way
+ /// list_servers keeps peer_fleets and engine_kind off its shared head.
+ [Fact]
+ public void SharedHead_KeepsPerProductFieldsOffIt_AndEachTailKeepsItsOwn()
+ {
+ var served = McpToolGuideTests.Served("get_latch_stats");
+ Assert.DoesNotContain("severity_banded_from", served.Served, StringComparison.Ordinal);
+ Assert.DoesNotContain("truncated", served.Served, StringComparison.Ordinal);
+ Assert.DoesNotContain("captured_at", served.Served, StringComparison.Ordinal);
+ Assert.Contains("severity_banded_from", served.Tail!, StringComparison.Ordinal);
+ }
+
+ /// Nothing dropped from Darling's original: every sentence the pre-#3898 description carried
+ /// (the two-clocks rule, the LOW-severity-beside-a-large-total note, the restart null list) rides verbatim
+ /// in the tail get_tool_guide serves. dropcheck.py is the formal check; this pins the sentences most
+ /// likely to be paraphrased by a future edit.
+ [Fact]
+ public void DarlingTail_KeepsEveryOriginalSentence()
+ {
+ var tail = McpToolGuideTests.Served("get_latch_stats").Tail!;
+ Assert.Contains("Gets top latch contention by class.", tail, StringComparison.Ordinal);
+ Assert.Contains("Shows latch waits, wait time, and per-second rates.", tail, StringComparison.Ordinal);
+ Assert.Contains("TWO CLOCKS PER ROW, NAMED: total_delta_* SUM every collection in the window", tail, StringComparison.Ordinal);
+ Assert.Contains("A LOW severity beside a large window total is a class that was hot earlier in the window and is quiet now, not a contradiction.", tail, StringComparison.Ordinal);
+ Assert.Contains("never a quiet 0.00 or a LOW banded from a zero nobody measured; the window totals beside them still stand.", tail, StringComparison.Ordinal);
+ }
+
+ /// The latch classes keep Microsoft's meaning (sys.dm_os_latch_stats): ACCESS_METHODS_DATASET_PARENT
+ /// synchronizes parallel operations, and FGCB_ADD_REMOVE covers file add, drop, grow and shrink. The old reading
+ /// of them as TempDB allocation contention must not come back, in the head or the tail.
+ [Fact]
+ public void LatchClasses_KeepMicrosoftsMeaning_NotATempDbReading()
+ {
+ var served = McpToolGuideTests.Served("get_latch_stats");
+ var whole = served.Served + " " + served.Tail;
+ Assert.DoesNotContain("TempDB allocation contention", whole, StringComparison.Ordinal);
+ Assert.Contains("ACCESS_METHODS_DATASET_PARENT synchronizes child dataset access to the parent dataset during parallel operations, and FGCB_ADD_REMOVE synchronizes", served.Tail!, StringComparison.Ordinal);
+ }
+}
diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpLatchSpinlockTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpLatchSpinlockTools.txt
index 008190584e..5d9840a744 100644
--- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpLatchSpinlockTools.txt
+++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpLatchSpinlockTools.txt
@@ -1,5 +1,5 @@
# DarlingMcpLatchSpinlockTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks.
-tool get_latch_stats 1027
+tool get_latch_stats 549
param get_latch_stats.as_of 167
param get_latch_stats.hours_back 37
param get_latch_stats.server_name 28
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLatchSpinlockTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLatchSpinlockTools.cs
index 86608b9e26..9d91931768 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLatchSpinlockTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLatchSpinlockTools.cs
@@ -53,7 +53,7 @@ namespace PerformanceMonitor.Darling.Service.Mcp;
[McpServerToolType]
public sealed class DarlingMcpLatchSpinlockTools
{
- [McpServerTool(Name = "get_latch_stats"), Description("Gets top latch contention by class. Shows latch waits, wait time, and per-second rates. High LATCH_EX on ACCESS_METHODS_DATASET_PARENT or FGCB_ADD_REMOVE indicates TempDB allocation contention. TWO CLOCKS PER ROW, NAMED: total_delta_* SUM every collection in the window; severity, waits_per_second and wait_ms_per_second are banded/derived from the LATEST interval only - interval_seconds is the seconds that interval accrued over, and the severity_banded_from block names it in full (its delta wait, the same seconds, and the collection it ended at, which is also latest_collection_time). A LOW severity beside a large window total is a class that was hot earlier in the window and is quiet now, not a contradiction. When the latest interval was unknowable (a restart or first sample, stored as 0), interval_seconds, both per-second rates, severity and severity_banded_from.delta_wait_time_ms are all null - unknowable, never a quiet 0.00 or a LOW banded from a zero nobody measured; the window totals beside them still stand.")]
+ [McpServerTool(Name = "get_latch_stats"), Description("Latch contention by class. Darling sums waits over the whole hours_back window and returns the top classes by that total. Lite: LATEST IS A TIME, only the newest snapshot within hours_back, paged to limit, heaviest last-interval wait first. interval_seconds, both per-second rates and (on Darling) severity come from the LATEST interval only; on a restart or first sample (interval unknowable, stored 0) they are null, never 0 or LOW. Zero rows: not_collected on a non-SQL Server target, else unavailable, never empty. <> Gets top latch contention by class. Shows latch waits, wait time, and per-second rates. ACCESS_METHODS_DATASET_PARENT synchronizes child dataset access to the parent dataset during parallel operations, and FGCB_ADD_REMOVE synchronizes filegroup add, drop, grow and shrink file operations. TWO CLOCKS PER ROW, NAMED: total_delta_* SUM every collection in the window; severity, waits_per_second and wait_ms_per_second are banded/derived from the LATEST interval only - interval_seconds is the seconds that interval accrued over, and the severity_banded_from block names it in full (its delta wait, the same seconds, and the collection it ended at, which is also latest_collection_time). A LOW severity beside a large window total is a class that was hot earlier in the window and is quiet now, not a contradiction. When the latest interval was unknowable (a restart or first sample, stored as 0), interval_seconds, both per-second rates, severity and severity_banded_from.delta_wait_time_ms are all null - unknowable, never a quiet 0.00 or a LOW banded from a zero nobody measured; the window totals beside them still stand.")]
public static async Task GetLatchStats(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
diff --git a/Lite.Tests/McpToolGuideHeads.Latch.cs b/Lite.Tests/McpToolGuideHeads.Latch.cs
new file mode 100644
index 0000000000..9dc3280e53
--- /dev/null
+++ b/Lite.Tests/McpToolGuideHeads.Latch.cs
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using PerformanceMonitor.Common;
+using Xunit;
+
+namespace Lite.Tests;
+
+///
+/// #3898 D3 head pins for get_latch_stats's Lite twin (wave E, lane e4). Darling's twin is
+/// Darling.Tests/McpToolGuideHeadsLatchTests, which also holds the cross-SKU lockstep pin.
+///
+public sealed class McpToolGuideHeadsLatchTests
+{
+ private static readonly string[] HeadFacts =
+ [
+ "Darling sums waits over the whole hours_back window and returns the top classes by that total",
+ "Lite: LATEST IS A TIME, only the newest snapshot within hours_back, paged to limit, heaviest last-interval wait first",
+ "interval_seconds, both per-second rates and (on Darling) severity come from the LATEST interval only",
+ "they are null, never 0 or LOW",
+ "Zero rows: not_collected on a non-SQL Server target, else unavailable, never empty",
+ ];
+
+ [Fact]
+ public void GetLatchStatsHead_CarriesEveryGuardrailFact_AndThePointer()
+ {
+ var served = McpToolGuideTests.Served("get_latch_stats");
+ Assert.NotNull(served.Tail);
+ Assert.EndsWith(McpToolGuide.GuidePointer, served.Served, StringComparison.Ordinal);
+ Assert.True(served.Served.Length <= 620, $"get_latch_stats: served head {served.Served.Length} is over the 620 target");
+ Assert.All(served.ParameterDescriptionLengths, p => Assert.True(p.Length <= 200, $"get_latch_stats.{p.Parameter}: {p.Length} > 200"));
+
+ foreach (var fact in HeadFacts)
+ {
+ Assert.Contains(fact, served.Served, StringComparison.Ordinal);
+ }
+ }
+
+ /// D6: the shared head names neither product's exclusive field. Lite's own paging fields
+ /// (truncated, captured_at) stay off the shared head and live only in Lite's tail; Darling's severity
+ /// block never appears here at all.
+ [Fact]
+ public void SharedHead_KeepsPerProductFieldsOffIt_AndLiteTailKeepsItsOwn()
+ {
+ var served = McpToolGuideTests.Served("get_latch_stats");
+ Assert.DoesNotContain("truncated", served.Served, StringComparison.Ordinal);
+ Assert.DoesNotContain("captured_at", served.Served, StringComparison.Ordinal);
+ Assert.DoesNotContain("severity_banded_from", served.Served, StringComparison.Ordinal);
+ Assert.Contains("truncated", served.Tail!, StringComparison.Ordinal);
+ Assert.Contains("captured_at", served.Tail!, StringComparison.Ordinal);
+ Assert.DoesNotContain("severity_banded_from", served.Tail!, StringComparison.Ordinal);
+ }
+
+ /// Nothing dropped from Lite's original: the LATEST-IS-A-TIME sentence, the page-bounded-by-limit
+ /// rule and the restart null list all ride verbatim in the tail get_tool_guide serves.
+ /// dropcheck.py is the formal check; this pins the sentences most likely to be paraphrased by a
+ /// future edit.
+ [Fact]
+ public void LiteTail_KeepsEveryOriginalSentence()
+ {
+ var tail = McpToolGuideTests.Served("get_latch_stats").Tail!;
+ Assert.Contains("Gets the latest latch-contention snapshot by latch class", tail, StringComparison.Ordinal);
+ Assert.Contains("LATEST IS A TIME: this is the newest snapshot found within hours_back of as_of, not an aggregate over those hours", tail, StringComparison.Ordinal);
+ Assert.Contains("THE PAGE IS BOUNDED BY limit: latches_returned is how many latch classes you got, heaviest last-interval wait first", tail, StringComparison.Ordinal);
+ Assert.Contains("null, never 0, so a restart cannot read as a quiet latch; interval_seconds is also null, with the deltas standing, on a row collected before the interval was stored.", tail, StringComparison.Ordinal);
+ }
+
+ /// The latch classes keep Microsoft's meaning (sys.dm_os_latch_stats): ACCESS_METHODS_DATASET_PARENT
+ /// synchronizes parallel operations, and every page latch reports under BUFFER. The old reading of them as
+ /// allocation contention "(often TempDB)" must not come back, in the head or the tail.
+ [Fact]
+ public void LatchClasses_KeepMicrosoftsMeaning_NotATempDbReading()
+ {
+ var served = McpToolGuideTests.Served("get_latch_stats");
+ var whole = served.Served + " " + served.Tail;
+ Assert.DoesNotContain("TempDB allocation contention", whole, StringComparison.Ordinal);
+ Assert.DoesNotContain("(often TempDB)", whole, StringComparison.Ordinal);
+ Assert.Contains("ACCESS_METHODS_DATASET_PARENT synchronizes child dataset access to the parent dataset during parallel operations, and every page latch reports under the one BUFFER class", served.Tail!, StringComparison.Ordinal);
+ }
+}
diff --git a/Lite.Tests/McpToolsListBudget/McpLatchSpinlockTools.txt b/Lite.Tests/McpToolsListBudget/McpLatchSpinlockTools.txt
index f30f90a480..99b5e019a9 100644
--- a/Lite.Tests/McpToolsListBudget/McpLatchSpinlockTools.txt
+++ b/Lite.Tests/McpToolsListBudget/McpLatchSpinlockTools.txt
@@ -1,5 +1,5 @@
# McpLatchSpinlockTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks.
-tool get_latch_stats 1372
+tool get_latch_stats 549
param get_latch_stats.as_of 167
param get_latch_stats.hours_back 63
param get_latch_stats.limit 194
diff --git a/Lite/Mcp/McpLatchSpinlockTools.cs b/Lite/Mcp/McpLatchSpinlockTools.cs
index eeea6ea7dc..87aee95f30 100644
--- a/Lite/Mcp/McpLatchSpinlockTools.cs
+++ b/Lite/Mcp/McpLatchSpinlockTools.cs
@@ -45,7 +45,7 @@ public sealed class McpLatchSpinlockTools
? Math.Round((double)measured / seconds, 2)
: null;
- [McpServerTool(Name = "get_latch_stats"), Description("Gets the latest latch-contention snapshot by latch class: cumulative waiting requests and wait time (with the max single wait) plus the last collection interval's delta waits. High LATCH_EX on ACCESS_METHODS_DATASET_PARENT or a page-latch class indicates allocation/page contention (often TempDB). LATEST IS A TIME: this is the newest snapshot found within hours_back of as_of, not an aggregate over those hours - captured_at is the instant it was collected and age_seconds its distance from the window's end. THE PAGE IS BOUNDED BY limit: latches_returned is how many latch classes you got, heaviest last-interval wait first, and truncated says the snapshot held more than limit - a sum over the page is a sum over the page, not over the server. Raise limit when truncated is true. interval_seconds is the seconds the deltas accrued over, and waits_per_second / wait_ms_per_second are the deltas over it - the same keys Darling's twin derives from its latest interval. On a restart / first-sample row (the interval they would have accrued over was unknowable, stored as 0) delta_waiting_requests_count, delta_wait_time_ms, avg_wait_ms_per_request, interval_seconds and both per-second rates are all null - null, never 0, so a restart cannot read as a quiet latch; interval_seconds is also null, with the deltas standing, on a row collected before the interval was stored.")]
+ [McpServerTool(Name = "get_latch_stats"), Description("Latch contention by class. Darling sums waits over the whole hours_back window and returns the top classes by that total. Lite: LATEST IS A TIME, only the newest snapshot within hours_back, paged to limit, heaviest last-interval wait first. interval_seconds, both per-second rates and (on Darling) severity come from the LATEST interval only; on a restart or first sample (interval unknowable, stored 0) they are null, never 0 or LOW. Zero rows: not_collected on a non-SQL Server target, else unavailable, never empty. <> Gets the latest latch-contention snapshot by latch class: cumulative waiting requests and wait time (with the max single wait) plus the last collection interval's delta waits. ACCESS_METHODS_DATASET_PARENT synchronizes child dataset access to the parent dataset during parallel operations, and every page latch reports under the one BUFFER class, where contention can mean hot pages or slow I/O. LATEST IS A TIME: this is the newest snapshot found within hours_back of as_of, not an aggregate over those hours - captured_at is the instant it was collected and age_seconds its distance from the window's end. THE PAGE IS BOUNDED BY limit: latches_returned is how many latch classes you got, heaviest last-interval wait first, and truncated says the snapshot held more than limit - a sum over the page is a sum over the page, not over the server. Raise limit when truncated is true. interval_seconds is the seconds the deltas accrued over, and waits_per_second / wait_ms_per_second are the deltas over it - the same keys Darling's twin derives from its latest interval. On a restart / first-sample row (the interval they would have accrued over was unknowable, stored as 0) delta_waiting_requests_count, delta_wait_time_ms, avg_wait_ms_per_request, interval_seconds and both per-second rates are all null - null, never 0, so a restart cannot read as a quiet latch; interval_seconds is also null, with the deltas standing, on a row collected before the interval was stored.")]
public static async Task GetLatchStats(
LocalDataService dataService,
ServerManager serverManager,
diff --git a/deprecated/Dashboard/Mcp/McpAnalysisTools.cs b/deprecated/Dashboard/Mcp/McpAnalysisTools.cs
index 8c60d18133..30dbd51f5f 100644
--- a/deprecated/Dashboard/Mcp/McpAnalysisTools.cs
+++ b/deprecated/Dashboard/Mcp/McpAnalysisTools.cs
@@ -622,7 +622,7 @@ internal static class ToolRecommendations
],
["LATCH_EX"] =
[
- new("get_latch_stats", "Check latch contention by class (ACCESS_METHODS_DATASET_PARENT / FGCB_ADD_REMOVE point at TempDB allocation contention)"),
+ new("get_latch_stats", "Check latch contention by class"),
new("get_tempdb_trend", "Check TempDB for allocation contention"),
new("get_wait_trend", "Track the latch contention trend", new() { ["wait_type"] = "LATCH_EX" })
],
diff --git a/deprecated/Dashboard/Mcp/McpLatchSpinlockTools.cs b/deprecated/Dashboard/Mcp/McpLatchSpinlockTools.cs
index 560d10f120..4fffe77c60 100644
--- a/deprecated/Dashboard/Mcp/McpLatchSpinlockTools.cs
+++ b/deprecated/Dashboard/Mcp/McpLatchSpinlockTools.cs
@@ -12,7 +12,7 @@ namespace PerformanceMonitorDashboard.Mcp;
[McpServerToolType]
public sealed class McpLatchSpinlockTools
{
- [McpServerTool(Name = "get_latch_stats"), Description("Gets top latch contention by class. Shows latch waits, wait time, and per-second rates. High LATCH_EX on ACCESS_METHODS_DATASET_PARENT or FGCB_ADD_REMOVE indicates TempDB allocation contention.")]
+ [McpServerTool(Name = "get_latch_stats"), Description("Gets top latch contention by class. Shows latch waits, wait time, and per-second rates. ACCESS_METHODS_DATASET_PARENT synchronizes child dataset access to the parent dataset during parallel operations, and FGCB_ADD_REMOVE synchronizes filegroup add, drop, grow and shrink file operations.")]
public static async Task GetLatchStats(
ServerManager serverManager,
DatabaseServiceRegistry registry,