Deduplicating CAP Updates Across Alerting Authorities
A public-facing alert map shows two active evacuation orders for the same neighbourhood with different boundaries. Both are real CAP messages, both are current, and one of them was superseded twenty minutes ago — by an update that referenced the county’s original identifier, which the relayed copy never carried.
Root Cause and Operational Impact
CAP is designed to be relayed. A county issues an alert, a state emergency operations centre republishes it, a federal aggregator republishes that, and each hop is entitled to assign its own identifier and sender. That is correct behaviour and it means a consumer aggregating several feeds routinely receives one decision several times, under identifiers that have nothing in common.
Duplication on its own is a display nuisance. The real failure is what happens at the update. When the county extends the boundary and issues an update, its references names only its own original identifier — the one the state and federal copies were never issued under. Those copies are not superseded, so a consumer holding them continues to present the old boundary as current, alongside the new one.
That is the operational cost: not two copies of one message, but one message that has been withdrawn and one that has not, describing the same neighbourhood with different instructions.
Tiered Resolution Strategy
- Key on the decision, not the message (definitive). Store messages under
senderplusidentifier, which is their true unique key, but group them under a decision identifier the consumer assigns. - Use the references chain when it is present. It is authoritative and cheap. It is also absent far more often than the specification implies, so it cannot be the only mechanism.
- Fall back to a content hash over the fields that carry meaning. Event, area, effective window and instruction. Two byte-equivalent relays hash identically regardless of who sent them.
- Fall back again to a spatio-temporal key, and flag whatever it merges (safe default). Overlapping area, same event type, effective windows within a tolerance. This catches relays that reworded the headline, and it can occasionally merge two genuinely distinct nearby incidents — so anything it merges is flagged rather than merged silently.
- Apply supersession across the whole decision, never to a single message. This is the step that fixes the failure above, and it is only possible because the copies were kept rather than discarded.
The layering matters because each key is wrong on its own. The references chain is exact and frequently missing. A content hash is exact and defeated by a relay that changed one word of the headline. The spatio-temporal key catches those and is the only one that can produce a false merge, which is why it is last and why its merges are reviewable.
Keeping the copies is the part that looks wasteful and is load-bearing. A consumer that dedupes by discarding has nothing to apply a later supersession to — the very copies that need withdrawing are the ones it threw away.
Production Python Implementation
from __future__ import annotations
import hashlib
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from shapely.geometry.base import BaseGeometry
logger = logging.getLogger("incidentgis.cap_dedup")
EFFECTIVE_TOLERANCE = timedelta(minutes=10)
AREA_OVERLAP_MIN = 0.80
# Higher wins when choosing which copy is authoritative for a decision.
SENDER_PRECEDENCE = {"county": 3, "state": 2, "federal": 1}
@dataclass(frozen=True)
class CapMessage:
sender: str
identifier: str
msg_type: str # Alert | Update | Cancel | Error
references: tuple[str, ...]
event: str
area: BaseGeometry
effective: datetime
instruction: str
sent: datetime
@property
def key(self) -> tuple[str, str]:
"""The message's true unique key — never the identifier alone."""
return (self.sender, self.identifier)
@property
def content_hash(self) -> str:
"""Hash the fields that carry the decision, not the presentation."""
payload = "|".join([
self.event,
self.area.wkt,
self.effective.isoformat(),
self.instruction.strip().lower(),
])
return hashlib.sha256(payload.encode()).hexdigest()
@dataclass
class Decision:
decision_id: str
members: set[tuple[str, str]] = field(default_factory=set)
authoritative: CapMessage | None = None
superseded: bool = False
needs_review: bool = False
merged_by: dict[str, str] = field(default_factory=dict)
class DecisionIndex:
"""Group relayed CAP messages into the decisions they describe."""
def __init__(self) -> None:
self._by_key: dict[tuple[str, str], str] = {}
self._by_hash: dict[str, str] = {}
self._decisions: dict[str, Decision] = {}
self._counter = 0
def _new_decision(self) -> Decision:
self._counter += 1
d = Decision(decision_id=f"d-{self._counter:06d}")
self._decisions[d.decision_id] = d
return d
def _match(self, msg: CapMessage) -> tuple[Decision | None, str]:
# Tier 2: the references chain, when the relay bothered to populate it.
for ref in msg.references:
for (sender, ident), did in self._by_key.items():
if ident == ref:
return self._decisions[did], "references"
# Tier 3: byte-equivalent relays.
did = self._by_hash.get(msg.content_hash)
if did:
return self._decisions[did], "content_hash"
# Tier 4: reworded relays — may over-merge, so it is flagged.
for d in self._decisions.values():
other = d.authoritative
if other is None or other.event != msg.event:
continue
if abs(other.effective - msg.effective) > EFFECTIVE_TOLERANCE:
continue
inter = other.area.intersection(msg.area).area
union = other.area.union(msg.area).area
if union and inter / union >= AREA_OVERLAP_MIN:
return d, "spatio_temporal"
return None, "new"
def ingest(self, msg: CapMessage) -> Decision:
decision, how = self._match(msg)
if decision is None:
decision = self._new_decision()
decision.members.add(msg.key)
decision.merged_by[f"{msg.sender}/{msg.identifier}"] = how
if how == "spatio_temporal":
# A merge the fallback made is a merge a human should confirm.
decision.needs_review = True
self._by_key[msg.key] = decision.decision_id
self._by_hash.setdefault(msg.content_hash, decision.decision_id)
if msg.msg_type == "Cancel":
# Supersession applies to the decision, so every relayed copy is
# withdrawn — including ones whose identifier was never referenced.
decision.superseded = True
elif decision.authoritative is None or _outranks(msg, decision.authoritative):
decision.authoritative = msg
logger.info("cap_message_ingested", extra={
"decision": decision.decision_id, "matched_by": how,
"members": len(decision.members), "review": decision.needs_review,
})
return decision
def _outranks(a: CapMessage, b: CapMessage) -> bool:
"""Higher-precedence sender wins; ties break on the later sent time."""
pa = SENDER_PRECEDENCE.get(a.sender, 0)
pb = SENDER_PRECEDENCE.get(b.sender, 0)
return (pa, a.sent) > (pb, b.sent)
Validation Checklist
- Messages are stored under
senderplusidentifier, never under the identifier alone. - The references chain is used first and its absence is expected rather than treated as an error.
- The content hash covers event, area, effective window and instruction — and not the headline.
- Spatio-temporal merges set a review flag and are visible in the operator interface.
- Supersession is applied to every member of a decision, not to the referenced message alone.
- Copies are retained after merging, so a later supersession has something to apply to.
- Sender precedence is configuration, agreed with the participating authorities in advance.
- A fixture reproduces the county-state-federal relay chain and asserts one decision with four members.
Edge Cases and Gotchas
- A relay that alters the polygon slightly. Reprojection round trips can move vertices by centimetres, which changes the WKT and defeats the content hash. Normalise coordinate precision before hashing, exactly as the deterministic artifact rule requires elsewhere.
- Two real incidents in the same block within ten minutes. The spatio-temporal fallback will merge them. That is the cost of catching reworded relays, and the review flag is what makes it recoverable — do not tighten the tolerance until it stops catching relays.
- A cancel that arrives before the alert it cancels. Feeds reorder. Create the decision on the cancel and mark it superseded, so the alert is born withdrawn rather than resurrecting the order.
- Sender precedence disagreements. If two authorities each believe they outrank the other, the authoritative copy flips as messages arrive. Agree precedence in advance and treat an unknown sender as lowest rather than defaulting it into the middle.
- An update that changes the event type. A wildfire evacuation updated to a flood evacuation is arguably a new decision. Treat an event-type change as a new decision and reference the old one, rather than mutating a decision’s meaning in place.
Frequently Asked Questions
Why does deduplicating CAP alerts by identifier not work? Because every relay is entitled to assign its own identifier and sender. A county issues an alert, a state operations centre republishes it under a new identifier, and a federal aggregator republishes that under another, so one decision arrives three times with nothing in common between the identifiers. Sender plus identifier is the correct unique key for a message, but it says nothing about whether two messages describe the same decision — which is the question a consumer aggregating several feeds actually needs answered.
What actually goes wrong when relayed copies are not linked? The update. When the county extends an evacuation boundary and issues an update, its references field names only its own original identifier, which the relayed copies were never issued under. Those copies are therefore not superseded, and a consumer holding one continues presenting the old boundary as current alongside the new one. The visible failure is two active evacuation orders for the same neighbourhood with different instructions, one of which was withdrawn twenty minutes earlier.
Why keep the duplicate copies instead of discarding them? Because supersession has to be applied to them later. A consumer that dedupes by keeping the newest message and discarding the rest has nothing to withdraw when a cancel or update arrives — the copies that most need withdrawing are precisely the ones it threw away. Collapsing into a decision record that lists every member, names the authoritative copy by sender precedence, and records which key merged each one keeps the supersession applicable and makes any merge made by the fuzzy fallback reviewable.
Related
- Public Alerting & CAP Message Pipelines — why references and expires carry the risk in the message this consumer receives.
- Deduplicating Replayed Incident Messages After Broker Failover — the same problem where the producer controls the key, which is why that one is easier.
- Resolving Duplicate Incident Reports Across Jurisdictions — the spatio-temporal scoring this fallback key is a narrow case of.
- Conflict Resolution in Multi-Agency Edits — sender precedence as a policy artefact agreed before an incident rather than during one.
Up: Public Alerting & CAP Message Pipelines