Suppress false all-clears when a hazard is superseded (#145) #149

Merged
claude-bot merged 1 commit from fix/false-all-clear-supersession into main 2026-07-28 00:58:49 +00:00
Contributor

Closes #145.

Problem

NWS routinely replaces one product with another for the same hazard — an Extreme Heat Watch (XH.A) becomes a Heat Advisory (HT.Y), a watch becomes a warning. The superseded product simply drops out of the active feed, and _clear_missing_alerts read that as "the hazard is over" and announced an all-clear.

Confirmed in production: NWS_KLSX_XH_A_0002 had VTEC /O.NEW.KLSX.XH.A.0002.260726T1600Z-260729T0000Z/ and stored expires of 2026-07-29, and was cleared with a delivered all-clear on 2026-07-24 17:16 — 4.5 days early. On the Rolla location the replacing Heat Advisory was inserted at 16:28:03.81 and the watch cleared at 16:28:03.96, 150 ms apart in the same poll cycle. Same pattern in June (XH_A_0001, valid to 07-03, cleared 06-27).

Why the obvious fix is not enough

Checking "is another alert still active here" only works if the replacement was recorded. It often is not. The replacement is frequently less severe than what it replaces, so _meets_threshold drops it before any row exists. The four affected locations run min_severity = watch; the replacing advisory left no trace whatsoever, so from the database the hazard looked finished.

The guard therefore has to consult the feed at the point of geographic match, before severity filtering.

Change

Guard A — hazard-family presence. Track (location_id, hazard_family) for every alert covering a location geographically, before the severity and exclusion filters. A record whose family is still present was superseded, not lifted: it is still cleared (it really has left the feed) but its all-clear is suppressed.

Family comes from the VTEC phenomenon already embedded in nws_alert_id (NWS_{office}_{phen}_{sig}_{etn}), mapped through a small table. This matters because the phenomenon code changes across the transition — XHHT — so matching on phenomenon alone would miss exactly the case being fixed. Unmapped phenomena return None and fall through to guard B, so an incomplete table degrades to today's behaviour rather than silently swallowing real all-clears.

Guard B — equal-or-greater severity still active. Never announce an all-clear while an alert of equal or greater severity is live at that location. Deliberately placed in send_pending_lifted_notifications so it covers every clearing path — the poll sweep, an explicit Cancel, and expire_alerts_job (#147) — not just the one that produced this report. The motivating case is a Tornado Watch cancelled while a Tornado Warning is live: same code path, and there a false all-clear is a safety failure rather than a confusing message.

Suppression stamps lifted_notified_at at clear time rather than leaving it NULL, so the row is not re-selected on every subsequent cycle — the loop #148 is currently stuck in.

Tests

tests/test_all_clear_supersession.py, including two controls so the guards cannot silently over-suppress:

  • family map groups XH/HT/EH as heat and returns None for non-VTEC and unmapped phenomena
  • the exact #145 case: watch replaced by a below-threshold advisory → cleared, no all-clear
  • control: hazard genuinely gone from the feed → all-clear is sent
  • Tornado Watch cancelled with a Tornado Warning live → all-clear suppressed
  • control: only a lower-severity advisory lingering → all-clear is sent

Verification

Step Result
ruff check . passed
python -m compileall app OK
SQLite bulk tier 797 passed
alembic upgrade head on fresh Postgres 16 clean
Postgres integration tier 4 passed

Not addressed here

#146 (pagination truncation), #147 (expire_alerts_job has no feed cross-check) and #148 (Webex retry loop) are separate routes to a bad all-clear or a stuck delivery. Guard B partially mitigates #147 but is not a substitute for it.

🤖 Generated with Claude Code

Closes #145. ## Problem NWS routinely **replaces** one product with another for the same hazard — an Extreme Heat Watch (`XH.A`) becomes a Heat Advisory (`HT.Y`), a watch becomes a warning. The superseded product simply drops out of the active feed, and `_clear_missing_alerts` read that as "the hazard is over" and announced an all-clear. Confirmed in production: `NWS_KLSX_XH_A_0002` had VTEC `/O.NEW.KLSX.XH.A.0002.260726T1600Z-260729T0000Z/` and stored `expires` of 2026-07-29, and was cleared with a delivered all-clear on **2026-07-24 17:16** — 4.5 days early. On the Rolla location the replacing Heat Advisory was inserted at `16:28:03.81` and the watch cleared at `16:28:03.96`, **150 ms apart in the same poll cycle**. Same pattern in June (`XH_A_0001`, valid to 07-03, cleared 06-27). ## Why the obvious fix is not enough Checking "is another alert still active here" only works if the replacement was recorded. **It often is not.** The replacement is frequently *less* severe than what it replaces, so `_meets_threshold` drops it before any row exists. The four affected locations run `min_severity = watch`; the replacing advisory left no trace whatsoever, so from the database the hazard looked finished. The guard therefore has to consult the feed at the point of **geographic** match, before severity filtering. ## Change **Guard A — hazard-family presence.** Track `(location_id, hazard_family)` for every alert covering a location geographically, before the severity and exclusion filters. A record whose family is still present was superseded, not lifted: it is still cleared (it really has left the feed) but its all-clear is suppressed. Family comes from the VTEC phenomenon already embedded in `nws_alert_id` (`NWS_{office}_{phen}_{sig}_{etn}`), mapped through a small table. This matters because the phenomenon code *changes* across the transition — `XH` → `HT` — so matching on phenomenon alone would miss exactly the case being fixed. Unmapped phenomena return `None` and fall through to guard B, so an incomplete table degrades to today's behaviour rather than silently swallowing real all-clears. **Guard B — equal-or-greater severity still active.** Never announce an all-clear while an alert of equal or greater severity is live at that location. Deliberately placed in `send_pending_lifted_notifications` so it covers **every** clearing path — the poll sweep, an explicit Cancel, and `expire_alerts_job` (#147) — not just the one that produced this report. The motivating case is a Tornado Watch cancelled while a Tornado Warning is live: same code path, and there a false all-clear is a safety failure rather than a confusing message. Suppression stamps `lifted_notified_at` at clear time rather than leaving it NULL, so the row is not re-selected on every subsequent cycle — the loop #148 is currently stuck in. ## Tests `tests/test_all_clear_supersession.py`, including **two controls** so the guards cannot silently over-suppress: - family map groups `XH`/`HT`/`EH` as heat and returns `None` for non-VTEC and unmapped phenomena - the exact #145 case: watch replaced by a below-threshold advisory → cleared, **no** all-clear - **control:** hazard genuinely gone from the feed → all-clear **is** sent - Tornado Watch cancelled with a Tornado Warning live → all-clear suppressed - **control:** only a lower-severity advisory lingering → all-clear **is** sent ## Verification | Step | Result | |---|---| | `ruff check .` | passed | | `python -m compileall app` | OK | | SQLite bulk tier | 797 passed | | `alembic upgrade head` on fresh Postgres 16 | clean | | Postgres integration tier | 4 passed | ## Not addressed here #146 (pagination truncation), #147 (`expire_alerts_job` has no feed cross-check) and #148 (Webex retry loop) are separate routes to a bad all-clear or a stuck delivery. Guard B partially mitigates #147 but is not a substitute for it. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Suppress false all-clears when a hazard is superseded (#145)
All checks were successful
CI / test (pull_request) Successful in 4m30s
e6b0f7e70e
NWS routinely replaces one product with another for the same hazard: an
Extreme Heat Watch (XH.A) becomes a Heat Advisory (HT.Y), a watch becomes a
warning. The superseded product drops out of the active feed, and
_clear_missing_alerts read that as "the hazard is over" and announced an
all-clear -- confirmed in production, where NWS_KLSX_XH_A_0002 had expires
2026-07-29 and was cleared with an all-clear on 2026-07-24.

Checking for another active record is not sufficient on its own: the
replacement is often LESS severe than what it replaces, so it is dropped by
the location's min_severity filter before any record exists. On the affected
locations (min_severity=watch) the replacing advisory left no trace at all, so
from the database the hazard looked finished.

Two guards:

A. Track (location_id, hazard_family) for every alert covering a location
   GEOGRAPHICALLY, before the severity and exclusion filters. A record whose
   family is still present was superseded, not lifted: it is still cleared,
   but its all-clear is suppressed. Family comes from the VTEC phenomenon
   already embedded in nws_alert_id, mapped through a small table, so a
   watch->advisory transition stays in one family even though the phenomenon
   code changes (XH -> HT). Unmapped phenomena fall through to guard B rather
   than suppressing anything.

B. Never announce an all-clear while an alert of equal or greater severity is
   still active at that location. Placed in send_pending_lifted_notifications
   so it covers every clearing path -- the poll sweep, an explicit Cancel, and
   expire_alerts_job (#147) -- not just the one that caused this report. The
   motivating case is a Tornado Watch cancelled while a Tornado Warning is
   live, where a false all-clear is a safety failure rather than a confusing
   message.

Suppression stamps lifted_notified_at at clear time rather than leaving it
NULL, so the row is not re-selected every cycle forever (the loop #148 is
stuck in).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/WeatherBot!149
No description provided.