fix(nws): normalize severities to schema; drop CAP pre-filter

Remove the nws_handler broadcast_severities/warning_suffix_promotes
pre-filter (GATE A) that dropped sub-Severe NWS products before the
pipeline. All NWS alerts now normalize to routine/priority/immediate
(map_severity) and breadth is governed solely by the per-toggle
dispatcher threshold. Warning-class categories are promoted to
immediate so a wrong/missing CAP severity int cant under-rank a real
warning. broadcast_severities/warning_suffix_promotes are now inert
(marked deprecated). Fixes sub-Severe alerts (Special Weather
Statements / advisories) for Magic Valley / East Idaho never reaching
the mesh despite a routine toggle threshold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-06-28 00:12:27 +00:00
commit e6bc101194
3 changed files with 62 additions and 25 deletions

View file

@ -78,7 +78,7 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
("nws", "broadcast_severities"): {
"default": ["Extreme", "Severe"], # nws_handler.py:43
"type": "json",
"description": "CAP severity strings allowed onto the mesh.",
"description": "CAP severity strings allowed onto the mesh. [DEPRECATED — no longer enforced; NWS breadth is governed by the per-toggle dispatcher severity threshold]",
},
("nws", "tombstone_msgtypes"): {
"default": ["Cancel", "Expire"], # nws_handler.py:46
@ -88,7 +88,7 @@ REGISTRY: dict[tuple[str, str], dict[str, Any]] = {
("nws", "warning_suffix_promotes"): {
"default": True, # nws_handler.py:172
"type": "bool",
"description": "Promote category-name-ending-in-_warning to Severe when CAP severity is missing.",
"description": "Promote category-name-ending-in-_warning to Severe when CAP severity is missing. [DEPRECATED — no longer enforced; NWS breadth is governed by the per-toggle dispatcher severity threshold]",
},
# =================================================================

View file

@ -240,20 +240,17 @@ def handle_nws(envelope: dict, subject: str,
table_name="nws_alerts", table_pk=cap_id)
return None
# Severity gate (CAP string from data.severity, fall back to category
# heuristic for envelopes that lack the field).
cap_sev = d.get("severity")
if cap_sev not in set(adapter_config.nws.broadcast_severities):
# Heuristic: category like wx.alert.severe_thunderstorm_warning ->
# treat as Severe even when CAP severity field is missing.
# v0.6-3b: gated by adapter_config.nws.warning_suffix_promotes.
if (not bool(adapter_config.nws.warning_suffix_promotes)) or not (
category_raw.endswith("_warning") or category_raw.endswith(".warning")):
_log_event(conn, now=now, source="nws", category=category_raw,
severity_word=severity_word, event_id_external=cap_id,
subject=subject, handled=0,
table_name="nws_alerts", table_pk=cap_id)
return None
# CAP-severity pre-filter (GATE A) removed. All NWS alerts now flow into
# the notification pipeline; breadth is governed solely by the per-toggle
# dispatcher severity threshold.
# Warning → immediate promotion: deterministically sets _severity_override
# so a wrong or missing CAP severity field cannot under-rank a real warning.
# Mirrors the wfigs_handler pattern. Applied before all broadcast-return
# paths so it covers new alert, cold-start race, and dedup-window re-bcast.
if isinstance(data, dict) and (
category_raw.endswith("_warning") or category_raw.endswith(".warning")):
data["_severity_override"] = "immediate"
# Per-CAP-id dedup.
log_id = _log_event_returning_id(

View file

@ -73,26 +73,31 @@ def test_extreme_emergency_broadcasts(mem_db):
assert wire.startswith("🌪️")
def test_special_weather_statement_skipped(mem_db):
def test_special_weather_statement_passes_through(mem_db):
# GATE A removed: Minor/SWS is no longer dropped on CAP severity alone.
env = _nws_env(severity_str="Minor", event="Special Weather Statement",
category="wx.alert.special_weather_statement")
data = {}
wire = handle_nws(env, env["subject"], data=data, now=1_000_000)
assert wire is None
assert wire is not None, "SWS should now pass through (GATE A removed)"
assert "Special Weather Statement" in wire
# Row inserted in nws_alerts (not a warning category → no override).
n_rows = mem_db.execute("SELECT COUNT(*) AS n FROM nws_alerts").fetchone()["n"]
assert n_rows == 0
n_log = mem_db.execute(
"SELECT COUNT(*) AS n FROM event_log WHERE source='nws' AND handled=0"
).fetchone()["n"]
assert n_log == 1
assert n_rows == 1
# _severity_override should NOT be set for a non-warning category.
assert data.get("_severity_override") is None
def test_watch_severity_moderate_skipped(mem_db):
def test_watch_severity_moderate_passes_through(mem_db):
# GATE A removed: Moderate watches now pass through; dispatcher threshold governs.
env = _nws_env(severity_str="Moderate", event="Severe Thunderstorm Watch",
category="wx.alert.severe_thunderstorm_watch")
data = {}
wire = handle_nws(env, env["subject"], data=data, now=1_000_000)
assert wire is None
assert wire is not None, "Moderate watch should now pass through (GATE A removed)"
assert "Severe Thunderstorm Watch" in wire
# Watches end in _watch, not _warning — no severity override.
assert data.get("_severity_override") is None
# ---- emoji map ----
@ -199,3 +204,38 @@ def test_wire_includes_event_and_headline(mem_db):
wire = handle_nws(env, env["subject"], data={}, now=1_000_000)
assert "Severe Thunderstorm Warning" in wire
assert "Twin Falls County" in wire
# ---- warning → immediate promotion (Step 2) ----
def test_warning_category_sets_severity_override_immediate(mem_db):
"""A *_warning category sets data[_severity_override]='immediate'."""
env = _nws_env(severity_str="Severe", event="Severe Thunderstorm Warning",
category="wx.alert.severe_thunderstorm_warning")
data = {}
wire = handle_nws(env, env["subject"], data=data, now=1_000_000)
assert wire is not None
assert data.get("_severity_override") == "immediate"
def test_tornado_warning_dotted_category_sets_severity_override(mem_db):
"""A category ending in .warning also sets _severity_override='immediate'."""
env = _nws_env(severity_str="Extreme", event="Tornado Warning",
category="wx.alert.tornado_warning")
# Override the data.data.severity to use dotted-style category check
env["data"]["category"] = "wx.alert.tornado.warning"
env["data"]["data"]["severity"] = "Extreme"
data = {}
wire = handle_nws(env, env["subject"], data=data, now=2_000_000)
assert wire is not None
assert data.get("_severity_override") == "immediate"
def test_non_warning_category_no_severity_override(mem_db):
"""A non-warning category (watch, advisory, statement) leaves no override."""
env = _nws_env(severity_str="Severe", event="Severe Thunderstorm Watch",
category="wx.alert.severe_thunderstorm_watch")
data = {}
wire = handle_nws(env, env["subject"], data=data, now=3_000_000)
assert wire is not None
assert "_severity_override" not in data