Repository navigation
Anchor the analysis family at a past window, and refuse to persist a backdated analysis (#2506) - #2513
Conversation
…ist a backdated pass (#2506) The analysis family's window is built inside AnalysisService / DarlingAnalysisService rather than in the tool, which is why #2495 left it out. AnalyzeAsync, CollectAndScoreFactsAsync and GetRecentFindingsAsync now take an optional asOfUtc; null is every existing caller — the scheduled worker, both WPF viewers, the recommendations readers — and is the pre-change window exactly. The anchor reaches the whole pipeline through AnalysisContext, so the anomaly detector's hour-of-day x day-of-week baseline moves with it. Its one remaining clock-derived bound (the 30-day baseline-data gate) now comes off the window's end too, identical for an unanchored pass and the only form that stays correct for an anchored one. The findings read gained an UPPER bound on analysis_time. Without it an anchor could only move the window's start earlier, so every anchored read would still have returned everything up to now — validated, advertised, and answering as of the present. AnalysisContext.PersistFindings is DERIVED from the anchor rather than settable: an anchored pass writes no row and raises no completion event. A finding row's identity for every consumer is its analysis_time, not the window it looked at — the viewers read MAX(analysis_time) as the server's current state, and get_analysis_findings collapses on story path to produce occurrence stats — so a backdated pass stamped now would rewrite what humans see and inflate a live incident's counts. There is no legitimate caller for "anchored AND persist", so there is no way to express it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…2506) analyze_server, get_analysis_facts, compare_analysis and get_analysis_findings now take as_of — same parameter name, semantics and description text as the rest of the surface, from the shared McpHelpers.AsOfDescription constant, byte-identical on both SKUs. compare_analysis hangs BOTH windows off the anchor; baseline_hours_back has always been measured from the comparison window's end, and moving only that end would change what the two windows are relative to each other. Its parameter description says so now. The anchor is passed to the engine as null when as_of was absent, rather than as the "now" ValidateWindow hands back. That distinction is what keeps every ordinary run persisting: AnalysisContext.AsOfUtc means "anchored", not "the window ends somewhere". analyze_server reports persisted / persistence_note on every result, because an anchored run returns a complete set of findings that does not exist in the store, and an agent told nothing would send someone to look for them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…could break at (#2506) The named-exclusion pin listed all four; leaving them there while they take the anchor would keep the pin green and make the list a lie. What is left are the two permanent kinds — the mixed snapshot/window reads and the store's own day-windowed growth series. EveryToolThatTakesTheAnchor_ActuallyUsesIt gained an analysisService arm. The existing checks cannot see this family's failure mode: a tool that resolves the anchor and then calls AnalyzeAsync without it still names windowEnd (from its own `out var`) and still never names DateTime.UtcNow, so it passes every other line while answering as of now. Verified red without the fix — all six tools flag, each with reaches=true — and green with it. The live halves seed a PAST window and prove the anchored call reaches it while the default does not, on both stores, plus the assertion the persistence argument rests on: the anchored run leaves analysis_findings untouched, and the SAME window unanchored writes to it. Every negative assertion has a positive control beside it so none of them can pass by returning nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… what analyze_server does with one (#2506) The "these do not take as_of" bullet named all four; three of them now do, with the anchor reaching the engine rather than stopping at the tool, and the fourth is anchored with a stated refusal to persist. The bullet that replaces it says which is which and why, and the analysis tool tables carry as_of. The remaining exclusions keep their own bullet, because they are excluded for a different and more permanent reason: get_pvs_stats and get_fleet_overview each mix a latest-snapshot measurement with a windowed one, so anchoring half of the answer would date the two halves differently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| anchor because both windows used to be pinned to now. Asserted on the rendered | ||
| instants rather than on a field path, so the check holds for both the data-bearing | ||
| shape and the "neither window had facts" status envelope. */ | ||
| var compared = await DarlingMcpTools.CompareAnalysis(service, postgres, ServerName, 1, 169, anchor); |
There was a problem hiding this comment.
baseline_hours_back: 169 exceeds McpHelpers.MaxHoursBack (168, "7 days"). CompareAnalysis validates it with the plain McpHelpers.ValidateHoursBack(baseline_hours_back) (unmodified by this PR), which returns an error for any value > 168:
if (hoursBack > MaxHoursBack)
return $"hours_back value '{hoursBack}' exceeds maximum of {MaxHoursBack} hours (7 days). Use a smaller value.";So this call (and the control call on line 244) will return the validation-error string instead of the comparison payload, and the two Assert.Contains(...instant..., compared, ...) checks right below will fail against that error text — not the intended "both windows hang off the anchor" assertion. This is a real (albeit gated behind DARLING_TEST_PG) test breakage, not a hypothetical: the fixture needs baseline_hours_back == 169 (7 days + the 1‑hour offset baked into incidentEnd) to land the baseline on historyStart, but 169 can never pass validation as written.
Worth fixing the fixture's arithmetic (e.g. drop the extra hour so the anchor sits exactly 168h after historyStart, or otherwise keep baseline_hours_back <= 168) rather than relying on a value the tool will always refuse.
There was a problem hiding this comment.
Correct, and CI hit it on the same run — the live test failed with hours_back value '169' exceeds maximum of 168 hours. Fixed in 7882a9f.
Not by the route suggested, though: dropping the extra hour would put the anchor at incidentStart, and with hours_back = 1 the window becomes [incidentStart - 1h, incidentStart], which excludes the three incident collections at +5/+10/+15 minutes. The extra hour is load-bearing — the window has to START at incidentStart so the anomaly baseline is looked up in the same hour-of-day bucket the history fills.
So it uses 168 and the baseline lands an hour past the history rows. That does not weaken the assertion, because the assertion is on the two rendered instants rather than on the comparison having data: baseline_start and comparison_end are present in the data-bearing shape, the caveat shape, and the unavailable envelope alike. The claim under test is that both bounds moved with the anchor, and the unanchored control below it is what makes that claim non-vacuous. Comment updated to say so.
|
Reviewed the diff. The engine-side anchor plumbing (Lite One concrete bug found, left as an inline comment: in No SQL-injection, secrets, or missing-index concerns — everything here is parameterized ($N / DuckDBParameter), and no T-SQL files are touched by this PR (the changed SQL constants are Darling's Postgres and Lite's DuckDB dialects, not subject to the T-SQL style section). |
Two live tests went red on the first push — one per SKU, both planting a finding stamped 30 minutes ahead of now to test occurrence collapsing — and they were right. Bounding the DEFAULT read at "now" broke #2495's central promise that a caller sending only hours_back gets byte-for-byte the window it always got, and this read has been half-open for its whole life. The mechanism behind the test fixture is not synthetic either. analysis_time is stamped by the WRITER's clock and would be filtered by the READER's. They are the same host today; the day they are not, a bounded default read drops the run written a moment earlier — a findings read that "sometimes misses the analysis that just finished", with nothing in it to point at a clock. So the upper bound binds only when the caller anchored; unanchored it is an unreachable sentinel. Present exactly where it is needed, absent exactly where it would do harm, with a test on each store asserting both halves so the sentinel does not get tidied away. Also: baseline_hours_back is a span and ValidateHoursBack caps it at 168, so the live compare_analysis probe uses 168 rather than 169. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review: Anchor the analysis family at a past window (#2506)Reviewed the full diff (CHANGELOG, Correctness
Lite/Darling parity — no drift found. Security — all new/changed SQL uses positional parameter binding ( No correctness bugs, parity gaps, or security issues found. Nothing to request changes on. |
Closes #2506.
#2504 anchored 57 Darling reads and 50 Lite reads and deliberately left four out — the four an incident investigation reaches for first. This finishes them.
What is anchored
All four, with the same parameter name, semantics and description text as the other 107, from the same
McpHelpers.AsOfDescriptionconstant. Verified byte-identical across SKUs including the em dashes.get_analysis_factsanalyze_servercompare_analysisbaseline_hours_backhas always been measured from the comparison window's endget_analysis_findingsThe window lives in the engine, so the anchor goes into the engine
The issue's framing is right: these tools hand a bare
hours_backtoAnalysisService/DarlingAnalysisService, which builds the window itself. I threaded it rather than leaving a subset unanchored, because the engine turned out to be already window-driven end to end — every bound inAnomalyDetector/PgAnomalyDetectorand every baseline lookup already comes offcontext.TimeRangeStart/End. Anchoring the context is therefore enough to make the anomaly comparison correct for a past window, which is the part that actually matters: the issue notes that a run pinned to now compares Tuesday 03:00 against whatever baseline bucket today is in.AnalyzeAsync,CollectAndScoreFactsAsyncandGetRecentFindingsAsynctake an optionalasOfUtcon both SKUs. Null is every existing caller and is the pre-change window exactly:DarlingWorker's scheduled pass and Lite'sCollectionBackgroundServiceRecommendationsTab.xaml.cs)ViewerDataService.GetLatestFindingsAsync— untouched)LiteRecommendationsReader, which calls the store directlyAnalyzeAsync's return value, not a store readTwo shared-path changes worth naming:
HasBaselineData's 30-day gate moved fromDateTime.UtcNow.AddDays(-30)tocontext.TimeRangeEnd.AddDays(-30), on both SKUs. Identical for an unanchored pass (TimeRangeEndis now), and the only form that agrees with the baseline provider — which reads the 30 days beforecontext.TimeRangeStart— when the pass is anchored. It was the last clock-derived bound in that class.GetRecentFindingsAsyncgained an UPPER bound onanalysis_time(LIMIT $3→$4on Darling; the pin moved with it). The read had a start and no end for its whole life. Without the bound an anchor could only move the window's start earlier and every anchored read would still have returned everything up to now — validated, advertised, and answering as of the present. The bound binds only when the caller anchored — see below.The upper bound is anchored-only, and CI is why
The first push bounded it at "now" unanchored too, and two existing live tests went red — one per SKU (
DarlingMcpToolsTests.EndToEnd_FindingsEnvelope_MuteFlow,McpAnalysisFindingsCommandTests.GetAnalysisFindings_CollapsesRepeatedChains), both planting a finding stamped 30 minutes ahead of now to exercise occurrence collapsing. They were right and I was wrong:hours_backgets byte-for-byte the window it always got.analysis_timeis stamped by the writer's clock and would be filtered by the reader's. They are the same host today; the day they are not, a bounded default read drops the run written a moment earlier — a findings read that "sometimes misses the analysis that just finished", with nothing in it to point at a clock.So unanchored the upper bound is an unreachable sentinel (
NoUpperBound) and the read stays half-open; anchored it is the caller's own instant. Neither existing test was touched. A test on each store now asserts both halves — the default read returns an ahead-of-now row, the anchored read does not — so the sentinel cannot be tidied away by someone who reads it as dead code.What
analyze_serverdoes with an anchor, and whyIt accepts the anchor and refuses to persist the run. Option 2 in the issue, and the issue author's own inclination — with the rule made derived rather than a flag.
AnalysisContext.PersistFindingsisAsOfUtc is null. An anchored pass writes no row and raises noAnalysisCompletedevent; the findings come back in full. There is no legitimate caller for "anchored and persist", so there is no way to express it — including for the next caller, who will not have read the argument.The reasoning, which is about
analysis_timeand not about the window:analysis_time— the moment the pass ran, not the window it looked at.GetLatestFindingsSqlkeys onMAX(analysis_time), and that read is what the Darling viewer's Recommendations tab (MainWindow.xaml.cs:1582) and Lite's tab show as this server's current state. Persisting a backdated run would make last week's findings the current headline for every human looking at the viewer — an out-of-band change to what other people see, caused by one agent's exploratory read.get_analysis_findingsfilters onanalysis_timeand then collapses on(story_path_hash, incident_id)to produceoccurrences/first_seen/last_seen/peak_severity. A backdated run's rows land in the same group as a live incident's and inflate exactly the stats an operator uses to decide whether it is getting worse.AnalysisNotificationService. An alert about last Tuesday delivered today is the persistence problem with a shorter fuse.On option 3 ("anchor and persist, with the window recorded on the row"): the issue says it needs a migration rung and a reader change. It does not —
analysis_findingsalready carriestime_range_start/time_range_endon both stores andget_analysis_findingsalready returns them astime_range. That is what makes option 3 the wrong answer rather than merely the expensive one: the window is on the row today and it does not help, because no consumer filters or orders on it. Making them do so is a separate, larger product change that would also alter what the scheduled path means, and it should be driven by someone actually wanting the persistence.The refusal is explicit, not silent: every
analyze_serverresult carriespersistedandpersistence_note(null on the ordinary run), both instructions files say it, and the tool description says it.What I deliberately did not anchor
get_pvs_stats,get_fleet_overview— out of scope per the issue, and for a more permanent reason: each mixes a latest-snapshot measurement with a windowed one, so anchoring half the answer dates the two halves differently. They keep their own bullet in the docs.get_store_metrics— windows in days over the store's own growth series. Already excluded; unchanged.audit_config— it callsCollectAndScoreFactsAsyncwith a hardcoded 1-hour window, but it takes nohours_backand is a current configuration audit; config history has its own anchored tool (get_server_config_changes). Anchoring it would mean inventing a window parameter the tool has never had. Left alone on purpose rather than by oversight.Proving the anchor reaches the query
#2504's review found eight tools that advertised
as_of, validated it, and then queriedDateTime.UtcNow. This family can fail the same category one seam over, and none of the existing checks can see it: a tool that resolves the anchor and then callsAnalyzeAsyncwithout it still containswindowEnd(from its ownout var) and still never namesDateTime.UtcNow— so it passes every line of the existing pin while answering as of now.EveryToolThatTakesTheAnchor_ActuallyUsesItgained ananalysisService.arm, mirroring thedataService.one, with the anchorable method set read off both SKUs' shipped sources and asserted non-empty. Proved red before green: strippingasOfUtc: anchorflags all six tools across both SKUs, each withreaches=true— i.e. exactly the cases nothing else would have caught.One subtlety that is load-bearing and easy to get backwards: the tools pass null, not the resolved
windowEnd, whenas_ofwas absent.ValidateWindowhands back "now" for an absent anchor, and passing that through would have made every ordinary run look anchored to the engine — which would have quietly stoppedanalyze_serverpersisting anything, ever.Tests
Darling.Tests/AnalysisAsOfAnchorTests.cs— ungated rules, plus a[Collection("live-postgres")]end-to-end on the pipeline fixture's own recipe (a >24h wait history in one hour×dow bucket, then a heavy window the following week). Anchored: facts score,analyze_serverfinds theANOMALY_WAIT_PROFILEstory, reportspersisted:false, and leavesanalysis_findingsat zero rows. The same window unanchored produces findings and rows — so the zero is the anchor's doing and not a fixture with nothing to say. The findings read then returns a planted 30-hour-old pass when anchored and only the now-stamped rows when not,finding_count == 1, which is what proves the new upper bound is doing work. Cleanup goes throughLiveStoreCleanup.RunAsync.Lite.Tests/AnalysisAsOfAnchorTests.cs— same claims on DuckDB, seeded under the derived server id (RemoteCollectorService.GetDeterministicHashCode(GetServerNameForStorage(...))), including the persistence A/B on an identical window and the refusal path (a bad anchor must not fall back to now and then run a real, persisting analysis).What I could not verify
Darling.TestsandLite.Testsarenet10.0-windows; they build on macOS withEnableWindowsTargetingand cannot execute. CI is the arbiter. The whole solution builds clean with no new warnings.DARLING_TEST_PGis not set here, so its window arithmetic and the anomaly firing on the anchored window were argued from the existingDarlingAnalysisPipelineTestsfixture rather than observed. CI has now executed it (it caught abaseline_hours_backover the 168-hour cap on the first run).EveryToolThatTakesTheAnchor_ActuallyUsesItchange was verified with a Python replica of the C# scan, run against the shipped sources both with and without the fix. The replica agrees with the C# by construction (same regexes, same paren matcher), but it is a replica.