Resolving Simultaneous Perimeter Edits During Surge

At 0300 during the second operational period of a wind-driven wildfire, two agencies are editing the same perimeter polygon in the shared incident feature service. A state fire mapper, working from a fresh infrared flight, drags the northeast edge out three hundred metres to enclose a spot fire that jumped the ridge. At almost the same moment a county analyst, working from a windshield report, trims a bulge on the southwest edge that a previous shift had over-drawn. Both save within the same sync window. The naive service keeps whichever write landed last, and the other agency’s change vanishes without a trace — either the spot fire is no longer inside the perimeter, or the corrected bulge reappears. During surge, that lost edit is not a cosmetic glitch: it is a piece of the hazard picture that silently reverted. This page solves that one narrow failure — two agencies editing the same incident perimeter concurrently — turning a race between overwrites into a deterministic, auditable reconciliation that never drops a defensible edit.

Root Cause and Operational Impact

The conflict is structural, not accidental. A shared perimeter is a single feature with one geometry, but during surge it has many concurrent editors working from different, equally valid sources — infrared flights, field observers, aircraft, sensor feeds. Each editor reads a base version, mutates the geometry locally, and writes back. If two writes derive from the same base and land in the same window, the datastore has no way, on its own, to tell a genuine improvement from a stale overwrite. It orders the writes by arrival and the last one wins. The losing edit is not merged, not flagged, not queued for review — it is gone, and the editor who made it has no signal that their change was discarded.

This is dangerous rather than merely inconvenient because the perimeter drives life-safety decisions downstream. Evacuation zones, road closures, and resource assignments are all derived from that polygon. A perimeter that silently shrinks because a growth edit was overwritten tells incident command an area is clear when it is still burning, and a re-expanded bulge can trigger an evacuation that strands crews needlessly. The National Incident Management System (NIMS) and the Federal Emergency Management Agency (FEMA) both expect the operational geometry feeding the Common Operating Picture (COP) to be reconstructable for after-action review, so a reconciliation that cannot explain why the perimeter looks the way it does is not defensible. The core defect is relying on wall-clock arrival order — the same reason robust conflict resolution in multi-agency edits replaces timestamps with causal version tracking.

Reconciling two concurrent perimeter edits with version vectors, union, and a conflict flag A base incident perimeter at version zero is read by two agencies. Agency A extends the northeast edge to enclose a spot fire and Agency B trims a southwest bulge, so their per-agency version vectors both advance past the shared base and diverge. A reconciler inspects the vectors. If neither edit dominates and the edits touch different areas, it unions the two geometries so no affected area is lost and advances a merged version vector. If both edits alter the same area in incompatible directions, the reconciler retains both candidate polygons, stamps the feature with a conflict flag, and escalates to a human editor. Every branch appends an immutable audit record capturing the base version, both incoming versions, the strategy, and the resulting geometry hash. Base perimeter version {A:0, B:0} Agency A · extend NE encloses spot fire · {A:1, B:0} spot fire Agency B · trim SW corrects bulge · {A:0, B:1} bulge removed Reconciler compare version vectors vs base disjoint Union geometries keep both · {A:1, B:1} same area Conflict flag keep both candidates escalate to editor Audit trail · base version · both incoming versions · strategy applied · result geometry hash

Tiered Resolution Strategy

Reconcile in ordered tiers, from the definitive causal check down to a safe default that always keeps the most conservative geometry and flags it for a human. Never resolve by silently discarding an edit — a lost growth edit during surge is a lost piece of the hazard picture.

  1. Track causality with version vectors (definitive). Give every editing agency a slot in a per-feature version vector. When an edit is submitted, compare its vector to the base: if one edit’s vector strictly dominates the other, it is a straight successor and can be applied. If neither dominates, the edits are genuinely concurrent and must be merged, not ordered.
  2. Union growth edits (safe for spreading hazards). For an actively spreading fire or flood, the conservative outcome is the largest defensible area. When two concurrent edits both add area and touch different parts of the boundary, union them so neither agency’s extension is lost.
  3. Last-writer-wins-with-merge for disjoint corrections. For corrective edits that trim area, apply the newer edit only in the region it changed, and only where it does not overlap the other agency’s edit. This keeps a valid correction without letting it silently undo an unrelated change.
  4. Raise a conflict flag when edits disagree on the same area (safe default). If the two edits alter the same region in incompatible directions — one extends where the other trims — do not pick a winner. Retain both candidates, mark the feature conflicted, publish the most conservative geometry to the COP, and escalate to a human editor.
  5. Emit an audit record for every reconciliation. Base version, both incoming vectors, the strategy applied, and the resulting geometry hash, so any merged perimeter is reproducible against the exact inputs and rule that produced it.

Production Python Implementation

The routine below carries the full resolution path: version-vector comparison, union of concurrent growth edits, last-writer-wins-with-merge for disjoint corrections, an explicit conflict flag with human escalation, structured logging, exception handling, and an immutable audit record per reconciliation. Geometries are shapely objects already reprojected to the incident’s equal-area CRS so that area comparisons are metric and meaningful — the same axis-order and datum contract described in coordinate reference systems for disaster zones. Senior-engineer assumptions apply: shapely is available and the caller has already validated attributes upstream.

python
from __future__ import annotations

import hashlib
import logging
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from enum import Enum

from shapely.geometry.base import BaseGeometry
from shapely.ops import unary_union

logger = logging.getLogger("incidentgis.perimeter_merge")

# A version vector maps agency_id -> integer edit counter for one feature.
VersionVector = dict[str, int]


class Strategy(str, Enum):
    DOMINATES = "successor_apply"
    UNION = "union_growth"
    LWW_MERGE = "lww_with_merge"
    CONFLICT = "conflict_escalate"
    ERROR_HOLD = "error_safe_hold"


@dataclass(frozen=True)
class PerimeterEdit:
    agency_id: str
    geometry: BaseGeometry
    version: VersionVector          # vector AFTER this agency's edit
    intent: str                     # "growth" or "correction"


@dataclass
class MergeResult:
    geometry: BaseGeometry
    version: VersionVector
    strategy: str
    conflicted: bool
    candidates: tuple[BaseGeometry, ...] = ()


@dataclass
class AuditEntry:
    """Immutable record of one reconciliation, appended to the audit trail."""
    feature_id: str
    base_version: VersionVector
    incoming: tuple[VersionVector, VersionVector]
    strategy: str
    conflicted: bool
    result_hash: str
    recorded_at: str = field(
        default_factory=lambda: datetime.now(timezone.utc).isoformat()
    )


def _dominates(a: VersionVector, b: VersionVector) -> bool:
    """True if vector ``a`` is a causal successor of ``b`` (a >= b, a != b)."""
    keys = set(a) | set(b)
    return all(a.get(k, 0) >= b.get(k, 0) for k in keys) and a != b


def _geom_hash(geom: BaseGeometry) -> str:
    """Stable content hash of a geometry for the audit trail."""
    return hashlib.sha256(geom.wkb).hexdigest()


class PerimeterReconciler:
    """Reconcile two concurrent edits to one incident perimeter.

    Every reconciliation appends an :class:`AuditEntry` to ``audit_log`` so a
    merged perimeter can be reconstructed from its inputs and the rule applied.
    """

    def __init__(self, feature_id: str, base_version: VersionVector) -> None:
        self.feature_id = feature_id
        self.base_version = base_version
        self.audit_log: list[AuditEntry] = []

    def _merged_version(self, a: VersionVector, b: VersionVector) -> VersionVector:
        """Element-wise max — the joined causal history of both edits."""
        return {k: max(a.get(k, 0), b.get(k, 0)) for k in set(a) | set(b)}

    def _audit(self, edit_a: PerimeterEdit, edit_b: PerimeterEdit,
               result: MergeResult) -> None:
        entry = AuditEntry(
            feature_id=self.feature_id,
            base_version=self.base_version,
            incoming=(edit_a.version, edit_b.version),
            strategy=result.strategy,
            conflicted=result.conflicted,
            result_hash=_geom_hash(result.geometry),
        )
        self.audit_log.append(entry)
        logger.info("perimeter_reconciled", extra={"audit": asdict(entry)})

    def reconcile(self, edit_a: PerimeterEdit, edit_b: PerimeterEdit) -> MergeResult:
        try:
            # Tier 1: causal dominance — one edit is a clean successor of the other.
            if _dominates(edit_a.version, edit_b.version):
                result = MergeResult(edit_a.geometry, edit_a.version,
                                     Strategy.DOMINATES.value, conflicted=False)
                self._audit(edit_a, edit_b, result)
                return result
            if _dominates(edit_b.version, edit_a.version):
                result = MergeResult(edit_b.geometry, edit_b.version,
                                     Strategy.DOMINATES.value, conflicted=False)
                self._audit(edit_a, edit_b, result)
                return result

            # Concurrent edits: neither vector dominates. Decide on intent + overlap.
            merged_ver = self._merged_version(edit_a.version, edit_b.version)
            overlap = edit_a.geometry.intersection(edit_b.geometry)
            both_growth = edit_a.intent == "growth" and edit_b.intent == "growth"

            # Tier 2: union growth edits — never shed area a spreading hazard gained.
            if both_growth:
                union = unary_union([edit_a.geometry, edit_b.geometry])
                result = MergeResult(union, merged_ver,
                                     Strategy.UNION.value, conflicted=False)
                self._audit(edit_a, edit_b, result)
                return result

            # Tier 3: disjoint correction — edits touch different areas, safe to merge.
            # symmetric_difference area near zero => the changes do not contest a region.
            if overlap.area == 0.0:
                merged = unary_union([edit_a.geometry, edit_b.geometry])
                result = MergeResult(merged, merged_ver,
                                     Strategy.LWW_MERGE.value, conflicted=False)
                self._audit(edit_a, edit_b, result)
                return result

            # Tier 4: same area contested in incompatible directions -> escalate.
            # Publish the conservative (larger) geometry until a human resolves it.
            conservative = max(edit_a.geometry, edit_b.geometry, key=lambda g: g.area)
            result = MergeResult(
                conservative, merged_ver, Strategy.CONFLICT.value,
                conflicted=True, candidates=(edit_a.geometry, edit_b.geometry),
            )
            logger.warning("perimeter_conflict",
                           extra={"feature_id": self.feature_id})
            self._audit(edit_a, edit_b, result)
            return result

        except (AttributeError, ValueError, TypeError) as exc:
            # Malformed geometry or vector: hold the base version, never crash sync.
            logger.error("perimeter_reconcile_failed", exc_info=exc)
            fallback = MergeResult(edit_a.geometry, self.base_version,
                                   Strategy.ERROR_HOLD.value, conflicted=True)
            self._audit(edit_a, edit_b, fallback)
            return fallback

The audit_log is the load-bearing output. Persisted as a content-hashed artifact, it lets a post-incident reviewer replay every reconciliation and confirm that no agency’s growth edit was silently dropped — the reproducibility guarantee that makes the merged perimeter defensible when it feeds evacuation and resource decisions.

Validation Checklist

Verify every item before deploying the reconciler to a live multi-agency perimeter service.

  • Every editing agency owns a distinct slot in the per-feature version vector, seeded from the base the editor actually read.
  • Reconciliation decisions use version-vector dominance, not wall-clock timestamps, so clock skew across agencies cannot pick the wrong winner.
  • Concurrent growth edits are unioned, and the test suite asserts the result area is at least the max of the two inputs — no area is ever lost.
  • Corrective edits merge only where they do not overlap another agency’s edit; overlapping contested edits raise the conflict flag.
  • A conflicted feature keeps both candidate geometries, publishes the conservative geometry to the Common Operating Picture, and escalates to a human editor.
  • Geometries are reprojected to an equal-area CRS before any area comparison so metric decisions are valid.
  • Every reconciliation appends an audit entry with the base version, both incoming vectors, the strategy, and the result geometry hash.
  • Structured logs route to the incident logging sink, not stdout, and malformed inputs fall back to the base version without stalling sync.

Edge Cases and Gotchas

  • Clock skew masquerading as causality. Field tablets and agency laptops rarely share a synchronised clock, so an edit that arrived later may have been authored against an older base. Never let arrival order break a tie — the version vector is the only trustworthy record of what each editor saw. This is the same failure that plagues naive syncing of ArcGIS Online edits to a local GeoPackage when the sync keys on edit_date.
  • Axis-order inversion before union. A perimeter arriving as (lon, lat) from one agency and (lat, lon) from another will union into a geometry that folds across the globe. Normalize axis order at ingest and run every transform with always_xy=True before the geometries ever reach the reconciler.
  • Slivers and invalid geometry from the union. Unioning two hand-digitised polygons frequently produces micro-slivers or a self-intersecting boundary. Run make_valid and a small buffer(0) cleanup after the union, and re-check that the result is a single polygon — a MultiPolygon here usually signals a digitising gap, not two real fire lobes.
  • Attribute conflicts riding along with geometry. Two agencies may agree on the boundary but disagree on the containment percentage or perimeter status. Reconcile attributes on their own field-level rules rather than letting the geometry winner carry its attributes wholesale; validate them against the shared contract in automated attribute validation rules.
  • Three-way and rapid successive edits. The pairwise reconciler handles two edits; a third concurrent editor needs the merged result fed back as the new base with its version vector advanced. Serialise reconciliations per feature so a burst of surge edits folds in deterministically instead of racing again inside the merge step.

Frequently Asked Questions

Why not just use last-writer-wins on the timestamp for perimeter edits? Wall-clock timestamps are unreliable across agencies whose devices have skewed or unsynchronised clocks, and last-writer-wins silently discards the losing edit. If one agency extended the perimeter to enclose a newly threatened neighbourhood and the other made a small correction a second later, a naive timestamp wins throws away the extension and shrinks the hazard area. Version vectors let you distinguish a genuine dominance from a real concurrent conflict before deciding what to keep.

When should concurrent perimeter edits be unioned rather than merged? Union when both edits are growth edits and the safe outcome is the largest defensible hazard area, which is the normal case for an actively spreading fire or flood: unioning never removes an area that either agency marked as affected. Fall back to last-writer-wins-with-merge only for corrective edits that trim a mistaken bulge, and even then apply the newer edit only where it does not overlap the other agency’s changes.

What happens when two edits conflict on the same area in opposite directions? The reconciler must not pick a winner automatically. It retains both candidate geometries, marks the feature as conflicted with a machine-readable flag, and escalates to a human editor with the full context of both edits. An audit record captures the base version, both incoming versions, and the fact that resolution was deferred, so the Common Operating Picture shows the most conservative geometry until a person reconciles it.

Up: Conflict Resolution in Multi-Agency Edits

Other guides in Conflict Resolution in Multi-Agency Edits: Python GIS Workflows