False all-clear when NWS replaces an alert (watch -> advisory/warning) #145
Labels
No labels
area:ai
area:ci-cd
area:notifications
area:observability
area:public-pages
backlog
bug
duplicate
enhancement
help wanted
invalid
question
type:decision
type:feature
type:infra
type:maintenance
type:security
v1.0.1
v1.1.0
v1.2.0
v1.3.0
v2.0.0
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
rbrooks/WeatherBot#145
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Confirmed against production data. An alert that NWS replaces with a different product is treated as "the hazard is over" and triggers an all-clear notification, days before the hazard actually ends.
Evidence
Reported symptom: heat warnings issued for 7/24–7/28, bot announced on 7/24 that they had expired/been cancelled.
sent_alerts(dashboard rows) for the affected locations:NWS_KLSX_XH_A_0002NWS_KSGF_XH_A_0001NWS_KSGF_HT_Y_0004VTEC on the cleared watch:
/O.NEW.KLSX.XH.A.0002.260726T1600Z-260729T0000Z/— valid through 07-29.expireswas parsed and stored correctly.lifted_notified_atis set, so an all-clear really was delivered.The watch was cleared 4.5 days before its own expiry, so this is not the time-based path. It is
_clear_missing_alertspath 2 (alert_processor.py:517-533): still valid by time, but absent fromactive_matches, therefore "disappeared" → cleared → all-clear.Why it left the feed: NWS replaced it. On the Rolla location the Heat Advisory
HT.Y.0004was inserted at16:28:03.81and the watch cleared at16:28:03.96— 150 ms apart, same poll cycle. A watch→advisory transition, read as a cancellation.Not a one-off — the same pattern in June:
NWS_KLSX_XH_A_0001, VTEC valid to260703T0500Z, cleared2026-06-27 18:16, ~5 days early.Aggravating factor: the replacement can be invisible
The four LSX locations have
min_severity = watch. The replacing Heat Advisory is advisory-severity, so it was filtered out before any record was created. Those locations got an all-clear and nothing else, in the middle of an escalating heat event. Only Rolla (min_severity = advisory) shows the replacement row.The general defect, and why it is worse than heat
_clear_recordsandsend_pending_lifted_notificationssend an all-clear per(lifecycle, location)with no check for any other active alert at that location. Nothing distinguishes:For heat this is confusing. For a Tornado Watch cancelled while a Tornado Warning is active at the same location, the same code path sends "all clear" during an active warning. That is a life-safety failure mode, not a cosmetic one.
Note
SentAlert.upgrade_fromalready exists (migration 0010) but is only used for SPC outlook risk transitions — there is no equivalent for NWS watch→warning.Suggested fix
UPG/CAN; the superseded lifecycle can be linked viaupgrade_fromand the message reworded ("upgraded to X") instead of an all-clear.min_severitycurrently produces silence where the user has just been told the hazard ended. At minimum the all-clear should not fire when a lower-severity successor exists.Fix 1 is the one to land first — it is small, and it is the difference between a confusing message and a dangerous one.
Picking this up on branch
fix/false-all-clear-supersession.Design note after re-reading the pipeline: the obvious fix does not actually fix the reported case. Checking "is another alert still active at this location" only works when the replacement was recorded — and for the four LSX locations it was not.
_process_single_alertfilters in this order (alert_processor.py:319-330):_location_matches_alert— geographic_meets_threshold— severity vsLocation.min_severityactive_matchesonly collects entries surviving all three. The replacing Heat Advisory is advisory-severity, those locations aremin_severity = watch, so it was dropped at step 2 and nosent_alertsrow ever existed. From the DB alone the hazard looks gone.So the guard has to consult the feed at the point of geographic match, before severity filtering.
Approach
Two complementary guards:
A. Hazard-family presence (fixes this bug). Track
(location_id, hazard_family)for every alert that matches a location geographically, regardless of whether it clears the severity threshold. When clearing a record whose family is still present at that location, mark it cleared but suppress the all-clear. Family comes from the VTEC phenomenon already embedded inSentAlert.nws_alert_id(NWS_{office}_{phen}_{sig}_{etn}→XH), mapped through a small table — heat isEH/HT/XH, so a watch→advisory transition stays inside one family even though the phenomenon code changes.B. Equal-or-greater severity still active (fixes the dangerous case). Before sending any all-clear, suppress if another uncleared alert of equal or greater severity is active at that location. Placed in
send_pending_lifted_notificationsso it covers every clearing path — the poll sweep,_process_cancel, andexpire_alerts_job(#147) alike — rather than only the "disappeared" path. This is what stops "all clear" going out while a Tornado Warning is live.Suppression means setting
lifted_notified_atat clear time, not leaving it NULL — otherwise the row is re-selected every cycle forever, which is exactly the loop #148 is stuck in.Fixed in #149 (merged to
main). CI green in 4m30s.Shipped: two guards.
A — hazard-family presence.
(location_id, hazard_family)is now tracked for every alert covering a location geographically, before the severity and exclusion filters. A record whose family is still present was superseded rather than lifted: still cleared, all-clear suppressed. Family is derived from the VTEC phenomenon already embedded innws_alert_id, so a watch→advisory transition stays in one family despite the phenomenon code changing (XH→HT) — the exact reason phenomenon-matching alone would have missed this.B — equal-or-greater severity still active. No all-clear goes out while something at least as severe is live at that location. Placed in
send_pending_lifted_notificationsso it covers every clearing path, includingexpire_alerts_job(#147), not just the poll sweep that caused this report.Suppression stamps
lifted_notified_atrather than leaving it NULL, so suppressed rows are not re-selected forever — the failure mode #148 is currently in.Verified locally in Docker before push: ruff clean,
compileallOK, 797 SQLite-tier tests, migrations clean on Postgres 16, 4 Postgres-tier tests. Two of the new tests are deliberately controls — hazard genuinely gone, and only a lower-severity alert lingering — so the guards cannot silently over-suppress real all-clears.Worth knowing:
Noneand falls through to guard B, degrading to previous behaviour rather than swallowing a real all-clear. If a hazard family shows up that should be grouped and is not, it is a one-line addition to_VTEC_HAZARD_FAMILIES.cleared_atandlifted_notified_atset from 7/24 — nothing re-sends or un-sends. The fix applies from the next supersession onward.expire_alerts_jobstill clears on stale timestamps during an NWS outage; guard B only stops the notification when something more severe happens to be recorded.