Skip to content

Deliver the alert's detail text on every channel - #3302

Merged
erikdarlingdata merged 9 commits into
devfrom
fix/3297-detail-to-delivery-channels
Sep 11, 2026
Merged

erikdarlingdata merged 9 commits into
devfrom
fix/3297-detail-to-delivery-channels

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 11, 2026 •

Copy link
Copy Markdown
Owner

An alert's DetailText — the block that says what happened, what it costs, and what to do about it — reached the history store, the WPF viewer, the MCP reader and the triage endpoint, and no delivery channel. WebhookAlertService held zero references to it across all 1,534 lines. The email template does have a detail section, but it is gated on AlertContext.Details, a separate and structured collection; self-alerts fire with context: null, so it never rendered and the detail had no path to the message at all. Reported from the field on #3296, where the operator received a metric name, a current value, a threshold and two timestamps, and had to open an issue to find out what the alert meant.

Which surfaces were broken

All five, and PagerDuty only looked like an exception.

surface before now
Email HTML dropped leads the DETAILS section
Email plain text dropped leads --- Details ---
Teams dropped its own Details MessageCard section, text not a fact
Slack dropped an mrkdwn section block under the fields
Generic dropped leads {{context}}, plus a new discrete {{detail}} token
PagerDuty dropped custom_details["Details"]

BuildPagerDutyCustomDetails already flattened AlertContext.Details, which is why #2710's note reads as though the channel carried the detail. It carried the structured collection; a prose-only self-alert took the empty-Details branch that reduces custom_details to {"Sent by": …}.

The generic channel was a token question, and a new token alone would have fixed nothing for anyone: the shipped default template carries {{context}}, so does every template an operator has already saved, and none of them can reference a token that did not exist when they were written. So the prose leads {{context}} — which is what makes the fix arrive with no operator action — and {{detail}} exists on top of that for a template that wants the prose on its own, mapped into a ticket body field rather than mixed with the flattened structure.

What keeps engine alerts unchanged

Every engine alert's DetailText is ContextToDetailText of its own context — AlertEngine builds it that way at eleven fire sites — so carrying it on top of the structured render would print the same content twice in exactly the alerts that already read correctly. AlertDetailText.ProseForDelivery suppresses on that equality, resolved once per firing in the fan-out the way triageUrl already is.

It is deliberately not "suppress whenever a context is present": the #2109 AG-database alerts carry both a structured context and independent prose naming the SET HADR RESUME remedy, and that prose must still be delivered. The comparison is against the shared AlertDetailText.Flatten, so the two texts are equal by construction — AlertContextBuilders.ContextToDetailText now forwards to it rather than holding a second copy. Should they ever disagree anyway the alert is delivered twice, which is visible and harmless, rather than having its remedy dropped again.

The wording fix

The #2813 Retention Held detail said the policy "arms ITSELF once its consumer covers everything raw holds" and stopped there. True and one step short: TimescaleSupport.EnsureRetentionPoliciesAsync is the only thing that arms a held policy and it has exactly one call site, the service startup path. An operator following the old wording runs --backfill-rollups, watches the hourly Critical keep firing, and concludes the backfill failed. The detail now names the restart and says why it is not optional — the wording docs/retention-hold-runbook.md already uses. That runbook's "the alert's email does not carry any of the above" rough edge is now stale and is removed; the untunable-thresholds one stays.

IAlertDeliverer's doc comment for DetailText said it was a rendering of Context, which is the statement that makes delivering it look redundant. It now says what a self-alert actually puts there.

Verification

There is no dogfooding path to this defect. No email is configured on any of the three stores and there is no webhook channel yet, so it can only ever be found by a user filing an issue, which is how it was found. The pins are the whole guard, and every one of them was mutation-tested: the detail deleted from the payload, red confirmed, restored, and the restore verified by blob hash rather than by diffstat.

TheDeliverer_PutsDetailTextOnTheWire_OnEmailAndEveryRedirectableWebhookChannel drives the whole Darling path — DeliverAsync, the send core, the fan-out, each builder — and asserts against the bytes that left the process, via a loopback sink per channel. Deliberately not a payload-builder assertion: the defect was never in a builder, it was that nothing passed the text to one, and a builder-level pin is green on a fan-out that drops the argument. Mutation testing proved that: dropping prose in the fan-out kills only this test, and every builder pin stays green.

It also found the one hop that no pin covered — the send core handing the email template the detail. Email has no interception point short of the protocol, and that hop is the channel #3296 actually reported, so the sink now speaks enough SMTP to capture the DATA and decode both transfer encodings (.NET picks base64 for the utf-8 plain part and quoted-printable for the us-ascii HTML one). The assertion is that the phrase appears twice, once per MIME part, so losing either body fails.

PagerDuty's endpoint is the hardcoded Events v2 URL and cannot be redirected at a sink; its builder is pinned separately and takes its prose from the same single resolution in the fan-out as the three that are checked on the wire.

Full solution builds clean with zero warnings. The Windows-only suites were run on macOS by compiling the actual committed test .cs files into net10.0 console harnesses over an xunit shim, in three scopes: the eight Lite render/payload classes (111/111), the two Darling delivery classes (44 pass, 1 skip), and AlertEngineTests (69/69, 66 async tests confirmed awaited rather than reported as hollow passes). The shim has a --self-check mode with two control classes — six that must pass, five that must fail, including a failure raised after an await and structural equality on two distinct-but-equal List<T> — so a hollow pass and a red that belongs to the harness are both ruled out by measurement rather than by care. A shimmed subject reports SKIP and is never counted as a pass: TheViewerRow_RendersThroughTheSharedDescriber's subject is the WPF ViewerAlertRow, so it skips here and CI runs it for real.

The new tests are confirmed to have RUN, by count rather than by name — MTP prints no per-test Passed lines, so the grep that used to confirm this returns nothing. Against dev at a220d210, Lite.Tests goes 3646 to 3663, exactly the 17 cases added here (15 facts plus a two-case theory), and Darling.Tests 8600 to 8604, exactly the 4 (2 here, plus the merged-seam wire pin and the no-duplicate-fact pin below). Skipped is unchanged — 338 in the Windows job, 6 in the Postgres one — and none of the touched classes appears in either skip list. The Darling whole-tree guards job reports in seconds because its gate saw the build job run the full Darling suite on this commit and says so; that is its documented skip path, not an empty run.

One mutation survived, and it is not a gap in this change: passing null instead of the per-event split's detail text in DarlingAlertDeliverer. That path's detail text is by construction the flattening of its own split context, so the gate suppresses it on either branch and no channel's output moves. It does reach the history row, which no test asserts — that is #1236/#1141's coverage, untouched here.

Two things review found, and what changed for each

The prose bypasses the never-on-a-webhook T-SQL redaction. The premise is right — the #2109 AG alert's DetailText really does contain ALTER DATABASE [db] SET HADR RESUME, and it now rides out unredacted. Declined, with the reason recorded in the code. That rule's subject is the GENERATED payload: IsCodeBlock bodies and RemediationAction built by FactRemediation, multi-statement, two of the seven shapes destructive, carrying a two-sided risk-disclosure header that has to be read before the script is run. Prose is authored per alert, and where it names a command at all that command IS the recovery action and the sentence IS the alert; replacing it with "see email or the in-app dialog" delivers nothing to an operator whose only channel is the one being redacted, which is #3296 over again. It is also not a disclosure control — the payload already carries resource_name, database and {{incidents_json}} with the same object names.

What the finding got right underneath that is the drift risk: nothing stopped a generated command from being folded into the prose, at which point it really would bypass the rule with no test noticing. FindingMessageFormatter.DetailText is finding metadata and the command rides only in the code-block item, so that is now asserted rather than assumed, and both docs say which surface the rule covers.

Two engine alerts would have delivered their facts twice. Correct, and fixed. Database-State-Changed and Force-Plan-Failing hand-author a detail text restating their own structured fields under different labels, so the suppression gate never fired for them. Per #2109 the fields are the canonical copy, so the restatement goes and the detail text is their flattening, as at the other eleven fire sites; the one thing each prose said that its fields did not — no baseline yet, and that the query is running on the optimizer's plan — is a field now. All thirteen detailText locals in AlertEngine now come from ContextToDetailText.

And the new token was in neither app's Settings help text, so nothing told an operator it exists. Both lists carry it, and Lite's automation list picks up the {{triage_url}} it never got when #2710 added it to Darling's. That is the second drift of the same kind, so the token set is now one list the matcher is built from and the help text is checked against that list — not against a second copy free to go stale the same way. Adding a token to the engine and not to the windows fails.

Merged with #3303's display name, which landed on the same seam first

#3308 threaded a custom rule's human display name through the same five-channel delivery seam this branch threads the alert's prose detail through, and landed on dev first. Every signature between DarlingAlertDeliverer and the payload builders had gained one parameter from each side, so four files conflicted. Both are kept, detailText ahead of displayName so this branch's positional call sites in AlertIncidentRenderTests stay valid while #3308's named displayName: sites are unaffected.

The two intents compose per channel rather than collapsing into one rule, because they are different kinds of thing. The prose is alert CONTENT, so every channel carries it. The display name is a TITLE concern, so it reaches the Teams card title and summary, the Slack header, the PagerDuty summary and the email subject and heading — and deliberately not the generic channel, which renders no title and whose {{metric}} field is the machine key an automation correlates on. That exemption was #3308's decision and is preserved; it is now asserted rather than left as a comment.

Neither side's severity, cooldown, dedup or mute key moves: AlertSeverity.ForMetric, DerivePagerDutyDedupKey and the history row all still key on the immutable metric name.

The hops the conflict was in had no pin, measured rather than assumed

detailText and displayName are both string?. Dropping one, or transposing the pair, at any of the three hops between the deliverer and the builders is not a compile error — and against the pre-merge code it was not a test failure either. Dropping displayName in DarlingAlertDeliverer, and again in EmailSendCore's webhook fan-out, each left every suite green: #3308's tests cover its payload BUILDERS, and neither side could cover a hop with the other side's field also present, because neither side had both fields.

That is the same asymmetry this branch already found and closed on the prose side, so it is closed the same way. TheDeliverer_CarriesBothTheProseAndTheDisplayName_ThroughTheOneFanOut drives the whole Darling path once with both fields set and reads the bytes that left the process. Bodies are identified by a channel-native marker rather than by arrival order. Four mutations confirm it discriminates: dropping displayName at either hop, transposing the pair at the merged call, and handing the generic channel the display name in place of the machine key all turn it red.

One more duplicated fact, found by the review pass on the merged head

The pending database-state alert carried a Baseline: none — first observed in a critical state field alongside an Expected State that already renders (no baseline yet) — computed from the same pending flag, and present since #2109. So it stated one fact under two labels, and now that the detail block is delivered that is something an operator reads twice rather than an internal duplication. It is the same defect class this PR exists to prevent, so it is fixed here rather than noted: the three canonical fields stand alone again, and the commit comment claiming the no-baseline fact was not already a field is corrected.

DatabaseState_PendingCriticalFirstObservation_StatesTheMissingBaselineOnce counts occurrences rather than enumerating field names, because a name list would pass while the fact arrived under a label nobody thought to enumerate — which is the failure being guarded. Re-adding the field turns it red.

Open, and not fixed here: analysis-finding alerts deliver their Diagnosis facts twice

FindingMessageFormatter is a third producer of (Context, DetailText) pairs, after the two AlertEngine sites above, and this PR does not touch it. BuildContext puts Story / Severity / Notify threshold / Confidence / Facts / Database / Window into a Diagnosis item; DetailText independently formats the same seven values with different labels (Facts in chain vs Facts, one combined severity line vs two fields, - vs → in the window). The two are therefore never string-equal, the suppression gate treats the prose as independent, and every channel renders both. Reproduced against the real formatter and the real email template: ProseForDelivery returns the prose, and the plain-text body carries all six facts twice, adjacent.

It is not fixed here because FindingMessageFormatter.DetailText is not only a delivery string — it is the persisted config_alert_log.detail_text for every analysis row, what the MCP reader and the triage page read, the Dashboard's no-channel tray-row text, and the input AlertMuteContext.PopulateFromDetailText parses. Making it AlertDetailText.Flatten(BuildContext(...)), which is the #2109 rule the two AlertEngine sites follow and the right direction, changes what that column holds for the largest alert category: it gains the Diagnosis heading, a separate Notify threshold field, the advice headline, the remediation-T-SQL heading and every drill-down field. That belongs in its own change with its own review.

Widening the gate is the wrong direction: any "suppress when the context's facts all appear in the prose" rule also drops the #2109 AG alerts' SET HADR RESUME remedy, which is the case the gate exists not to drop.

The review thread on AlertDetailText.cs is left unresolved for this reason.

CHANGELOG

The [Unreleased] entry text, for whoever is batching them:

- **Alert detail text now reaches every delivery channel** (#3297): an alert's detail block — what happened, what it costs, and the operator action that clears it — was stored and shown in the viewer, the MCP reader and the triage page, but delivered by no channel. Email (both the HTML and plain-text bodies), Teams, Slack, PagerDuty's `custom_details` and the generic webhook now all carry it; the generic channel's `{{context}}` token leads with it, so existing templates and the shipped default get it with no operator change, and a new `{{detail}}` token carries it alone. Engine alerts whose detail text is only a flattening of their structured context are unchanged. The Retention Held detail (#2813) also now names the service restart, which is what actually arms a held retention policy.

An alert's DetailText reached the history store, the Viewer, the MCP reader and the
triage endpoint, and no delivery channel. The email template's detail section was
gated on AlertContext.Details -- a different, structured field -- and a self-alert
fires with context: null, so an email-only operator received a metric name, a value,
a threshold and two timestamps with the remedy discarded. Reported on #3296.

Email (both bodies), Teams, Slack, the generic channel and PagerDuty now carry it.
The prose is resolved once per firing and suppressed when it is only a flattening of
the structured context, so engine alerts render unchanged.

Also: the #2813 Retention Held detail now names the service restart, which is the
step that actually arms a held policy.
Mutation testing found the one uncovered hop: dropping the detail on the way to the
email template alone passed every pin, because email has no interception point short
of the protocol. That is the exact defect #3296 reported, so the loopback sink now
speaks enough SMTP to capture the DATA and decode both transfer encodings, and the
assertion is that the phrase appears TWICE -- once per MIME part.
Comment thread PerformanceMonitor.Notifications/WebhookAlertService.cs
@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown

Review summary

This PR does what it says: AlertOutcome.DetailText now reaches every delivery channel (email HTML/plain-text, Teams, Slack, generic webhook, PagerDuty), not just the history store/viewer/MCP/triage endpoint. The design is clean — one shared AlertDetailText.Flatten/ProseForDelivery in PerformanceMonitor.Notifications (moved out of PerformanceMonitor.Alerting so the delivery channels can compare against it), resolved once per fan-out the same way triageUrl already is, with a suppression rule that correctly avoids double-printing an engine alert's context (verified by AnEngineAlertsFlattenedDetail_IsNotRenderedTwice_OnAnyChannel) while still allowing independent prose alongside a structured context (the #2109 AG case). Test coverage is thorough, including a real loopback SMTP/HTTP capture test for the actual Darling wire path rather than just payload-builder assertions.

Lite/Darling parity looks good — the change lives entirely in the shared PerformanceMonitor.Notifications/PerformanceMonitor.Alerting libraries, both apps' call sites (Lite/Services/EmailAlertService.cs, Darling/.../DarlingAlertDeliverer.cs, DarlingFindingAlertSender.cs) were updated together, and Lite's pre-existing self-alert fire sites (MainWindow.AlertEngine.cs) already threaded detailText through to TrySendAlertEmailAsync even before this PR, so no drift there.

One correctness/security issue worth a close look before merge — left as an inline comment on WebhookAlertService.cs: the new prose value bypasses this file's existing 'copy-paste remediation T-SQL never leaves the process on a webhook' redaction (RedactForWebhook, the per-channel IsCodeBlock→TsqlWebhookHint substitution). DetailText can and does carry literal T-SQL (the #2109 AG alert's ALTER DATABASE ... SET HADR RESUME is a real example, present in this PR's own tests), and that now ships unredacted to Teams/Slack/PagerDuty/generic webhook endpoints — a channel category this codebase has otherwise been careful to keep raw T-SQL out of. There's email-layer test coverage for that exact prose+context combination but no equivalent webhook-layer test, which is likely why it wasn't caught.

Everything else — the {{detail}} generic-webhook token, the JSON-escaping of prose (tested against a quote/injection payload), the retention-hold alert copy fix, the runbook update — looks correct and well-tested.

Review raised that the prose detail bypasses the redaction that replaces copy-paste
remediation T-SQL with a hint on every webhook channel. The rule's subject is the
GENERATED payload -- IsCodeBlock bodies and RemediationAction, built by FactRemediation,
multi-statement and in two shapes destructive. Prose is authored per alert, and where it
names a command at all that command is the recovery action and the sentence is the alert;
redacting it delivers "see email or the in-app dialog" to an operator whose only channel
is the one being redacted.

What made that a habit rather than a fact is that nothing stopped a generated command
from being folded into the prose. FindingMessageFormatter.DetailText is finding metadata
and the command rides only in the code-block item, so that is now asserted instead of
assumed, and both docs say which surface the rule covers.
@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown

Review summary

Overall this is a well-scoped, well-tested fix — the fan-out resolves detailText once (mirroring the existing triageUrl pattern), the suppression gate (AlertDetailText.ProseForDelivery) correctly keeps the eleven ContextToDetailText-derived engine alerts byte-identical, JSON injection is handled the same way as every other webhook token (EscapeForJson, verified by DetailWithAQuote_DoesNotBreakOutOfTheJsonStringLiteral), and Lite/EmailAlertService + Darling/DarlingAlertDeliverer+DarlingFindingAlertSender were updated in lockstep so the two apps stay in parity for this change.

Two things worth a look before merge (left as inline comments):

  1. Correctness — the "detail text only duplicates for engine alerts that already read correctly" premise doesn't hold for two fire sites. AlertDetailText.ProseForDelivery suppresses prose only when it is byte-equal to AlertDetailText.Flatten(context). The Database-State-Changed alert (AlertEngine.cs ~L1735) and the Force-Plan-Failing alert (~L1919) both hand-author a detailText that restates the same fields as their accompanying structured Context, but in different wording/order/labels — so the equality check never fires, and after this change both alerts will render the same facts twice (once as a "Details" paragraph, once as the structured table) on every channel. This is different from the [BUG] Database exposed as a discrete alert fact on only 2 of the database-scoped alerts #2109 AG-database case the PR explicitly designed around, where the prose adds a remedy that isn't in the structured fields at all.

  2. Minor — Settings UI wasn't updated for the new {{detail}} token. Both Lite/Windows/SettingsWindow.xaml (L648) and Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml (L819) list the generic-webhook placeholders in a tooltip/help Run, and neither mentions the new {{detail}} token added in WebhookAlertService.cs. Not a parity issue (both apps are equally missing it), but an operator configuring a custom generic template has no way to discover the token exists.

No SQL in this PR, no missing-index-DMV concerns, nothing else stood out on security/performance.

Comment thread PerformanceMonitor.Notifications/AlertDetailText.cs
Comment thread PerformanceMonitor.Notifications/WebhookAlertService.cs Outdated
…w token

Review found two fire sites that hand-author a detail text restating their own structured
fields under different labels. The suppression gate compares the prose against the
context's flattening, so it never fired for them and both alerts would now render the
same facts twice on every channel. Per #2109 the structured fields are the canonical
copy, so the restatement goes and the detail text is their flattening, as at the other
eleven sites. The one thing each prose said that its fields did not -- no baseline yet,
and that the query is running on the optimizer's plan -- is a field.

The generic channel's new token was also in neither app's Settings help text, so nothing
told an operator it exists. Both lists carry it now, and Lite's automation list picks up
the triage_url it never got. The token set is one list the matcher is built from, and the
help text is checked against that list rather than against a second copy of it: this is
the second drift of the same kind.
@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown

Review: #3297 — deliver alert DetailText on every channel

Reviewed all 6 commits (the diff is already self-corrected across several review passes: SMTP-wire pin, RedactForWebhook scope, double-render fix for the two engine alerts that hand-authored restated prose, Settings-help-text token parity). No correctness, security, or Lite/Darling parity issues found.

What I checked

  • Suppression logic (AlertDetailText.ProseForDelivery / Flatten): engine alerts pass DetailText = ContextToDetailText(context) at every fire site (11 in AlertEngine.cs, plus the two per-event split sites in LiteAlertDeliverer/DarlingAlertDeliverer), so prose is byte-identical to Flatten(context) and correctly suppressed — no double-render. The [BUG] Database exposed as a discrete alert fact on only 2 of the database-scoped alerts #2109 AG-database case (DarlingSelfAlertEvaluator.cs:1212-1227) carries independent prose alongside a structured context that does not flatten to the same text, so it survives suppression as intended — verified the wording genuinely differs from AgDatabaseContext's fields.
  • Lite/Darling parity: the actual delivery logic (EmailSendCore, WebhookAlertService, EmailTemplateBuilder, AlertDetailText) lives entirely in the shared PerformanceMonitor.Notifications/PerformanceMonitor.Alerting projects, so both apps get the fix for free. The per-app call sites (DarlingAlertDeliverer, DarlingFindingAlertSender, Lite's EmailAlertService/LiteAlertDeliverer) all thread detailText through identically. The generic-webhook Settings help text ({{detail}}, and the previously-missing {{triage_url}} on Lite's side) was updated in both Lite/Windows/SettingsWindow.xaml and Darling/.../SettingsWindow.xaml, and is now pinned against the single GenericBodyTokens list so the two can't drift again.
  • Security: the T-SQL redaction boundary (RedactForWebhook) is correctly scoped to IsCodeBlock/Remediation — the new prose path never carries generated remediation SQL, and that boundary is now asserted (TheGeneratedRemediationCommand_RidesOnlyInTheCodeBlockItem_NeverInTheProseDetailText) rather than just assumed. Webhook payloads escape the prose correctly (EscapeForJson for the generic channel's raw JSON substitution, JsonSerializer for Teams/Slack/PagerDuty's typed payloads, WebUtility.HtmlEncode for the HTML email body).
  • Retention-hold wording fix: confirmed TimescaleSupport.EnsureRetentionPoliciesAsync really does have exactly one call site (DarlingWorker.cs:1287, the startup path), so the new "restart is not optional" wording is accurate, and the stale "email does not carry any of the above" rough edge was correctly removed from docs/retention-hold-runbook.md.

Minor, non-blocking observation

In WebhookAlertService, TrySendWebhookAlertsAsync resolves prose = AlertDetailText.ProseForDelivery(detailText, context) once and passes it down as the detailText argument to each TrySend*AlertAsync helper, but BuildTeamsPayload/BuildSlackPayload/BuildGenericPayload/BuildPagerDutyPayload each call AlertDetailText.ProseForDelivery again internally on that already-resolved value. It's idempotent (confirmed: re-running the equality check against the same context can't flip the result) so there's no correctness bug, just a redundant trim+compare per channel per firing — harmless, not worth a line comment.

No inline comments — didn't find anything I'd block on.

Erik Darling added 2 commits September 11, 2026 15:14
…splay name

#3308 threaded a custom rule's display name through the same five-channel seam this
branch threads the alert's prose detail through, so every signature that gained a
parameter from one side gained one from the other. Both are kept: detailText first
(the positional form AlertIncidentRenderTests uses), displayName second.

The generic channel takes the prose but not the display name — its payload has no
title, and {{metric}} is the machine key an automation correlates on.
The two fields the conflict was in are both string?, so losing one or transposing the
pair at any of the three hops between the deliverer and the payload builders is not a
compile error — and measured against HEAD, it is not a test failure either. Dropping
displayName in DarlingAlertDeliverer, and again in EmailSendCore's webhook fan-out,
both left every suite green: each side's tests cover its payload BUILDERS, and neither
could cover the hops with the other side's field also present.

This drives the whole Darling path once with both set and reads the bytes that left
the process. Bodies are identified by a channel-native marker rather than by arrival
order, and the generic channel's deliberate exemption — prose yes, display name no,
because {{metric}} is a machine key and that channel renders no title — is asserted
here instead of assumed.
Comment thread PerformanceMonitor.Alerting/AlertEngine.cs Outdated
@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown

Reviewed. This is a well-scoped, thoroughly tested fix — traced the full delivery path (email HTML/plain text, Teams, Slack, generic webhook, PagerDuty) against AlertDetailText.ProseForDelivery's suppression gate, checked Lite/Darling parity for both the engine-alert (LiteAlertDeliverer/DarlingAlertDeliverer) and finding-alert (EmailAlertService.SendFindingAlertAsync/DarlingFindingAlertSender) paths, the generic-webhook token list vs. both Settings windows' help text, and the JSON-escaping boundary for the new {{detail}} token (the hostile-string test covers it). No correctness, parity, or security issues found in the delivery logic itself — the ProseForDelivery equality-suppression gate is sound (idempotent, so the fan-out's single resolution and each Build*Payload's own re-gate can't disagree), and the redaction boundary between generated remediation T-SQL and free-form prose is deliberately scoped and pinned by a dedicated test (TheGeneratedRemediationCommand_RidesOnlyInTheCodeBlockItem_NeverInTheProseDetailText).

One minor nit left as an inline comment on AlertEngine.cs: the Database-State-Changed rewrite adds a Baseline field that duplicates information already carried by Expected State in the pending (first-observation) case. Not a functional bug, just redundant content that will now ship on every channel.

Expected State already renders "(no baseline yet)" whenever the baseline is missing —
it has since #2109, and expectedText is computed from the same `pending` flag the extra
Baseline field was conditioned on. So the field said the same thing under a second
label, and shipped it to every channel now that the detail block is delivered.

The three canonical fields stand alone again, and the pin counts occurrences rather
than enumerating field names: a name list would pass while the fact arrived under a
label nobody thought to enumerate, which is the failure being guarded.
Comment thread PerformanceMonitor.Notifications/AlertDetailText.cs
@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown

Review summary

Traced the full delivery path this PR adds (AlertDetailText.ProseForDelivery → EmailTemplateBuilder/WebhookAlertService → each channel builder) against both AlertEngine.cs fire sites and the Darling self-alert / custom-rule fire sites. The suppression gate, the email/webhook/PagerDuty wiring, the redaction-boundary scoping, and the Lite/Darling Settings token-list parity all check out and are well covered by the new tests.

One correctness issue found and left as an inline comment on PerformanceMonitor.Notifications/AlertDetailText.cs:

Analysis-finding alerts will render their Diagnosis facts twice on every channel. AnalysisNotificationService.FindingMessageFormatter builds Context (via BuildContext) and DetailText (via DetailText) as two independently-formatted renderings of the same facts (Story/Severity/Confidence/Facts/Database/Window). Since they're never textually equal, ProseForDelivery's equality-based suppression can't recognize the redundancy the way it now does for the two AlertEngine.cs sites this PR fixed — so every finding-driven alert (CPU spikes, plan regressions, index/RCSI findings, etc., in both Lite and Darling) will show those facts twice per channel once this merges. Details and a suggested fix are in the inline comment.

Everything else — the SMTP/webhook wire-level test harness, the T-SQL-redaction scoping decision, the {{detail}} token and its Settings help-text parity between Lite and Darling, the DatabaseState/ForcePlanFailing de-duplication — looks correct and matches the PR description's own accounting of what review found.

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