Repository navigation
Anchor a windowed read at a past incident, not only at now (#2495) - #2504
Conversation
| if (!DateTime.TryParse( | ||
| asOf, | ||
| CultureInfo.InvariantCulture, | ||
| DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, | ||
| out var parsed)) | ||
| { | ||
| return $"Invalid as_of value '{asOf}'. Expected an ISO-8601 UTC instant, e.g. '2026-08-18T14:30:00Z' or '2026-08-18'."; |
There was a problem hiding this comment.
Minor: DateTime.TryParse (vs. TryParseExact) is more lenient than the documented "ISO-8601" contract. With CultureInfo.InvariantCulture, ambiguous non-ISO strings like "08/18/2026" or "01/02/2026" parse silently as M/d/yyyy instead of being refused.
That's the same failure mode ResolveAsOf's own doc comment says this parameter exists to prevent — the caller gets an answer to a subtly different question than they asked, with no error (just one level removed from the "silently answers as now" case that is refused). It's also untested: the AnUnusableAnchor_IsRefused_NotSilentlyTreatedAsNow theory only covers "last tuesday", "2026-13-45", and "4 hours ago", not an ambiguous-but-parseable date.
Worth considering DateTime.TryParseExact against an explicit ISO-8601 pattern set (or DateTimeStyles.RoundtripKind + a format allowlist) to match the documented/tested contract exactly. Low severity — not a crash risk, and probably rare in practice for an LLM-driven caller — but worth a decision either way.
There was a problem hiding this comment.
Good catch, and the reasoning is right — fixed in de4ae1f.
The parser is now TryParseExact against an explicit ISO-8601 allowlist rather than a general TryParse:
"yyyy-MM-dd",
"yyyy-MM-ddTHH:mmK",
"yyyy-MM-ddTHH:mm:ssK",
"yyyy-MM-ddTHH:mm:ss.FFFFFFFK",
K covers the empty offset, Z, and ±HH:mm / ±HHmm, so one format serves all three documented shapes and the allowlist stays short enough to read.
On the severity: I think it is a bit higher than "minor", for the reason you gave — 01/02/2026 is not garbage that produces an error, it is a plausible value that produces a wrong window silently, which is the exact defect as_of exists to remove, one step removed. A refusal that names the working forms is strictly more useful than a guess.
Also deliberately NOT accepted now: a space in place of the T (2026-08-18 14:30:00). It is unambiguous, but the contract is "the documented forms", and the refusal message lists all four, so a caller who sends the space form is one round-trip from correct rather than one round-trip from a subtly different answer.
The theory gained the ambiguous cases you named plus the sibling shapes — 08/18/2026, 01/02/2026, 2026/08/18, 18 Aug 2026, 2026-08-18 14:30:00 — and the accepted-spellings theory gained T14:30Z, .000Z and a whitespace-padded value, so the allowlist is pinned from both directions. Lite's suite got the 01/02/2026 case too, since the parser is shared.
Verified locally against the shipped McpHelpers (the xUnit suites are net10.0-windows and cannot run on macOS): 29 assertions covering every accepted form, every refusal above, the round-trip "o" form both SKUs' tests emit, and the ISO basic-format offset +0200, which the allowlist accepts on purpose — it is unambiguous.
| /// The diff's upper edge. With no <c>as_of</c> the read is still open-ended | ||
| /// (<see cref="DateTime.MaxValue"/>) — byte-for-byte the pre-#2495 behaviour, and deliberately not | ||
| /// "now": these snapshots are stamped by the collector, so a capture whose clock ran a little ahead of | ||
| /// the service's would drop out of a window it belongs in. An anchored read bounds at the anchor, | ||
| /// because that is the whole point of sending one. | ||
| /// </summary> | ||
| private static DateTime UpperEdge(string? asOf, DateTime anchorNaiveUtc) => | ||
| string.IsNullOrWhiteSpace(asOf) ? DateTime.MaxValue : anchorNaiveUtc; |
There was a problem hiding this comment.
Pre-existing Lite/Darling parity gap, surfaced (not introduced) by this PR: UpperEdge deliberately leaves the diff's upper bound at DateTime.MaxValue when no as_of is given, and the doc comment above explains why — "these snapshots are stamped by the collector, so a capture whose clock ran a little ahead of the service's would drop out of a window it belongs in."
Lite's equivalent (LocalDataService.GetServerConfigChangesAsync/GetDatabaseConfigChangesAsync/GetTraceFlagChangesAsync in Lite/Services/LocalDataService.ConfigChanges.cs) calls the shared GetTimeRange(hoursBack, fromDate, toDate, asOfUtc), whose no-anchor case returns endTime = DateTime.UtcNow — not an open upper edge — and passes that straight into ConfigChangeDiff.Diff*Changes(snapshots, startTime, endTime). So on Lite, a config-snapshot capture whose clock ran slightly ahead of the service's is exactly the case this comment says Darling avoids, but Lite doesn't.
This divergence predates this PR (Lite's GetTimeRange returned DateTime.UtcNow as the upper bound before as_of existed too), so it's not a regression here — but this PR is the first place the reasoning against it gets written down, and it applies equally to Lite. Might be worth a follow-up issue if it's not already tracked.
There was a problem hiding this comment.
You are right that the two SKUs diverge here, and right that this PR is where it first got written down — but the justification I wrote for it is wrong, which is the more useful half of this finding. Corrected in de4ae1f.
capture_time is stamped DateTime.UtcNow by the collector, in the same process that later reads it (Lite's own header comment on LocalDataService.ConfigChanges.cs says so, and Darling's collector does the same). So the clock-skew story I told cannot happen on either SKU: a snapshot can never carry a timestamp after the reading call's own now. I asserted a protection the code does not provide, which is exactly the kind of doc claim that outlives the person who wrote it.
With the skew argument gone, the divergence is real but unobservable: DateTime.MaxValue and now cannot select different rows, because no row can sit between them. So there is nothing to fix and nothing worth filing — an issue that says "two reads use different upper bounds that always return the same set" would be noise, and unifying them inside this PR would mean changing two shipped reads' behaviour in a change whose whole contract is that an unanchored read does not change.
What each SKU keeps is its own prior behaviour, which is the compatibility rule applied consistently rather than a rule applied twice differently. The comment now says that, and says why the two differ, so the next reader gets the real answer instead of mine.
Review summaryReviewed the full diff (81 files) against Overall: solid, careful, well-tested implementation. I traced the
Two minor findings posted inline, both low severity:
No missing-index DMV suggestions, no security issues in input handling, and no performance regressions spotted — the added parameter is a pure pass-through with no new hot-path work. |
cab0514 to
516d366
Compare
| var invalidLimit = McpHelpers.ValidateTop(limit); | ||
| if (invalidLimit != null) return invalidLimit; | ||
|
|
||
| var anchorError = McpHelpers.ResolveAsOf(as_of, out var windowEnd); |
There was a problem hiding this comment.
Minor, pre-existing gap this PR had a natural chance to close while it was already touching every one of these signatures: GetCollectionLog, GetCurrentWaitsTrend (line ~1155), and GetBlockingStats (line ~1224) call McpHelpers.ResolveAsOf(as_of, out var windowEnd) directly instead of McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd). That means hours_back is never validated here (no negative/MaxHoursBack check) — it only gets Math.Abs()'d — unlike the ~50 sibling reads that now route through ValidateWindow. Same pattern on the Lite side (Lite/Mcp/McpHealthTools.cs lines 281, 359, 422).
This isn't a regression — these three methods never validated hours_back before this PR either — so it's not blocking. But since the PR's whole premise is "every windowed read validates its window the same way," these three are now the only reads left where as_of is refused/validated but hours_back isn't, which is a small, visible inconsistency in the new convention. Worth a follow-up to swap these onto ValidateWindow for free, since the return value is unused either way when there's no error.
There was a problem hiding this comment.
Right that it is an inconsistency, and right that it is pre-existing — but the swap is not free, and that is worth pinning down rather than deferring.
Those three reads have never capped hours_back. They Math.Abs() it and window on the result, with no MaxHoursBack check anywhere on the path (Darling: DarlingMcpDataTools lines 1079 / 1161 / 1230 straight into the reader; Lite: Math.Abs into GetRecentCollectionLogAsync → GetTimeRange, no clamp). So get_collection_log(hours_back: 400) works today and returns 400 hours.
Routing them through ValidateWindow would refuse that, because ValidateHoursBack caps at 168. That is a functional regression on the read this issue was reported against, whose entire premise is looking further back than the default — inside a change whose contract is that an unanchored call does not move. Consistency of the validator is not worth buying with reach on the one tool that needs it.
So: the anchor is validated because it is new and has no prior behaviour to preserve; the span keeps exactly the behaviour callers have. That asymmetry is the point rather than an oversight, and I have written it at all six sites in 525ac10 so the next reader meets the reasoning before the one-line cleanup rather than after it.
Not filing a follow-up, because the conclusion is "leave it": the issue would say "make three reads worse for symmetry". What WOULD be worth filing, if anyone wants it, is the opposite direction — lifting the 168-hour ceiling off the reads that do carry it, now that as_of makes a narrow window at an arbitrary point in history the natural way to ask a historical question. That is a real design question about MaxHoursBack, not a cleanup, and it belongs to whoever decides what the store promises to retain.
Review summaryThis is a large but mechanically consistent PR (as_of anchor added to ~50 Darling reads / ~46 Lite reads plus the shared web dispatch), and it holds up well under review. I checked the shared resolver ( Correctness
Parity
One minor, non-blocking finding (left as an inline comment): No security, performance, or parity-drift issues found beyond the note above. |
Every MCP read that takes hours_back measured it backwards from now, so no
caller could ask what a server was doing during a past incident. Widening
hours_back until the incident falls inside is not the same question: for an
aggregate read a wider window is a DIFFERENT answer, not the same answer with
more rows.
as_of is an optional ISO-8601 UTC anchor that moves the window's END off now
while hours_back stays its LENGTH. 52 Darling reads and 45 Lite reads carry it
with one shared name, description and validator; the web catalog and dispatch
carry it too, so /api/read/{name} reaches it as well, and a test pins the two
surfaces together in both directions.
Backward compatible by construction: the anchor defaults to now, and
ValidateWindow returns exactly what ValidateHoursBack returned when no anchor
is sent. An anchor we cannot use is REFUSED (unparseable, or in the future past
a clock-skew allowance) rather than quietly answered as of now. An anchor older
than anything the store holds is NOT refused -- retention is per-deployment, so
a floor would be a guess -- and comes back as the read's own empty/unavailable.
Three reads that bounded only their lower edge now bound both; for alert
history that is a correctness fix, since its cap is applied by the database.
Left unanchored, and said so in both instructions files: the analysis family
windows inside the analysis engine, and get_pvs_stats / get_fleet_overview mix
a latest-snapshot measurement with a windowed one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven parameter-contract pins failed for the right reason: `as_of` is now the last parameter on every windowed read, and three of them already carried a Darling-only trailing optional (`group_by`, `dedup_key`). Lite's list is therefore no longer a PREFIX of Darling's. The pins now assert what the prefix was standing in for -- every Lite name present in Lite's relative order, and every Darling-only extra optional -- which still fails on a drop, a reorder, or a required addition, and does not require relocating a shipped parameter to preserve a position no MCP caller can observe. Review catch: `DateTime.TryParse` accepted `01/02/2026` as `M/d/yyyy` under the invariant culture, so a caller who meant 1 February would have got a window around 2 January with nothing to say so -- the same defect one step removed from the silent fall back to "now" that this parameter exists to refuse. The parser is now `TryParseExact` against an ISO-8601 allowlist, and the refusal message names the forms that work. Also drops a doc claim the code does not support: the config-change diff's open upper edge was justified by collector clock skew, and `capture_time` is stamped by the same process that reads it, so it cannot happen. The real reason is that an unanchored read must not change, which is what the comment now says. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both catalog pins are of the form "no read is in set A but not set B", so a lookup that quietly returned false for a read it could not FIND would empty the failing set and make them pass for the wrong reason -- the exact shape of a coverage check that converts an open question into false confidence. The tool method is now asserted to exist rather than defaulted, and a partially loadable service assembly is reported rather than silently enumerated as whichever types happened to load. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rebasing onto dev picked up #2497's get_blocking_stats, which is a windowed read and took the anchor under the same rule as everything else. The changelog still said 52/45. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The MCP section told readers most tools take server_name and hours_back, which was the whole story until as_of existed. A parameter nobody knows about is one nobody sends. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review asked, reasonably, why get_collection_log / get_current_waits_trend / get_blocking_stats call ResolveAsOf rather than the shared ValidateWindow that ~50 sibling reads now use, leaving hours_back unvalidated on exactly three tools. Because the "free" swap is not free: those three have never capped hours_back — they Math.Abs() it — so ValidateWindow would impose the 168-hour ceiling every other read carries, and take reach away from the read whose whole premise is looking further back than the default. That is a functional regression on the tool this issue was reported against, inside a change that promises an unanchored call does not move. The reasoning now sits at all six sites, so the next reader gets it before the one-line "cleanup" rather than after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rebasing onto dev picked up get_query_store_regressions,
get_procedure_duration_trend, get_query_store_duration_trend and
get_health_parser_significant_waits. All four are windowed reads, so they take
as_of under the same rule as the rest -- on both SKUs, through the Lite service
methods behind them, and on the web catalog and dispatch.
Two of them needed a decision rather than a substitution:
- get_query_store_regressions hands its "what does zero regressions mean"
branch to a private helper that probes for a BEFORE. The anchor is threaded
into the helper rather than re-resolved there, because a window the probe
computed for itself would be a different window than the one it is
explaining.
- Lite's get_blocking_trend already pins one instant for its two reads, for
the reason its comment gives. That instant is now the anchor, so both reads
move onto the past window together instead of one of them.
Read counts are 57 Darling / 50 Lite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
525ac10 to
f5119c9
Compare
| var windowEnd = anchorEnd; | ||
| var windowStart = windowEnd.AddHours(-Math.Abs(hours_back)); | ||
|
|
||
| var points = await dataService.GetBlockingTrendAsync( |
There was a problem hiding this comment.
windowStart/windowEnd (already-correct UTC instants derived from the anchor) are passed positionally into GetBlockingTrendAsync's fromDate/toDate parameters. But GetTimeRange (LocalDataService.cs:117-123) treats fromDate/toDate as server-local whenever both are supplied, and subtracts ServerTimeHelper.UtcOffsetMinutes from them to convert back to UTC — even though these values are already UTC. The net effect: for any monitored server that isn't at UTC+0, the actual query window is shifted by the server's UTC offset, silently, in both the anchored and default (no as_of) case.
This PR added a proper asOfUtc parameter to GetBlockingTrendAsync (LocalDataService.Blocking.cs:668) specifically so callers route the anchor through GetTimeRange's UTC-safe branch instead of the server-local fromDate/toDate branch — the comment right above this call (lines 252-255) even explains the "one instant for both reads" discipline the anchor is meant to preserve. But the call site here never adopted it; it still uses the pre-existing fromDate/toDate positional pattern, so asOfUtc is dead code from this path and the anchor doesn't reach the query correctly.
This looks pre-existing (the double-offset conversion predates as_of), but it directly undermines this PR's own stated goal for this tool and is worth fixing alongside the dropped-anchor bugs — swap to dataService.GetBlockingTrendAsync(resolved.ServerId, hours_back, asOfUtc: windowEnd) the way ~48 other call sites in this PR do.
There was a problem hiding this comment.
All eight are real, all eight are fixed in 25d7279, and this is the most useful review pass on the PR — thank you.
Every one came from resolving the last rebase in dev's favour and then re-applying the convention with a script that checked the parameter was present rather than that the anchor arrived. They compiled, and they passed a suite that had just gone green on 5,600 tests. Exactly the seam-not-logic shape: the code on each side of the seam was individually correct.
On McpBlockingTools.cs:259 specifically — you are right that it is worse than a dropped anchor. windowStart/windowEnd were already UTC and GetTimeRange treats fromDate/toDate as server-local, subtracting ServerTimeHelper.UtcOffsetMinutes, so the window was shifted by the monitored server's offset in the unanchored case too. Both that read and its capture count now thread asOfUtc; the capture-count helpers (GetBlockingCaptureCountsAsync / GetDeadlockCaptureCountsAsync / the shared GetCaptureCountsAsync) learned the anchor so the "one instant for BOTH reads" discipline its comment describes survives — one value, both reads, UTC-safe branch.
The category, not the eight instances. Eight at once is not eight mistakes, so there is now a pin: an anchored tool must name no DateTime.UtcNow at all, must use the instant it resolved, and — on Lite — must pass asOfUtc to every LocalDataService read that accepts one (that set is read off the shipped source, not transcribed, and the scan asserts it found 40+ so it cannot pass by parsing nothing).
Worth recording how close that pin came to being useless. The first version asserted "does not ASSIGN DateTime.UtcNow", which is what my own detector had used. I reintroduced the get_file_io_trend bug to watch it go red and it stayed green — the body still contained the word windowEnd from its own out var, so the reaches-check was satisfied by the validation line alone. A coverage check that green-lights the defect it was written for is worse than none, because it converts an open question into confidence. It is an absolute now, and I reintroduced one bug of each form (recomputed-now, and dropped-on-the-call) and watched both fail before restoring.
On Lite.Tests/AsOfWindowAnchorTests.cs:203 — correct, and it is the same vacuity in miniature: at offset 0 a correct conversion and no conversion are indistinguishable, so the test could not fail for the reason its own comment gives. Taking that one now.
| var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); | ||
| if (hoursError != null) return hoursError; | ||
|
|
||
| var points = await dataService.GetFileIoLatencyTrendAsync(resolved.ServerId, hours_back); |
There was a problem hiding this comment.
as_of is validated into windowEnd (line 71) but never passed to GetFileIoLatencyTrendAsync — the overload already accepts asOfUtc (LocalDataService.FileIo.cs:71). get_file_io_trend silently ignores its as_of parameter and always answers as of now.
| var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); | ||
| if (hoursError != null) return hoursError; | ||
|
|
||
| var points = await dataService.GetMemoryTrendAsync(resolved.ServerId, hours_back); |
There was a problem hiding this comment.
Same class of bug as McpIoTools.cs:74 — windowEnd is computed but never passed to GetMemoryTrendAsync (which supports asOfUtc per LocalDataService.Memory.cs:70). get_memory_trend silently ignores as_of.
| var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); | ||
| if (hoursError != null) return hoursError; | ||
|
|
||
| var points = await dataService.GetQueryDurationTrendAsync(resolved.ServerId, hours_back); |
There was a problem hiding this comment.
Same class of bug — windowEnd is validated but never passed to GetQueryDurationTrendAsync (which supports asOfUtc per LocalDataService.QueryStats.cs:1111). get_query_duration_trend silently ignores as_of.
| var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); | ||
| if (hoursError != null) return hoursError; | ||
|
|
||
| var types = await dataService.GetDistinctWaitTypesAsync(resolved.ServerId, hours_back); |
There was a problem hiding this comment.
windowEnd is validated but never passed to GetDistinctWaitTypesAsync here — get_wait_types silently ignores as_of. Notably the same method is called correctly with asOfUtc: windowEnd at line ~131 in this file's GetWaitTrend fallback, so the anchor-aware call pattern was known and used elsewhere in this file — just missed here.
| /* sample_time is the monitored server's LOCAL wall clock, so the fixture is seeded in that base — | ||
| the read's window is server-local and the anchor arrives in UTC, and the point of the test is | ||
| that the conversion between them happens exactly once. */ | ||
| var offset = ServerTimeHelper.UtcOffsetMinutes; |
There was a problem hiding this comment.
Minor test-coverage gap: this reads whatever ServerTimeHelper.UtcOffsetMinutes currently is (a process-wide mutable static) rather than pinning a known non-zero value itself. SystemEventsReaderTests.cs is the only other place in this collection that sets it (to 330, then restores the saved value afterward), so absent a specific ordering guarantee this test most likely runs with offset 0 — at which point the server-local conversion this test's own doc-comment says it's meant to catch ("a bug here would shift the window by the monitored server's offset and still look plausible") can't actually be exercised: a correct implementation and one that skips the offset conversion entirely would produce identical results at offset 0. Consider setting a known non-zero offset (save/restore, same pattern as SystemEventsReaderTests) so this test can't pass vacuously.
| if (error != null) return error; | ||
|
|
||
| var validation = McpHelpers.ValidateHoursBack(hours_back); | ||
| var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); |
There was a problem hiding this comment.
windowEnd resolved here is never used — a few lines below, var now = DateTime.UtcNow; recomputes "now" from scratch instead of var now = windowEnd;, so get_blocking_trend silently ignores as_of and always answers as of the real current time. Same bug in GetDeadlockTrend just below (line 397 / var now = DateTime.UtcNow; under it). Every other windowed tool touched by this PR does var now = windowEnd; after ValidateWindow; these two are the outliers.
This is exactly the failure mode as_of exists to prevent — a caller anchoring at a past incident gets a silent, unlabeled "now" answer, and the validation succeeding gives false confidence the anchor was honored (an unparseable/future as_of is still correctly rejected inside ValidateWindow itself — it's only the successful resolution that gets thrown away).
The Lite twin has the identical bug in GetDeadlockTrend (Lite/Mcp/McpBlockingTools.cs:311, var dlWindowEnd = DateTime.UtcNow;), so this looks like a copy-paste omission that landed on both SKUs.
| if (error != null) return error; | ||
|
|
||
| var validation = McpHelpers.ValidateHoursBack(hours_back); | ||
| var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); |
There was a problem hiding this comment.
Same bug as GetBlockingTrend above (line 347): windowEnd from ValidateWindow here is discarded a few lines down in favor of DateTime.UtcNow, so get_deadlock_trend silently ignores as_of.
| try | ||
| { | ||
| var hoursError = McpHelpers.ValidateHoursBack(hours_back); | ||
| var hoursError = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd); |
There was a problem hiding this comment.
windowEnd resolved here is discarded a few lines down (var dlWindowEnd = DateTime.UtcNow;), so get_deadlock_trend silently ignores as_of and always answers as of real now. This is the Lite twin of the identical bug in Darling/.../DarlingMcpBlockingTools.cs GetDeadlockTrend. The fix right above it in this file's GetBlockingTrend (var windowEnd = anchorEnd;) shows the intended pattern — it just wasn't applied here.
|
Reviewed the
Real bugs found — the anchor is silently dropped or miscomputed for 8 tools across both SKUs, all confirmed by reading the actual source (not diff-only):
Left individual inline comments on each. Given the PR's whole premise is "an anchor that's silently not honored is worse than no anchor" (the parser refuses ambiguous input for exactly this reason), these 8 are worth fixing before merge — they're not edge cases, they're the tool's main path. Also flagged a minor test-coverage gap inline on Everything else checked out: no SQL injection (Darling's PostgreSQL queries are consistently parameterized via |
…tegory (#2495) Review found eight tools that advertised as_of, validated it, refused a bad one correctly -- and then computed their window from DateTime.UtcNow anyway. All eight came from resolving the last rebase in dev's favour, all eight compiled, and all eight passed a green suite. That is #2495's own defect one level up: the caller gets NOW labelled as their window, and the validation succeeding is what makes them believe it. Fixed: - Darling get_blocking_trend / get_deadlock_trend recomputed now, taking their denominator (the capture counts) with them. - Lite get_deadlock_trend did the same. - Lite get_blocking_trend passed already-UTC instants into fromDate/toDate, which GetTimeRange treats as SERVER-LOCAL and converts again -- so the window was shifted by the monitored server's offset, in the unanchored case too. Both it and its capture count now thread asOfUtc, which is the UTC-safe branch and still one instant for the pair. The capture-count helpers learned the anchor so that pairing stays possible. - Lite get_file_io_trend / get_memory_trend / get_query_duration_trend / get_wait_types validated the anchor and never handed it to the query. And pinned, because eight at once is a category rather than eight mistakes: an anchored tool must name no DateTime.UtcNow at all, must use the instant it resolved, and -- on Lite -- must pass asOfUtc to every service read that accepts one. The first form of that pin was written as "does not ASSIGN UtcNow" and PASSED against a reintroduced bug, because the body still contained the word windowEnd from its own `out var`; a check that green-lights the defect it exists for is worse than none, so it is an absolute now. Verified by reintroducing one bug of each form and watching it go red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It read whatever ServerTimeHelper.UtcOffsetMinutes happened to be, which on any normal machine is 0 -- and at 0 a correct server-local conversion and no conversion at all give identical results, so the test could not fail for the bug its own summary names. It now SETS UTC+5:30, saved and restored, the pattern SystemEventsReaderTests already uses; the half hour also catches an implementation that rounds to whole hours. Second vacuity found in this PR by the same question -- would this fail if the thing it guards were broken -- so it is worth saying plainly: a check that cannot go red is not weak evidence, it is confidence with nothing behind it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DocCommentHygieneTests caught it: inserting the new pin anchored on `private static bool ToolTakesAnAnchor(...)` put it BELOW that member's doc block, so the block ended up documenting the new test and ToolTakesAnAnchor carried none. The rule the repo already knows and I did not follow: anchor a doc-comment insert on the PREVIOUS member's closing brace, never on the next member's signature. Both blocks are intact; only the stranded one moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#2504 landed as_of across 57 reads while this branch was open. The heatmap is a windowed read, so it takes the anchor too — on the same shared McpHelpers.AsOfDescription constant as the rest, not a second convention. It earns the parameter more than most reads do. A heatmap IS a time axis, so "the four hours ending Tuesday 03:00" is the shape of every question anyone brings to it, and widening hours_back until an old incident falls inside is not the same question here even in the weak sense: the extra hours arrive as extra COLUMNS, which push the incident's own columns past the cell cap. Proved by CONTENT rather than by the parameter existing, on both SKUs. #2495's own failure mode is a tool that advertises as_of, validates it, refuses a bad anchor correctly and then queries now — eight of those shipped past a green suite during #2504 — so both tests seed rows outside every default window and assert the anchored call returns them while the same-LENGTH default-anchored call does not. Neither tool names DateTime.UtcNow, and Lite threads the resolved instant into GetQueryHeatmapCellsAsync and GetQueryHeatmapCoverageAsync rather than letting either compute its own. That also subsumes the window-skew fix from the previous commit: one resolved instant now decides the query window, the coverage probe's window and the window reported back. Conflicts, and how they were taken: - DarlingWebEndpoints catalog + dispatch: dev's as_of on the neighbouring reads, plus PAsOf() / as_of: AsOf(c) on get_query_heatmap. - Both instructions tables: dev's rows win (they carry as_of); the heatmap row is re-inserted in place with as_of appended. - The census sentence did not conflict, but it was RECOUNTED rather than trusted: scanning McpServerTool(Name = "...") gives 109 / 84 / 25, which is what it says. The uat-onboarding counts were re-derived the same way and are unchanged at 90 read endpoints, 109 tools, 69 reached by the per-server page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ngdata#2484) The last two of the ten viewer surfaces with no /api/read endpoint. Both readers already existed on both SKUs and nothing called either, so this is wiring rather than collection: GetDailySummaryRangeAsync had been sitting in DarlingHealthReader uncalled since it was written, and the viewer's LockWaitTrendSql had no port into the service at all. get_daily_summary_range is a SIBLING of get_daily_summary rather than a wider version of it, and the mechanical reasons decide it before the taste ones do. The single-day tool returns a flat object of scalars and a range returns rows, so one tool answering either shape depending on whether a span argument arrived would make every consumer branch on a parameter it may not have sent - the web stat tile would simply stop rendering. And a tab must not fetch one read twice (there is a pin for it), so the Overview tab's today tile and the month grid beside it CANNOT be the same read. They share the one aggregate underneath, which is what stops them ever disagreeing about a day. The two reads sit on opposite sides of the denominator question, and both are the PERIODIC case rather than the edge case erikdarlingdata#2508 corrected. - Lock waits read v_wait_stats, where the collector writes a row every cycle for every wait type it observes. The probe asks whether ANY wait sample exists and deliberately does NOT carry the read's own LIKE 'LCK%' filter: a server collected for months that never took a lock wait is precisely the all-clear this branch exists to give, and an LCK-filtered probe would report it as uncollected and send someone to fix collection that works. - The daily range probes v_collection_log, which is the day spine's own member - ANY run marks a day collected, which is why a quiet but monitored day comes back Healthy rather than absent. That is also what makes a HOLE in the returned days diagnostic: a missing day is a gap in collection, never a quiet one. Both take the erikdarlingdata#2504 anchor, and both are proven to use it BY CONTENT rather than by signature - rows seeded two days back come back when anchored there and the recent rows do not. The daily range anchors on the LAST DAY of the span, which needed its own shared description constant: the existing AsOfDescription names hours_back in its own text, and a parameter description naming a parameter the tool does not have is worse than a generic one, because the caller sends that key and it is ignored rather than rejected. Same contract, same resolver, same ceiling constant shared across the SKUs so neither can accept a span the other refuses. The lock-wait rate is a double the whole way down, and the test asserts a 3 ms delta over 60 seconds comes back as 0.05 rather than 0. That is erikdarlingdata#2507's defect one read over: a rate shipped as an integer reports a quiet server as an idle one. Counts in uat-onboarding §3.4 are re-derived from source rather than incremented - 92 read endpoints of 111 tools, 71 of them reached by the per-server page - as is the instructions census (111 / 86 shared / 25 Darling-only), recounted by scanning the McpServerTool attributes in both trees. Verified locally: the whole solution builds; a DOM shim built all twelve tabs at three time ranges with every panel a real node, and both new reads were actually FETCHED (the only two named-but-unfetched reads are get_wait_trend and get_perfmon_trend, which fetch on a picker by design). Test suites are net10.0-windows and CI is the arbiter for running them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #2495.
The convention, and whether I agree with option 2
I agree with option 2, and I'd go further: option 1 is not just wider, it's the wrong shape for this surface.
hours_backis not a window — it is half a window plus an implicitend = now. Option 2 doesn't add a knob so much as it makes the existing knob honest:hours_backis the SPAN,as_ofis where the span ends. Every read keeps exactly one time concept it already had, plus one anchor.Adding
from/towould have given ~100 reads three time parameters with a validity rule between them (to > from, and what doeshours_backmean when both are set?). That is three states to document, three to test, and one —from/to/hours_backall supplied — that has no defensible answer. And it buys almost nothing: the real question is always "N hours around a known time", whichas_ofanswers exactly.Option 3 I reject on the issue's own argument, which the tests now make concrete: widening
hours_backreturns a different aggregate, not a superset.So: every read that already takes a window takes
as_of.hours_backkeeps its meaning. Reads that take no window don't get one.What changed
Shared —
McpHelpers.ResolveAsOfparses the anchor,McpHelpers.ValidateWindow(hours_back, as_of, out end)replaces the bareValidateHoursBackat every windowed read, andMcpHelpers.AsOfDescriptionis the single description string both SKUs'[Description]attributes point at, so the two servers cannot drift into describing the same parameter differently.Reads that gained
as_of— 57 on Darling, 50 on Lite (the difference is the 8 Darling-only PostgreSQL reads, plus the knownget_blocking↔get_blocked_process_reportsnaming pair andget_cpu_scheduler_pressure, which is windowed on Lite and unwindowed on Darling — a divergence that predates this):The full list
get_active_queries,get_alert_history,get_blocked_process_reports(Lite) /get_blocking(Darling),get_blocked_process_xml,get_blocking_stats,get_blocking_trend,get_health_parser_significant_waits,get_procedure_duration_trend,get_query_store_duration_trend,get_query_store_regressions,get_collection_log,get_cpu_scheduler_pressure(Lite),get_cpu_utilization,get_current_waits_trend,get_database_config_changes,get_deadlock_detail,get_deadlock_trend,get_deadlocks,get_default_trace_events,get_file_io_trend,get_health_parser_cpu_tasks,get_health_parser_io_issues,get_health_parser_memory_broker,get_health_parser_memory_conditions,get_health_parser_memory_node_oom,get_health_parser_scheduler_issues,get_health_parser_severe_errors,get_health_parser_system_health,get_latch_stats,get_long_query_completions,get_memory_grants,get_memory_pressure_events,get_memory_trend,get_perfmon_trend,get_pg_autovacuum_health,get_pg_blocking,get_pg_io_stats,get_pg_replication_slots,get_pg_top_queries,get_pg_wait_stats,get_pg_wraparound_risk,get_pg_xmin_horizon,get_plan_cache_bloat,get_plan_corrections,get_query_duration_trend,get_query_store_top,get_query_trend,get_resource_semaphore,get_server_config_changes,get_spinlock_stats,get_tempdb_trend,get_top_procedures_by_cpu,get_top_queries_by_cpu,get_trace_flag_changes,get_wait_stats,get_wait_trend,get_wait_types,get_waiting_tasksReads left alone, and why. Two rules, both stated in the instructions and both pinned by a test that names the exclusions explicitly, so removing one from the list has to be deliberate:
No window, nothing to anchor.
get_memory_stats,get_file_io_stats,get_memory_clerks,get_index_usage,get_object_locking,get_table_index_sizes,get_database_sizes,get_server_properties,get_session_stats,get_running_jobs,get_collection_health,get_server_summary,get_ag_health,get_perfmon_stats,get_cpu_scheduler_pressure(Darling) and the configuration reads all return the newest row. Giving themas_ofwould mean turning a latest-row read into a windowed one, which is a different feature and a different answer.Half-windowed reads.
get_pvs_statsandget_fleet_overviewreturn a latest-snapshot measurement and a windowed one in the same payload — PVS's current size next to a trend; the fleet cards' current CPU/memory/threads next to windowed blocking and deadlock counts. Anchoring only the windowed half returns a result whose two halves describe different instants, which is worse than not offering the parameter.get_plan_correctionslooks like this case and isn't: its unwindowed component is per-databaseFORCE_LAST_GOOD_PLANconfiguration, not a measurement, in the same class as the CPU countget_top_queries_by_cpureads for its attribution denominator.The analysis family —
analyze_server,get_analysis_facts,compare_analysis,get_analysis_findings— computes its window inside the analysis engine from a barehours_back, so anchoring it is an analysis-engine change on a shared seam, not a read-surface one;analyze_serveralso persists what it finds. Deferred rather than dropped: filed as The analysis family cannot be anchored at a past window, and it is the one an incident starts with #2506, including the persistence decision that needs making rather than coding.Web surface.
PAsOf()on all 57 Darling descriptors andas_of: AsOf(c)in all 57 dispatch lambdas. Two details worth review:dedup_keyon the blocking reads,group_byonget_top_queries_by_cpu), and a positional append would have bound the anchor to the wrong one;The wire key is
as_ofon both surfaces — no alias, soServerPageTabsTests' canonical-time-key rule is untouched.Three reads that bounded only their lower edge now bound both:
get_cpu_utilization,get_tempdb_trend,get_alert_history(and their Lite twins). For alert history this is a correctness fix independent of the anchor — its row cap is applied by the database, so trimming after the read would have spent the wholeLIMITon rows newer than the anchor and returned an empty window that looks exactly like a quiet one. The two SQL constants renumber; their pins moved with them.For an unanchored call the closed edge is
now, which is what every other windowed read on the surface already used — so this makes those three consistent rather than special. The only rows it can exclude are ones written between the tool computingnowand the query running, which is the semantics the rest of the surface has always had.Lite plumbing.
LocalDataService.GetTimeRangeandGetTimeRangeServerLocaltake an optionalasOfUtc, threaded through the 48 windowed methods the MCP reads call. Deliberately not routed through the existingfromDate/toDatepair: those are server-local (converted back to UTC inside the helper) while the anchor is UTC, so reusing them would silently shift every window by the monitored server's offset. The two helpers keep their own time bases and both take the anchor in UTC.Backward compatibility
ValidateWindow(h, null, out _)is asserted equal toValidateHoursBack(h)across{-1, 0, 1, 24, 168, 169}, so a caller that sends no anchor gets the same validation and the same message.DateTime.UtcNow, i.e. every existing call site computes the identical window.as_ofis appended LAST on every signature — the conventiongroup_by(Per-query CPU attribution unusable on a plan-churning instance: reads see 18 execs where Datadog sees 43% of the box; literal fragmentation defeats top-N #2235) anddedup_key([FEATURE] Let the MCP incident readers filter by the alert fingerprint (Dedup Key) #2159) already set — so no shipped parameter moved.One consequence worth naming rather than burying: for the four tools that carry a Darling-only trailing optional (
get_top_queries_by_cpu,get_blocking,get_deadlocks,get_deadlock_detail), Lite's parameter list is no longer a prefix of Darling's — Darling reads…, min_dop, group_by, as_ofwhile Lite reads…, min_dop, as_of. Three pins asserted that prefix, and I widened them to what the prefix was standing in for: every Lite name present in Lite's relative order, and every Darling-only extra optional. That is the guarantee those pins were written for ("a client written against Lite's contract still calls Darling correctly") and it still fails on a drop, a reorder, or a required Darling-only addition. It is not positional, and does not need to be: MCP invokes by name, and the only C# call sites — the/api/readdispatch — do not pass Darling's extra optionals at all. The alternative was relocating two already-shipped parameters to preserve a position no caller can observe.Refusal semantics
Following
ValidateTop— report, don't substitute.The parser is an ISO-8601 allowlist (
TryParseExact), not a general date parse. That was a review catch and a good one:DateTime.TryParseunder the invariant culture also accepts01/02/2026asM/d/yyyy, so a caller who meant 1 February would have got a window around 2 January silently — the same defect as the fall-back-to-now this parameter exists to remove, one step removed. A space in place of theTis refused for the same reason: the message names the four forms that work, which is more useful than a fifth guess.2026-08-19T05:00:00Z,...T05:00:00,...+02:00,2026-08-19empty/unavailableThat last row is the one I'd most like a second opinion on. A floor would have to guess at retention, which is per-deployment, per-server and per-collector, and would refuse legitimate reads on a long-retention store. The honest answer is the miss vocabulary, and it is unambiguous because the caller chose the window. The span stays bounded by
ValidateHoursBack; only the anchor is free.Tests
The pin that matters most, added after review found eight tools that advertised
as_of, validated it, refused a bad one correctly — and then computed their window fromDateTime.UtcNowanyway. All eight came from resolving a rebase indev's favour; all eight compiled and passed a green 5,600-test suite. That is this issue's own defect one level up: the caller gets NOW labelled as their window, and the validation succeeding is what makes them believe it. SoEveryToolThatTakesTheAnchor_ActuallyUsesItasserts, over the shipped source of BOTH SKUs, that an anchored tool names noDateTime.UtcNowat all, uses the instant it resolved, and (Lite) passesasOfUtcto everyLocalDataServiceread that accepts one — that set read off source, not transcribed, with a floor assertion so the scan cannot pass by parsing nothing.Worth recording how close that pin came to being useless. Its first form asserted "does not ASSIGN
DateTime.UtcNow". I reintroduced theget_file_io_trendbug to watch it go red and it stayed green — the body still contained the wordwindowEndfrom its ownout var. A check that green-lights the defect it was written for converts an open question into confidence, so it is an absolute now, and I reintroduced one bug of each form and watched both fail before restoring.Darling.Tests/AsOfWindowAnchorTests.csValidateHoursBackequivalence above);[Collection("live-postgres")],DARLING_TEST_PG, cleanup throughLiveStoreCleanup.RunAsync: the test that matters — wait_stats seeded 30 hours back, plus a recent row with a different wait type. The default read returns the recent type and not the incident; anchored at the incident the same read returns the incident and not the recent one; a widened 48-hour window returns both, with a different top-N, which is the issue's argument as an assertion rather than a claim. Thenget_collection_log(where the gap was noticed) anchored and unanchored, both refusals, and the ancient-anchorunavailable.Lite.Tests/AsOfWindowAnchorTests.cs— the same shape against a real DuckDB through the real tool methods, seeded under the derived server id (RemoteCollectorService.GetDeterministicHashCode(GetServerNameForStorage(server))), covering both window families:get_wait_statsfor the UTC helper andget_cpu_utilizationfor the server-local one. That second one sets UTC+5:30 rather than reading the ambient offset — review caught that at offset 0 (which is what any normal machine gives) a correct conversion and no conversion are indistinguishable, so it would have passed vacuously for exactly the bug its summary names. It joins the existingserver-time-helpercollection because that static is process-wide.What I could not verify
net10.0-windows). Everything below builds clean, including a full solution build; CI is the arbiter for execution. I did verify the resolver's arithmetic locally by running the shippedMcpHelpersfrom a throwawaynet10.0console harness — 18 assertions, all passing — which covers the parse/refusal/window logic but not the plumbing, and the plumbing is where this kind of change breaks.emptyis unambiguous — but a shared payload field would make it self-describing. That would touch ~100 serialization sites and change every response shape, so I left it; happy to file it if you want it.get_plan_corrections) — pre-existing, not introduced here. They are covered by the new section's rule ("every tool below that takeshours_backalso takesas_of") rather than by a row.get_collection_log,get_current_waits_trend,get_blocking_statscallResolveAsOfrather thanValidateWindow. Raised in review and deliberate: those three have never cappedhours_back(theyMath.Abs()it, noMaxHoursBackon the path), so the "free" swap would impose the 168-hour ceiling every other read carries and take reach away from the read this issue was reported against. The asymmetry is now commented at all six sites so the next reader meets the reasoning before the one-line cleanup.get_cpu_scheduler_pressurestill diverges between SKUs — windowed on Lite, unwindowed on Darling. I anchored the Lite one because it has a window; I did not add a window to Darling's, which would be a separate behaviour change.dev, which added five windowed reads while this was open:get_blocking_stats(Serve blocking severity, and share the deadlock aggregation (#2484) #2497), thenget_query_store_regressions,get_procedure_duration_trend,get_query_store_duration_trendandget_health_parser_significant_waits(Four more reads that could not say which nothing they had found (#2485) #2505). All five took the anchor under the same rule as everything else. Two needed a decision rather than a substitution, and are worth a look in the diff:get_query_store_regressionsthreads the anchor into the private helper that explains an empty result (a window the probe computed for itself would be a different window than the one it is explaining), and Lite'sget_blocking_trendalready pinned one instant for its two reads — that instant is now the anchor, so both reads move together.🤖 Generated with Claude Code