Monitoring an Area for Suspicious OSM Edits Jump to heading

If your product depends on one city being right, you want to know within the hour when somebody deletes half of it — and you want that signal without an alert for each of the two thousand ordinary edits that also happened.

Prerequisites Jump to heading

Conceptual minimum Jump to heading

The stream gives changeset metadata — bounding box, user, editor, comment, counts of created, modified and deleted elements — on a minutely cadence. The work is turning that into a short ranked list.

Score, do not classify. A binary “suspicious or not” forces a threshold nobody can defend and produces arguments about individual cases. A score sorts the queue, and the reviewer works down it until the findings stop being interesting, which is self-calibrating in a way a threshold is not.

Most signals are weak alone and useful together. A large deletion count is normal for an import cleanup. A new account is normal for the great majority of contributors, who are simply new. A vague changeset comment means very little. All three at once, inside a small area, on features your product depends on, is a different matter.

Bias toward consequence, not intent. You are not adjudicating whether somebody meant harm; you are deciding what to look at. An accidental mass delete from a misconfigured editor is as damaging as a deliberate one and considerably more common, and a scoring system aimed at consequence catches both.

The most important design constraint is that the queue must stay short enough to be worked. A monitor producing forty items a day gets ignored within a fortnight, and the correct response to a long queue is to raise the bar rather than to expect more attention.

Three signals that mean little alone and a great deal together Three panels. A large deletion count is entirely normal in isolation, since import cleanups and duplicate removals routinely delete thousands of elements legitimately. A brand new account is normal in isolation too, because the overwhelming majority of new accounts belong to people who have simply started mapping. A vague or empty changeset comment is extremely common and carries almost no information by itself. Combined within a small area and touching features a product depends on, the three together are worth a human look, which is why the design scores rather than classifies. Weak signals, combined Large deletion count Normal in isolation Import cleanups delete Duplicate removal too Needs context A new account Most new accounts are fine People start mapping daily Suspicion alone is unfair Needs context A vague comment Extremely common Carries little alone Editors default it Needs context Any one of these as a trigger produces a queue nobody works through; all three together produce a handful of items a week.
The combination is the signal. Each part on its own describes ordinary mapping.

Runnable solution Jump to heading

python
from __future__ import annotations

import gzip
import logging
import re
import urllib.request
import xml.etree.ElementTree as ET
from collections.abc import Iterable, Iterator
from dataclasses import dataclass, field
from datetime import datetime, timezone

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.changeset.watch")

STREAM = "https://planet.openstreetmap.org/replication/changesets"
USER_AGENT = "area-edit-monitor/1.0 (ops@example.net)"
QUEUE_TARGET = 5          # items per day; raise the bar if it overflows

VAGUE_COMMENT = re.compile(r"^\s*(|\.|update|edit|fix|test|asdf)\s*$", re.I)


@dataclass(frozen=True)
class Box:
    west: float
    south: float
    east: float
    north: float

    def overlaps(self, other: "Box") -> bool:
        return not (other.east < self.west or other.west > self.east
                    or other.north < self.south or other.south > self.north)

    @property
    def area_deg2(self) -> float:
        return max(self.east - self.west, 0) * max(self.north - self.south, 0)


@dataclass
class Changeset:
    id: int
    user: str
    uid: int
    created_at: str
    closed_at: str | None
    comment: str
    editor: str
    box: Box | None
    created: int = 0
    modified: int = 0
    deleted: int = 0


@dataclass
class Score:
    changeset: Changeset
    points: float
    reasons: list[str] = field(default_factory=list)


def fetch(sequence: int, stream: str = STREAM) -> Iterator[Changeset]:
    text = f"{sequence:09d}"
    url = f"{stream}/{text[0:3]}/{text[3:6]}/{text[6:9]}.osm.gz"
    request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    with urllib.request.urlopen(request, timeout=60) as response:
        payload = gzip.decompress(response.read())
    for element in ET.fromstring(payload).findall("changeset"):
        tags = {t.get("k"): t.get("v") for t in element.findall("tag")}
        box = None
        if element.get("min_lon"):
            box = Box(float(element.get("min_lon")), float(element.get("min_lat")),
                      float(element.get("max_lon")), float(element.get("max_lat")))
        yield Changeset(
            id=int(element.get("id")), user=element.get("user", "?"),
            uid=int(element.get("uid", 0)), created_at=element.get("created_at"),
            closed_at=element.get("closed_at"), comment=tags.get("comment", ""),
            editor=tags.get("created_by", "unknown"), box=box,
            created=int(element.get("num_changes", 0)))


def score(changeset: Changeset, watched: Box,
          known_users: set[int], critical_hits: int = 0) -> Score:
    """Bias toward CONSEQUENCE, not intent.

    An accidental mass delete from a misconfigured editor does the same damage
    as a deliberate one and is considerably more common.
    """
    result = Score(changeset, 0.0)

    if changeset.deleted >= 500:
        result.points += 4
        result.reasons.append(f"{changeset.deleted:,} deletions")
    elif changeset.deleted >= 100:
        result.points += 2
        result.reasons.append(f"{changeset.deleted:,} deletions")

    if critical_hits:
        result.points += min(4.0, critical_hits * 0.5)
        result.reasons.append(f"touches {critical_hits} feature(s) we depend on")

    # A new account is NOT suspicious by itself; it only adds weight to a
    # changeset that already looks consequential.
    if changeset.uid not in known_users and result.points > 0:
        result.points += 1
        result.reasons.append("editor not seen in this area before")

    if VAGUE_COMMENT.match(changeset.comment) and result.points > 0:
        result.points += 0.5
        result.reasons.append("no meaningful changeset comment")

    # A wide bounding box for few changes means edits scattered far apart,
    # which is what a careless bulk operation looks like.
    if changeset.box and changeset.box.area_deg2 > watched.area_deg2 * 4:
        result.points += 1.5
        result.reasons.append("extent far larger than the watched area")

    return result


def watch(sequences: Iterable[int], watched: Box, known_users: set[int],
          floor: float = 4.0) -> list[Score]:
    queue: list[Score] = []
    for sequence in sequences:
        for changeset in fetch(sequence):
            if changeset.box is None or not watched.overlaps(changeset.box):
                continue
            scored = score(changeset, watched, known_users)
            if scored.points >= floor:
                queue.append(scored)

    queue.sort(key=lambda s: s.points, reverse=True)
    logger.info("%d changeset(s) above the floor", len(queue))
    for item in queue[:20]:
        logger.info("%5.1f  cs/%d by %s — %s", item.points, item.changeset.id,
                    item.changeset.user, "; ".join(item.reasons))
    return queue


def calibrate(queue: list[Score], days: int, target: int = QUEUE_TARGET) -> float:
    """A queue nobody works through protects nothing. Raise the bar instead
    of expecting more attention."""
    per_day = len(queue) / max(days, 1)
    if per_day <= target:
        return 0.0
    keep = sorted((s.points for s in queue), reverse=True)[:target * days]
    logger.warning("%.1f items/day against a target of %d; suggest floor %.1f",
                   per_day, target, keep[-1] if keep else 0.0)
    return keep[-1] if keep else 0.0


if __name__ == "__main__":
    logger.info("score, rank, and keep the queue short enough to be worked")

Step-by-step walkthrough Jump to heading

  1. Filter by bounding box first. The stream is global and the overwhelming majority of changesets are irrelevant to you, so the cheapest possible test comes first.
  2. Score rather than classify. Ranking lets the reviewer decide where to stop, and it avoids a threshold argument about any individual changeset.
  3. Weight deletions heavily. Deletion is the hardest edit to notice downstream and the most expensive to recover from, which makes it the highest-value signal.
  4. Let account age and comment quality act as multipliers only. They add weight to something already consequential and should never trigger on their own — fairer, and more accurate.
  5. Treat a wide extent with few changes as a signal. Edits scattered across a large box are what a careless bulk operation looks like from the metadata alone.
  6. Check whether critical features were touched. This requires resolving the changeset’s elements against your own dependency list, and it is the single most informative signal available.
  7. Calibrate the floor from volume. If the queue exceeds what somebody will actually review, raise the bar; expecting more attention has never once worked.
  8. Identify yourself in the user agent. The stream is a shared resource, and an anonymous poller is a poor neighbour.
From a global stream to a queue somebody works through Four steps. The minutely changeset stream carries every changeset worldwide, which is far too many to consider individually. A bounding-box test discards everything not overlapping the watched area, which is the cheapest filter and removes the overwhelming majority. Each survivor is scored on deletion volume, whether it touched features the product depends on, the editor's history in this area, comment quality and extent, producing a number rather than a verdict. The scored items above a floor are ranked and presented as a queue, with the floor recalibrated whenever the daily volume exceeds what a reviewer will actually work through. Filter, score, rank, calibrate global stream every changeset far too many box filter cheapest test first most discarded score a number, not a verdict weak signals combined rank and calibrate reviewer stops when bored floor follows volume The last step is the one that keeps the system alive: a queue that outgrows its reviewer is abandoned rather than triaged.
Nothing here decides anything. It decides what a person looks at first.
What each signal contributes, and why none of them triggers alone A grid of five scoring signals against the weight each carries and the reason it cannot stand on its own. Deletion volume carries the most weight because deletion is the hardest edit to notice downstream, but large legitimate deletions happen during import cleanups. Critical-feature overlap carries heavy weight and requires resolving elements against your own dependency list. An unfamiliar editor carries light weight and only ever acts as a multiplier, because the great majority of new accounts belong to people who have simply started mapping. A vague comment carries the lightest weight because editors default it. An extent far larger than the changes justify carries moderate weight as the signature of a careless bulk operation. Five signals and their weights Weight Why not alone Deletion volume heaviest import cleanups delete Critical features hit heavy needs your own list Unfamiliar editor light, multiplier only most new accounts are fine Vague comment lightest editors default it Extent versus changes moderate large areas are edited Scoring on consequence rather than intent is what makes the third row a multiplier instead of a trigger — fairer, and more accurate.
Every weight here is a judgement, which is why the floor is calibrated from volume rather than argued about.

Verification Jump to heading

  • A synthetic mass delete ranks first. Replay a historical large-deletion changeset and confirm it tops the queue.
  • Ordinary mapping does not appear. Run a week of real data over a quiet area and confirm the queue is near empty.
  • New accounts alone do not score. Confirm a small edit by a first-time editor produces nothing.
  • The box filter is tight. Confirm changesets adjacent to but outside the area are excluded.
  • Calibration responds. Feed a noisy period and confirm the suggested floor rises.

Common errors and fixes Jump to heading

Symptom Root cause One-line fix
Queue too long to review Floor set below what anyone will work Calibrate the floor from observed volume
New contributors constantly flagged Account age used as a standalone trigger Make it a multiplier on an already-scoring changeset
Genuine vandalism missed Only deletions scored Add critical-feature overlap and extent signals
Irrelevant changesets reviewed Bounding box too generous Tighten the box, or test against the actual boundary
Monitor stops without notice No heartbeat on the poller Alert on the stream sequence failing to advance
Requests throttled No identifying user agent Identify the client and poll politely
Arguments about individual items Binary classification Score and rank; let the reviewer decide where to stop

Specification reference Jump to heading

The changeset replication stream publishes minutely .osm.gz files containing changeset metadata: identifier, user, timestamps, the bounding box of the changes, the number of changes and the changeset’s tags, including comment and created_by. The bounding box is present only for changesets that modified at least one element with a location. Element-level detail is not included and must be retrieved separately. See the OpenStreetMap changeset replication documentation and the changeset API.

Frequently Asked Questions Jump to heading

Should the monitor revert anything automatically?

No. Automated reverts are how a false positive becomes an edit war, and the community norms around reverting exist for good reasons — a revert is itself an edit, attributed to you, that another mapper will have to review. The monitor’s output is a queue for a person who can look at the changeset, read the comment, check imagery and, where necessary, message the editor. That path resolves far more cases than a revert does.

How do you decide which features are critical?

From what your product actually breaks without, which is usually a much shorter list than people expect: the boundary polygons your aggregates depend on, the road classes your routing uses, the named places your search resolves. Deriving it from your own schema rather than from importance in the abstract keeps it short and keeps the scoring sharp, and it is worth revisiting whenever the schema changes.

What is a polite polling interval?

Matching the stream’s own cadence is fine — a minutely file published every minute can be fetched every minute — and conditional requests using the file’s ETag or last-modified time cost the server almost nothing when there is nothing new. What is not fine is polling several times a minute hoping to be first, or fetching without identifying yourself. The stream is run for everybody and the etiquette is the same as for any shared service.

Should this run against a local database instead?

If you already replicate the area, comparing before and after states gives element-level detail the changeset metadata cannot, including exactly which features changed and how. That is strictly more informative and considerably more work. The changeset stream is the low-cost version that runs without any local data, and running both — metadata for the fast signal, local diff for the detail once something is flagged — is a good arrangement.

Up one level: Changeset Analysis & Vandalism Detection.