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.
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.
- 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.
- 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.
- 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.
- 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.
- 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.
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
areacomparison 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 withalways_xy=Truebefore 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_validand a smallbuffer(0)cleanup after the union, and re-check that the result is a single polygon — aMultiPolygonhere 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.
Related
- Conflict Resolution in Multi-Agency Edits — the broader patterns for reconciling concurrent edits this perimeter merge builds on.
- Syncing ArcGIS Online Edits to Local GeoPackage — where the same version-vector discipline keeps a two-way sync from losing edits.
- Automated Attribute Validation Rules — validate the attributes that ride along with a reconciled perimeter.
Up: Conflict Resolution in Multi-Agency Edits