Repository navigation
Confidence chooses the channel: uncorroborated findings route to digest + web, corroboration pages (#3712) - #3732
Merged
Conversation
This was referenced Sep 19, 2026
erikdarlingdata
added a commit
that referenced
this pull request
Sep 20, 2026
…ery Store capture modes, config_alert_settings gains the store-backed twin of the uncorroborated-finding route knob, and store_metrics gains the plan dimension's TOAST bytes plus the store's own checkpointer phases — eight nullable columns on three existing tables, no new hypertable (partial #3796, partial #3712, partial #3783) (#3813) (a) #3796 — the health row stored every Query Store option except the one that names a plan-churn factory. query_capture_mode (2016+) and wait_stats_capture_mode (2017+) ride the shared collector as its eleventh and twelfth payload columns; the 2017+ one is the collector's first per-column version gate (HasWaitStatsCaptureMode, the DatabaseConfigCollector idiom), because a missing catalog column fails the whole database's batch at compile rather than yielding a NULL cell. Both execution shapes build from the one body for the one target. v_query_store_health refreshed for the V14 reason; V76's CREATE carries the two for the V101 rule; Lite v64 is the DuckDB twin. (b) #3712 — analysis_uncorroborated_route on the singleton alert-settings row, spelled as Lite's settings.json already spells it. Nullable tri-state: NULL means the file-level knob (#3732) governs, which is every existing store the morning after; digest / page when set. A CHECK where the numeric knobs have none, because a route has no clamp and a misspelling would otherwise read as the default. The code half (store non-NULL wins over file) is its own lane. (c) #3783 — toast_bytes = pg_relation_size(reltoastrelid) on the dimension rows, so a TOAST file at 40 % utilisation reads as a series rather than a size-pass discovery. toast_live_bytes is created and written NULL: measured on a 2.28.1-pg18 rig (215 MB of 9.6 KB values, half deleted, VACUUM; pgstattuple oracle 47.9 % live), the extension-free tuple-share proxy reads 100 %, the chunk-count estimate 48.9 % with a value-size-dependent error, and pg_freespacemap's file - sum(avail) 48.5 % — honest, but CREATE EXTENSION is the maintainer's call, not this rung's. (d) #3783 (+#3802/#3745) — checkpoint_write_ms, checkpoint_sync_ms and checkpoints_requested for the checkpointer row the inventory will write (deltas since the previous run; pg_stat_checkpointer on 17+, pg_stat_bgwriter before). Three unattributed read kills sat inside 25.2 s and 14.0 s checkpoint sync phases and nothing recorded it. The writer needs the previous cumulative, so it is #3783's code half; the columns are NULL until then. Viewer probe gains the V137 sentinel and top arm; V136's tests hand over the top-rung claims. Verified live: TimescaleDB 2.28.1 / PG 18 climbs 136 -> 137 in one rung then zero, over a COMPRESSED query_store_health hypertable whose pre-rung row reads NULL through the compressed chunk; DuckDB climbs 63 -> 64 with the v_ view present.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The lie
Confidence has measured corroboration since #3538 A6, and every finding that crossed the severity floor still took the same road to the same channel. Severity said "this is big"; the channel heard "page a human". The first day the recalibrated engine ran on a large production fleet: forty-plus anomaly pages in seven hours, severity 1.5–1.9, confidence 0.20–0.35, interleaved with the handful that actually needed a human, and the operator's own reading of the channel was no idea what to focus on. That is alert fatigue as a mechanism — not wrong alerts but unranked ones, where the cost lands on the true positives that stop being seen. The batch itself made the case sharper: the low-confidence pages were largely true and redundant — anomaly stories attached to a CPU flap already paging through the CPU family, to a server inside its own maintenance job, to a burst the platform alert had already delivered. Uncorroborated single-fact stories are, empirically, mostly echoes.
The mechanism
Confidence chooses the channel, and it reads the corroboration COMPONENTS, never the scalar (design point 1).
FindingRouting.Classify(PerformanceMonitor.Notifications — one pure function both SKUs consult) pages a finding when any of these holds, and its reason names which:FactCount ≥ 2— each traversed edge is a fired predicate onto a fact that itself scored);MatchedAmplifiers ≥ 1— every amplifier the scorer defines is a predicate over ANOTHER fact: a sibling anomaly in the same window, an absolute fact at its concerning bar, a wait that is significant; one match is one corroborating fact the engine went looking for and found);Everything else is a lone fact and takes the digest road under the shipped default. The two components the scalar folds together now ride the story and the finding as their own numbers —
AnalysisStory/AnalysisFinding.MatchedAmplifiersand.DefinedAmplifiers, read inInferenceEngine.BuildStorywhere the root fact is in hand, copied across by both finding stores, ephemeral likeRootFactMetadata. The inverse of the formula is exact today (a one-fact story above 0.20 has a matched check) and stops being exact the day a weight moves; a paging bar that silently moves with a formula constant is what design point 1 forbids.The extremity escape keeps its severity power; its published intent becomes the enforced bar.
FactScorer.IsExtremeAnomaly(#3526) is a private predicate that writes NO marker onto the fact or the finding — the brief's hypothesis that the escape marks something it routes on was checked at the source and is false — so the gate cannot see extremity and does not try to. An extreme single still clears the floor and still leads its story; with no matched check it is still a single and lands in the digest with its severity intact, which is exactly "extreme and corroborated" as the bar.AnalysisNotificationService.NotifyAsynctakes two roads. The severity floor stays upstream of the gate — a below-floor finding is not routed at all. Each notify-worthy incident is classified member by member: an incident with any page-routed member takes the page road, led by a PAGE member even when the single outranks it, and the single is named on the page as co-fired — the "true and redundant" shape the live batch was made of, accounted for exactly as before. An incident of uncorroborated singles takes the digest road: the sameFindingAlertto the sameIFindingAlertSender, withRoute = Digest; the sender consults NO channel (no send core, no webhook fan-out, no tray) and writes the ledger row with the newAlertDelivery.RoutedToDigest()disposition (notification_type = 'digest',Sent = falseas a measurement). The tray is a paging channel — it interrupts — so the digest road raises nothing there either.The ledger records the decision the way it records the fired tier.
AlertContext.Routing(AlertRoutingDto(Route, Reason)) is a trailing nullable member of the context JSON, set on BOTH roads — a page says why it earned the channel, a digest entry says why it did not — read off the row byAlertContextSerializer.TryReadRoutingand published byget_alert_historyon both SKUs asrouting/routing_reasonbesideseverity_source. Null on every engine alert and every pre-#3712 row, and the descriptions say so. The web alerts page carriesrouting_reasonas the status cell's title;util.js/AlertDeliveryStatuslabel the disposition Digest.Escalation is the corroboration event (design point 2). The route is a key component of the re-notify bucket: the digest road cools under a
digest|namespace prefix and the page namespace keeps its pre-#3712 key text byte for byte, so a story that gains a fact (a new hash) or a matched check (the SAME hash, a flipped route) finds no page bucket and pages as a NEW firing. Across a restart the page seed is the store'sGetLastAlertTimeAsync, and both stores (PG, DuckDB) now EXCLUDE digest rows from it — spelled throughAlertDelivery.ChannelDigest, not a retyped literal — because a seed that read a digest row would hold the escalation as a repeat of a page that never happened. The digest namespace is deliberately not seeded: there is no route-filtered seed on the sender seam, the cost is bounded and channel-free (one re-recorded ledger row per standing single per restart), and the digest reads distinct stories over its span rather than counting rows.The Analysis Singles Digest is the third daily document beside the Collector Cost Digest and the Fleet Sweep Rollup, seam for seam: family
reports, declared INFO arm, one post per trailing day, the #3580 delivered-today stamp under its own key, empty day posts nothing without consuming the interval, a failed read skips the tick and is counted into the #3013 census (34 sites now). Its SOURCE is the ledger (AnalysisSinglesDigestReader,notification_type = 'digest'overconfig.config_alert_log), because a digest-routed ledger row exists for exactly the findings the gate decided about and already carries the reason and the #1140 fingerprints. The document: "Anomaly singles: N across M servers", the top movers by severity — each with the KEY a page would have carried (Analysis: <category> [<hash8>], the ledgermetric_nameand the story hashget_analysis_findingsresolves), its reason and its dedup fingerprints (design point 3) — then by family, by server, every cap stating its remainder. Rows are deduplicated per (server, story) at the newest severity so a restart's second row counts once.One knob,
analysis.uncorroborated_route = digest | page, defaultdigest, onIAlertSettings.UncorroboratedFindingRouteas a defaulted interface member (theNotificationRoutesprecedent: the default IS the ruling). Lite: a live setting —App.AnalysisUncorroboratedRoute,analysis_uncorroborated_routein the settings file, a Settings → Alerts checkbox,get_alert_settings. Darling:analysis.uncorroboratedRoutein darling.json, file-level likeweb.publicBaseUrl— carried acrossStoreConfigProvider's wholesaleAnalysisswap from the held config (theBuildServerFromRowbackfill shape), published to an ambient static by both config-loading hosts soget_alert_settingsreports the value the running process applies rather than a constant, refused by name byupdate_alert_settingswith the file key (a round-tripped unchanged value is accepted with a warning). Documented in a new### analysissection of Darling's README and in Lite's Alert Behavior list.Lite's grid says what was not delivered.
FindingRoutingLens(notify floor + knob) judges a PERSISTED finding throughFindingRouting.ClassifyPersisted, which answers three arms exactly from the persisted fields and re-derives the matched-check arm from the scalar — saying so in its reason, and pinned to agree with the write-side decision over the whole small-integer catalogue domain. A notify-worthy single reads Not paged — uncorroborated: … on its Recommendations card; a delivered finding and a below-floor one carry no line.What it does NOT do
FactScorer,AnomalyGate, the reconciler or anyPgTarget*file — the fence held. Severity is unchanged; only the channel a severity reaches is decided here.config_alert_settingscolumn, at which point the carry inStoreConfigProviderbecomes areader.GetStringandupdate_alert_settingsgains the field).get_analysis_findingsreturns: digest-routed findings are persisted by the sameInsertFindingsAsyncpath they always were, upstream of the notification service; the live pipeline test asserts the sender still receives them.Evidence and tests
Executed on this Mac (mactest harnesses over the built assemblies):
Lite.Tests/FindingRoutingTests.cs(new, 39 tests, Notifications-only harness): everyClassifyarm in order with its reason; the knob moves only the uncorroborated arm;ClassifyPersisted≡Classifyover path 1–11 × catalogue 0–12 × both knobs; the legacy path-shape 1.0 reads uncorroborated; the routing record round-trips and reads off the row alone (and a foreign shape reads null); the digest disposition is state-carrying, never Sent, labelled Digest on both SKUs; throughNotifyAsync: a single takes the digest road with the reason on the context and no toast, a chain pages and toasts, a below-floor single is not routed, the page knob pages a single with the knob named in the reason, a mixed incident is led by the page member with the single co-fired, an all-single incident writes ONE digest row, a single that gains a matched check on the same hash pages as a new firing, a seeded page bucket still holds (which is why the stores exclude digest rows), the digest road never consults the seed. Red-first: emptying thedigest|prefix reds exactly the escalation pin; raising the matched-check arm to≥ 2reds five.Darling.Tests/AnalysisSinglesDigestTests.cs(new, 22 tests): extraction (distinct counts, top-movers order, restart-row collapse at newest severity, family-then-server grouping,FamilyOfshapes, unparseable context kept), the rendered document (headline, span, ranked movers with key/reason/fingerprints, family blocks, remainders stated), the lifecycle (metric/key/label/INFO/count, interval, an earlier process's stamp gates, failed delivery writes no stamp and retries, empty day posts nothing, master gate, mute seam), the taxonomy registration, the reader's predicate, the seed exclusion spelled through the constant.DarlingMcpAlertToolsTests.UncorroboratedRoute_IsReportedFromThePublish_WithItsNote_AndTheWriterRefusesToChangeIt(new): unpublished → null + note, null coming home claims nothing; a value with nothing published is refused with the file key; published → wire spelling; the same value coming home → one warning, no column; a different value → refused; an unparseable file value publishes nothing; both descriptions say it.Darling.Testson the Mac against a scratch TimescaleDB (DARLING_TEST_PGset, live tests lit): 8419 total, 255 failed, 54 skipped — versus 8241 / 254 / 54 on pristine dev at the same HEAD-minus-this-PR, and the failure SETS are identical (the platform reds: Windows paths, deploy, repo-root resolution); the +1 isPgTargetWaitTests.TheWalWaits_…, which is red on dev's own HEAD CI (run on1acd6062, jobDarling PostgreSQL tests) — a PostgreSQL I/O family: an operator now learns how slow each data-file read was, on measured Aurora-storage bars, and hears why when the pass cannot know — timing off or a pre-16 major (partial #3691) #3726/Replication lag and slot retention stories for the PostgreSQL-target engine: a standby's stage and drift, a slot's disk-fill and horizon, on the alert's own bars (partial #3691) #3727 lane's test, fenced, not touched here. Live:DarlingAnalysisPipelineTests.EndToEnd_FullAnalysisPipeline_AgainstDevPostgresnow also asserts the components rodeBuildStory → PgFindingStore(ANOMALY_WAIT_PROFILE defines five checks) and the gate's decision rides the alert;DarlingAlertingTests.PgAlertHistoryStore_GetLastAlertTimeAsync_ExcludesDigestRoutedRows_FromThePageSeed(new) proves the exclusion against Postgres in both directions and was proven red by blanking the filter.AlertReadFailureSurfaceTests(10 counted in the evaluator, 34 tree-wide),NotificationRoutingTests(family census names the third document),AlertTrayStatusLoggedTests(util.js ↔ constants),AlertDeliveryChannelTests,AlertMetricClassifierTests,AlertHistoryRowSeverityTests,DarlingConfigTests.CI-first (net10.0-windows only):
Lite.Tests/LiteRecommendationsReaderTests.MapFinding_MarksANotifyWorthyUncorroboratedSingle_AndNothingElse(new), the LiteAlertSeverityTests/AlertReadFailureSurfaceTests/McpAlertSettingsKeyTestsre-pins (the Darling↔Liteanalysiskey parity was replayed against both sources by hand: seven keys each side).Every touched project builds
-c Release -p:EnableWindowsTargeting=true --no-incrementalwith 0 warnings (the one xUnit2000 inLite.Tests/LogTailOverlapThresholdPinTests.csis dev's, from 40b13d5, untouched here).Partial #3712 — residual: the Darling knob's store column and Viewer Settings surface (rung-fenced).