Reconcile & Post Automation for Utility Network Edits

Reconciling and posting by hand works until an estate has more than a handful of open versions, at which point it stops happening reliably and starts happening in bursts before deadlines. That is the worst possible cadence: the conflicts are large, the person resolving them is under time pressure, and the post lands without validation because validation is what gets dropped when the window is short. Automating the mechanical half — reconcile, classify, validate, prove — while leaving the judgement half to people turns versioning from a periodic ordeal into a nightly non-event. This guide builds that job, and it deliberately refuses to post anything whose conflicts were not automatically resolvable, following the conflict taxonomy set out in branch versioning and conflict resolution.

Environment Prerequisites

  • ArcGIS Pro 3.2+ or ArcGIS Enterprise 11.2+ with branch versioning enabled on the utility network’s feature dataset, and arcpy importable from Python 3.11.
  • Two portal identities: a reconcile account with edit rights and no post rights, and a post service account with post rights. Running both halves as one identity removes the control this job exists to provide.
  • A version inventory — the list of open versions with their owner, creation time and work order — readable from the versioning service so the job knows what it is reconciling.
  • A per-field ownership map declaring which system owns which attribute, used to decide the only conflict class the job may resolve unattended.
  • A validated baseline: the default version’s topology clean at the start of the run, so any dirty area found after a reconcile belongs to that reconcile.
  • A defined post window that does not overlap the reconcile schedule, because a reconcile against a default version that is being posted to fails intermittently and looks like a database fault.
  • An append-only audit table with insert rights for both accounts and no update rights for anyone.

Schema-Aware Validation Protocol — Run Before the Job

  1. Confirm the default version is clean before the run. Dirty areas inherited from the previous night make every validation result in this run ambiguous.
  2. Confirm no version is older than the policy allows. A version months past its expected lifetime should be escalated rather than reconciled quietly; it is a process failure, and automating around it hides the failure.
  3. Verify the ownership map covers every field the job may auto-resolve. A field with no declared owner must never be auto-merged, because the default in that case is last-writer-wins under a different name.
  4. Check that the post window is clear. Query for in-progress posts before starting; a reconcile that races a post produces errors that will be misattributed to the network.
  5. Confirm the confirming-trace origins are defined. For versions that touched connectivity, the job needs to know which controller or device to trace from, or the proof step silently degrades into no proof at all.

Minimal Reproducible Implementation

The job below reconciles each open version, classifies its conflicts, validates the topology, runs the confirming trace where connectivity moved, and returns a per-version verdict. It never posts; it returns the set of versions that are safe to post, and a separate call under the post account acts on that set.

from __future__ import annotations

import logging
from dataclasses import dataclass, field

import arcpy

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
LOG = logging.getLogger("reconcile-post")

AUTO_RESOLVABLE = {"ATTRIBUTE"}      # everything else is held for a person


@dataclass
class VersionVerdict:
    name: str
    conflicts: dict[str, int] = field(default_factory=dict)
    resolved: int = 0
    dirty_after: bool = True
    trace_ok: bool | None = None
    problems: list[str] = field(default_factory=list)

    @property
    def postable(self) -> bool:
        held = {k: v for k, v in self.conflicts.items() if k not in AUTO_RESOLVABLE and v}
        return (not self.problems and not held and not self.dirty_after
                and self.trace_ok is not False)


def _classify_conflicts(un_path: str, version: str) -> dict[str, int]:
    """Count conflicts by class for a reconciled version.

    The conflict table names the row and the kind of difference; this collapses it into
    the four classes the policy distinguishes, so the verdict is readable.
    """
    counts = {"ATTRIBUTE": 0, "GEOMETRY": 0, "DELETE_UPDATE": 0, "ASSOCIATION": 0}
    table = f"{un_path}_Conflicts"
    with arcpy.da.SearchCursor(table, ["VERSIONNAME", "CONFLICTTYPE", "ISASSOCIATION"]) as cur:
        for name, kind, is_assoc in cur:
            if name != version:
                continue
            if is_assoc:
                counts["ASSOCIATION"] += 1
            elif kind in counts:
                counts[kind] += 1
            else:
                counts["ATTRIBUTE"] += 1
    return counts


def reconcile_version(
    un_path: str,
    workspace: str,
    version: str,
    trace_origin: str | None = None,
) -> VersionVerdict:
    """Reconcile one version, validate it, and decide whether it is safe to post."""
    verdict = VersionVerdict(name=version)

    try:
        arcpy.management.ReconcileVersions(
            input_database=workspace,
            reconcile_mode="ALL_VERSIONS",
            target_version="sde.DEFAULT",
            edit_versions=[version],
            acquire_locks="LOCK_ACQUIRED",
            abort_if_conflicts="NO_ABORT",
            conflict_definition="BY_ATTRIBUTE",
            conflict_resolution="FAVOR_TARGET_VERSION",
            with_post="NO_POST",
        )
    except arcpy.ExecuteError as exc:
        verdict.problems.append(f"reconcile: {exc}")
        return verdict

    verdict.conflicts = _classify_conflicts(un_path, version)
    verdict.resolved = verdict.conflicts.get("ATTRIBUTE", 0)

    try:
        arcpy.un.ValidateNetworkTopology(in_utility_network=un_path)
    except arcpy.ExecuteError as exc:
        verdict.problems.append(f"validate: {exc}")
        return verdict

    with arcpy.da.SearchCursor(f"{un_path}_DirtyAreas", ["OBJECTID"]) as cur:
        verdict.dirty_after = any(True for _ in cur)

    if trace_origin:
        try:
            arcpy.un.Trace(in_utility_network=un_path, trace_type="CONNECTED",
                           starting_points=trace_origin)
            verdict.trace_ok = True
        except arcpy.ExecuteError as exc:
            verdict.trace_ok = False
            verdict.problems.append(f"confirming trace: {exc}")

    LOG.info("%s: conflicts=%s dirty=%s trace=%s postable=%s", version,
             verdict.conflicts, verdict.dirty_after, verdict.trace_ok, verdict.postable)
    return verdict


def nightly_run(un_path: str, workspace: str,
                versions: dict[str, str | None]) -> list[VersionVerdict]:
    """Reconcile every open version and return the verdicts. Posts nothing."""
    return [reconcile_version(un_path, workspace, name, origin)
            for name, origin in versions.items()]
The nightly reconcile-and-post run, and the four conditions that gate the post Each open version is reconciled against the default version. Conflicts are detected and classified; anything outside the automatically resolvable class stops that version and leaves it for review. The topology is validated after the reconcile, because the merge itself can create dirty areas. A confirming trace runs where the version touched connectivity. Only a version that reconciled cleanly, validated clean, and traced as expected is posted, and the post itself runs under a separate account. RECONCILE pull DEFAULT in per open version CLASSIFY conflicts by class auto vs review VALIDATE after the merge dirty areas cleared PROVE confirming trace where connectivity moved POST separate account version then deleted not auto-resolvable HELD FOR REVIEW geometry, delete-update and association conflicts never post unattended Four conditions, all of them cheap; the post is the only irreversible step.

Two design choices carry the safety. The job returns verdicts rather than acting on them, so the posting step is a separate call that can run under a different identity. And postable is conservative by construction: an unclassified conflict falls outside AUTO_RESOLVABLE and blocks the post rather than being waved through.

Why the reconcile account and the post account are different identities The scheduler runs the reconcile under an account with edit rights but no post rights, so a defect in the automation can never publish. The gate evaluates the run and, when every condition passes, asks the posting service to publish. The posting service holds post rights and nothing else. An estate that gives one account both rights has automated its separation of duties away, and the audit trail can no longer show that a publication was gated. Scheduler Reconcile account Gate Post service reconcile every open version conflicts, validation, trace result any condition fails → leave the version open all conditions pass → request post posted, version deleted, audit row written Edit rights and post rights on one account is separation of duties in name only.

Production Deployment Pattern

  1. Schedule the reconcile nightly, outside the post window. Overlapping the two produces intermittent failures that get attributed to the database rather than to the schedule.
  2. Post from a separate service under a gate. Feed the postable set to a second job running as the post account. That account should have no ability to edit, so it cannot resolve a conflict on its way past one.
  3. Escalate versions that fail two nights running. A version that cannot reconcile cleanly twice is a person’s decision waiting to happen; leaving it in the nightly loop turns it into background noise.
  4. Apply bounded retry on lock acquisition only. Retrying a reconcile that failed on a conflict achieves nothing; retrying one that failed on a transient lock is worthwhile.
  5. Delete the version after a successful post. A posted version left open reconciles forever and slows every subsequent run.
  6. Persist the verdict per version. Conflicts by class, resolutions applied, validation result, trace outcome, the accounts involved and the timestamps form the record that shows a publication was gated rather than merely successful.
Reconcile cost against how long a version has been left open Reconcile has to evaluate everything the default version gained since the branch moment, so its cost is a function of elapsed time rather than of the number of edits in the version. A version reconciled daily meets one day of change. The same version left for a month meets a month of it, and the conflicts it surfaces are correspondingly harder to reason about. This is the argument for a nightly schedule rather than a reconcile-when-finished habit. illustrative: cost tracks elapsed time, not edit count Reconciled daily 6 s one day of change Weekly 34 s conflicts still legible Monthly 190 s merge is hard to reason about Left a quarter 640 s effectively a second network The version that is expensive to reconcile is the one that has not been reconciled.

Conclusion

A nightly reconcile with a conservative post gate converts the riskiest routine operation in a versioned estate into something boring. The mechanical work — merging, classifying, validating, proving — runs unattended, and the only human attention required is on the conflicts that genuinely need a decision. Splitting the reconcile and post identities keeps the audit trail meaningful, and deleting versions after posting keeps tomorrow’s run as cheap as today’s. The conflicts that this job holds back are the subject of the next step: deciding them consistently rather than case by case, which is what resolving branch version conflicts sets out.

For authoritative reference, consult the ArcGIS Pro branch versioning documentation and the Python logging facility.