Skip to content

Anchor a windowed read at a past incident, not only at now (#2495) - #2504

Merged
erikdarlingdata merged 10 commits into
devfrom
feat/2495-as-of
Aug 22, 2026
Merged

erikdarlingdata merged 10 commits into
devfrom
feat/2495-as-of

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Aug 22, 2026 •

Copy link
Copy Markdown
Owner

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_back is not a window — it is half a window plus an implicit end = now. Option 2 doesn't add a knob so much as it makes the existing knob honest: hours_back is the SPAN, as_of is where the span ends. Every read keeps exactly one time concept it already had, plus one anchor.

Adding from/to would have given ~100 reads three time parameters with a validity rule between them (to > from, and what does hours_back mean when both are set?). That is three states to document, three to test, and one — from/to/hours_back all supplied — that has no defensible answer. And it buys almost nothing: the real question is always "N hours around a known time", which as_of answers exactly.

Option 3 I reject on the issue's own argument, which the tests now make concrete: widening hours_back returns a different aggregate, not a superset.

So: every read that already takes a window takes as_of. hours_back keeps its meaning. Reads that take no window don't get one.

What changed

Shared — McpHelpers.ResolveAsOf parses the anchor, McpHelpers.ValidateWindow(hours_back, as_of, out end) replaces the bare ValidateHoursBack at every windowed read, and McpHelpers.AsOfDescription is 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 known get_blocking ↔ get_blocked_process_reports naming pair and get_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_tasks

Reads 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:

  1. 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 them as_of would mean turning a latest-row read into a windowed one, which is a different feature and a different answer.

  2. Half-windowed reads. get_pvs_stats and get_fleet_overview return 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_corrections looks like this case and isn't: its unwindowed component is per-database FORCE_LAST_GOOD_PLAN configuration, not a measurement, in the same class as the CPU count get_top_queries_by_cpu reads for its attribution denominator.

  3. The analysis family — analyze_server, get_analysis_facts, compare_analysis, get_analysis_findings — computes its window inside the analysis engine from a bare hours_back, so anchoring it is an analysis-engine change on a shared seam, not a read-surface one; analyze_server also 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 and as_of: AsOf(c) in all 57 dispatch lambdas. Two details worth review:

  • the dispatch argument is named, not positional — several tools have trailing optional parameters the dispatch omits (dedup_key on the blocking reads, group_by on get_top_queries_by_cpu), and a positional append would have bound the anchor to the wrong one;
  • the anchor is passed through to the tool unvalidated, so the parse and the refusal message live in one place and the bare string lands on the existing 400 mapping.

The wire key is as_of on both surfaces — no alias, so ServerPageTabsTests' 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 whole LIMIT on 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 computing now and the query running, which is the semantics the rest of the surface has always had.

Lite plumbing. LocalDataService.GetTimeRange and GetTimeRangeServerLocal take an optional asOfUtc, threaded through the 48 windowed methods the MCP reads call. Deliberately not routed through the existing fromDate/toDate pair: 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

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_of while 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/read dispatch — 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.TryParse under the invariant culture also accepts 01/02/2026 as M/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 the T is refused for the same reason: the message names the four forms that work, which is more useful than a fifth guess.

input behaviour
absent / blank window ends at now (unchanged)
2026-08-19T05:00:00Z, ...T05:00:00, ...+02:00, 2026-08-19 all resolve to one UTC instant; the offset-less form is read as UTC, never as the service host's local time
unparseable refused, naming the value
in the future beyond 5 minutes refused — a stored read cannot cover uncollected data
in the future within 5 minutes accepted; that is the client-clock allowance, not a grace period for asking about the future
older than anything retained accepted, returns the read's own empty / unavailable

That 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 from DateTime.UtcNow anyway. All eight came from resolving a rebase in dev'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. So EveryToolThatTakesTheAnchor_ActuallyUsesIt asserts, over the shipped source of BOTH SKUs, that an anchored tool names no DateTime.UtcNow at all, uses the instant it resolved, and (Lite) passes asOfUtc to every LocalDataService read 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 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. 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.cs

  • ungated: the resolver's contract (four spellings → one instant, refusals, no lower bound, span-before-anchor ordering, and the ValidateHoursBack equivalence above);
  • ungated: MCP ↔ catalog agreement in both directions, plus the convention pin — a windowed read with no anchor and not on the named exclusion list fails;
  • [Collection("live-postgres")], DARLING_TEST_PG, cleanup through LiveStoreCleanup.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. Then get_collection_log (where the gap was noticed) anchored and unanchored, both refusals, and the ancient-anchor unavailable.

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_stats for the UTC helper and get_cpu_utilization for 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 existing server-time-helper collection because that static is process-wide.

What I could not verify

  • The test suites do not run on macOS (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 shipped McpHelpers from a throwaway net10.0 console 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.
  • No live-Postgres or live-DuckDB run from here. The live assertions are written but have only ever executed in CI.
  • The empty result does not echo the resolved window. A caller who sent an anchor knows what they asked for, so empty is 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.
  • Nine tools have no instructions table row at all (the eight PostgreSQL reads and get_plan_corrections) — pre-existing, not introduced here. They are covered by the new section's rule ("every tool below that takes hours_back also takes as_of") rather than by a row.
  • Three reads validate the anchor but not the span — get_collection_log, get_current_waits_trend, get_blocking_stats call ResolveAsOf rather than ValidateWindow. Raised in review and deliberate: those three have never capped hours_back (they Math.Abs() it, no MaxHoursBack on 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_pressure still 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.
  • Rebased twice onto a moving dev, which added five windowed reads while this was open: get_blocking_stats (Serve blocking severity, and share the deadlock aggregation (#2484) #2497), then get_query_store_regressions, get_procedure_duration_trend, get_query_store_duration_trend and get_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_regressions threads 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's get_blocking_trend already pinned one instant for its two reads — that instant is now the anchor, so both reads move together.

🤖 Generated with Claude Code

Comment on lines +150 to +156
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'.";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +314 to +321
/// 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@claude

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown

Review summary

Reviewed the full diff (81 files) against CONTRIBUTING.md's conventions, correctness, Lite/Darling parity, and security. This is pure C# (no .sql touched), so the T-SQL style rules don't apply here.

Overall: solid, careful, well-tested implementation. I traced the as_of anchor end-to-end on both SKUs — validation (McpHelpers.ValidateWindow/ResolveAsOf), the ~97 tool signatures, the reader/service layers, the web dispatch, and both new test suites — plus ran four independent deep-dive passes (Darling SQL/reader plumbing, Lite DuckDB plumbing, Lite/Darling parity, and McpHelpers/tests/style). Specifically verified and found correct:

  • SQL injection: every new time bound is passed through parameterized queries (NpgsqlParameter/DuckDBParameter) on both backends — no string concatenation of as_of or any derived value into SQL text anywhere.
  • The three named lower-bound-only fixes (get_cpu_utilization, get_tempdb_trend, get_alert_history) now correctly bind both edges on both SKUs, with parameter ordinals correctly renumbered.
  • UTC vs. server-local handling: LocalDataService.GetTimeRangeServerLocal converts the UTC anchor to the server's local offset before use — it doesn't compare a UTC anchor against server-local timestamps directly, which would have silently shifted every window by the monitored server's offset.
  • Web dispatch: the new as_of argument is passed as a named argument (as_of: AsOf(c)) everywhere, not positional — so it can't accidentally bind to a trailing optional like dedup_key or group_by.
  • Backward compatibility: as_of is appended last on every signature, ValidateWindow(h, null, out _) is asserted equal to the old ValidateHoursBack(h), and the anchor defaults to DateTime.UtcNow.
  • Parity of the as_of rollout itself: all tools called out in the PR description carry as_of symmetrically on both SKUs, off one shared McpHelpers.AsOfDescription constant (verified as a real shared reference, not two hand-typed copies) — no new naming/behavior drift introduced.
  • Tests: both new AsOfWindowAnchorTests suites are genuine regression tests (seed before/after an incident window, assert content — not just row counts — differs between anchored/unanchored/widened reads), not smoke tests. The live-Postgres test skips gracefully without DARLING_TEST_PG and cleans up via LiveStoreCleanup.RunAsync in a finally.

Two minor findings posted inline, both low severity:

  1. McpHelpers.ResolveAsOf's use of DateTime.TryParse is more lenient than the documented "ISO-8601" contract — ambiguous non-ISO strings could parse silently instead of being refused, which cuts against the parameter's own "refuse, don't substitute" design goal.
  2. A pre-existing Lite/Darling divergence in how config-change diffs (get_server_config_changes etc.) bound their upper edge — surfaced (not introduced) by this PR's own new code comment on the Darling side, which doesn't apply to Lite's equivalent path.

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.

var invalidLimit = McpHelpers.ValidateTop(limit);
if (invalidLimit != null) return invalidLimit;

var anchorError = McpHelpers.ResolveAsOf(as_of, out var windowEnd);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@claude

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown

Review summary

This 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 (McpHelpers.ResolveAsOf/ValidateWindow/AsOfDescription), the Darling web dispatch (DarlingWebEndpoints.cs), a representative cross-section of Mcp tool files on both SKUs (blocking, wait, query, CPU, health, session, config-history, PG family), the Lite LocalDataService time-range plumbing, and both McpInstructions.cs files, then had a second independent pass sweep every remaining touched Mcp/LocalDataService file for the same checklist. No T-SQL was touched by this PR (it's all C#), so the T-SQL style conventions don't apply here.

Correctness

  • The ISO-8601 allowlist parser (TryParseExact with 4 explicit formats) correctly rejects ambiguous slash-delimited dates instead of silently misparsing them (01/02/2026 would otherwise resolve to the wrong date under DateTime.TryParse) — good catch already baked in.
  • AssumeUniversal | AdjustToUniversal correctly reads an offset-less timestamp as UTC rather than the service host's local time, which is the right call for a UTC-only store read by remote agents.
  • The 5-minute future-tolerance window is a sensible clock-skew allowance, not a loophole for asking about uncollected data.
  • Every place I checked that reassigns var now = windowEnd (or end = windowEnd) uses that single value consistently for both the start and end of the query window — no half-anchored windows.
  • SQL parameter renumbering (Postgres $1,$2,... and DuckDB positional params) is correct everywhere a new window-end bind parameter was inserted ahead of existing trailing parameters (e.g. LocalDataService.WaitingTasks.cs's BuildDbInClause shifting from $3 to $4).
  • All as_of values are parsed to DateTime before ever reaching SQL and are otherwise passed through fully parameterized queries (Npgsql $n / DuckDB $n) — no injection surface. The web dispatch's AsOf(context) passes the raw string through unvalidated by design (the tool owns the refusal message), which is safe since it's parsed by the same strict allowlist before use.

Parity

  • as_of is appended as the last parameter in every signature I checked, on both SKUs, preserving the documented "Lite parameter list is a prefix of Darling's" invariant for the four tools with Darling-only trailing optionals.
  • The exclusion list (analyze_server, get_analysis_facts, get_analysis_findings, compare_analysis, get_pvs_stats, get_fleet_overview, get_store_metrics, and all latest-snapshot-only reads) is correctly excluded from as_of in the dispatch/catalog, and the documented Darling-only divergence on get_cpu_scheduler_pressure (windowed on Lite, not on Darling) is real and intentional, not an oversight.
  • Both McpInstructions.cs files document the same set of tools with as_of that the code actually implements it for.

One minor, non-blocking finding (left as an inline comment): get_collection_log, get_current_waits_trend, and get_blocking_stats — on both Darling and Lite — call ResolveAsOf directly instead of the new ValidateWindow, so hours_back still isn't validated on these three reads (pre-existing gap, not a regression, but this PR had a natural opportunity to fold them in since it touches all three signatures anyway).

No security, performance, or parity-drift issues found beyond the note above.

erikdarlingdata and others added 7 commits August 22, 2026 09:51
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>
var windowEnd = anchorEnd;
var windowStart = windowEnd.AddHours(-Math.Abs(hours_back));

var points = await dataService.GetBlockingTrendAsync(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread Lite/Mcp/McpIoTools.cs Outdated
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread Lite/Mcp/McpMemoryTools.cs Outdated
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread Lite/Mcp/McpQueryTools.cs Outdated
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread Lite/Mcp/McpWaitTools.cs Outdated
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread Lite.Tests/AsOfWindowAnchorTests.cs Outdated
/* 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@claude

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown

Reviewed the as_of anchor plumbing across the shared McpHelpers, both SKUs' MCP tool/reader layers, the Darling web catalog/dispatch, and both new test suites (used parallel review passes, then verified every claim directly against the checked-out source before posting).

PerformanceMonitor.Common/Mcp/McpHelpers.cs (the shared core both SKUs depend on) holds up well under adversarial scrutiny: the future-skew boundary is exact integer-tick arithmetic, the date-only form is forced to UTC midnight (AssumeUniversal/AdjustToUniversal, never host-local), the ambiguous-format allowlist genuinely can't match a space-for-T substitution, ValidateWindow(h, null, …) is provably equivalent to the old ValidateHoursBack(h), null/empty/whitespace as_of are all treated as "omitted," and there's no mutable shared state to race on.

DarlingWebEndpoints.cs / DarlingMcpInstructions.cs: catalog descriptors and dispatch wiring match 1:1 (57/57), every dispatch call uses the anchor as a named argument (verified — no positional binding risk for the tools with trailing optionals like dedup_key/group_by), and the instructions doc's exclusion list matches reality.

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):

  • get_blocking_trend and get_deadlock_trend on Darling (DarlingMcpBlockingTools.cs:347/397) validate and resolve as_of into windowEnd, then throw it away — var now = DateTime.UtcNow; a few lines down recomputes real "now" instead of using the resolved anchor.
  • get_deadlock_trend on Lite (McpBlockingTools.cs:307) has the identical bug — looks like the same copy-paste omission landed on both SKUs.
  • get_blocking_trend on Lite (McpBlockingTools.cs:259) does use the anchor, but routes it through GetBlockingTrendAsync's legacy fromDate/toDate positional parameters instead of the new asOfUtc parameter this PR added to that method. GetTimeRange treats supplied fromDate/toDate as server-local and subtracts UtcOffsetMinutes to convert to UTC — but the values passed in are already UTC, so the window is silently shifted by the monitored server's UTC offset for both anchored and default calls. Looks pre-existing (the positional-passing pattern predates as_of), but it defeats the anchor for this tool and is exactly the UTC/local mixup this PR's own design notes for LocalDataService.GetTimeRange/GetTimeRangeServerLocal say was deliberately avoided elsewhere.
  • get_file_io_trend, get_memory_trend, get_query_duration_trend, get_wait_types on Lite (McpIoTools.cs:74, McpMemoryTools.cs:66, McpQueryTools.cs:411, McpWaitTools.cs:77) all validate as_of into windowEnd and then never pass it to the underlying LocalDataService call, even though every one of those methods already accepts asOfUtc. McpWaitTools.cs even calls the same method correctly with asOfUtc: windowEnd a few lines later in GetWaitTrend's fallback, so the right pattern was known — just missed on these four.

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 Lite.Tests/AsOfWindowAnchorTests.cs:203 — TheAnchorAlsoMoves_AServerLocalWindow reads the ambient ServerTimeHelper.UtcOffsetMinutes rather than pinning a known non-zero value, so it likely runs at offset 0 in CI, where it can't actually distinguish a correct server-local conversion from a broken one.

Everything else checked out: no SQL injection (Darling's PostgreSQL queries are consistently parameterized via AddTimestamp/AddWindow, and no DuckDB query concatenates the anchor into SQL text), no window-math sign errors anywhere else, the three deliberate ResolveAsOf-instead-of-ValidateWindow exceptions (get_collection_log, get_current_waits_trend, get_blocking_stats) are correctly implemented and commented, as_of is consistently the last parameter with matching [Description] text via the shared AsOfDescription constant on both SKUs, and the new AsOfWindowAnchorTests.cs suites (live-Postgres and DuckDB) genuinely assert window content (top-N/wait-type membership), not just counts, with solid cleanup discipline.

erikdarlingdata and others added 3 commits August 22, 2026 10:15
…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>
@erikdarlingdata
erikdarlingdata merged commit 73f3a50 into dev Aug 22, 2026
6 checks passed
erikdarlingdata added a commit that referenced this pull request Aug 22, 2026
#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>
MisterZeus pushed a commit to MisterZeus/PerformanceMonitor that referenced this pull request Aug 24, 2026
…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>
@erikdarlingdata
erikdarlingdata deleted the feat/2495-as-of branch September 12, 2026 20:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant