Resolving Orphaned Asset Nodes After a Site Merge

Merging two facilities’ registries into one CMMS instance is a routine consolidation project until the routing engine starts blocking work orders it used to dispatch without complaint. The proximate cause is almost always the same: the merge script remapped most parent_id references to the unified tree, but not all of them, and the nodes it missed now point at ancestors that no longer exist. This is the failure mode that sits directly beneath asset hierarchy design — a tree that looks complete in a spot-check but is quietly a forest of disconnected fragments the moment a technician’s dispatch tries to walk it for criticality and location.

Incident Profile

A site consolidation folded a north facility’s registry into a south facility’s registry to create one unified asset table. The import ran clean, logged a success line, and the on-call team moved on — until preventive work orders on a subset of south-facility equipment began failing to route the next morning.

2026-07-14 03:02:11 INFO  [merge_import] Merged registries: north_facility (root NORTH-ROOT), south_facility (root SOUTH-ROOT) -> unified_assets (9,482 nodes)
2026-07-14 03:02:12 WARN  [merge_import] parent_id remap table applied: 214 of 231 cross-references remapped
2026-07-14 08:15:47 ERROR [asset_resolver] AssetPathResolver.resolve(asset_id="CHILLER-South-14") failed
2026-07-14 08:15:47 DEBUG [asset_resolver] parent_id "SOUTH-BLDG-02-OLD" not found in unified_assets index (9,482 nodes scanned)
2026-07-14 08:15:47 ERROR [routing_engine] WorkOrderPayload work_order_id="WO-51190" asset_id="CHILLER-South-14" location_id=None criticality=None
2026-07-14 08:15:47 ERROR [routing_engine] Cannot infer escalation_tier: asset path unresolved, location_id missing. Dispatch blocked.

A follow-up scan against the unified registry confirmed the traceback was not an isolated event:

$ python manage.py scan_orphans --registry unified_assets
Scanned 9,482 nodes.
Orphaned (parent_id not in id set): 47
Duplicate roots (parent_id is null): 3   # expected exactly 1
Cycles detected: 1  (LOOP-ROOT-B7 -> LOOP-ROOT-C2 -> LOOP-ROOT-B7)

Forty-seven nodes referenced a parent_id that the remap table never touched, three separate nodes had parent_id: null where only one canonical root should exist, and a stray pair of nodes pointed at each other, forming a cycle instead of a path to a root. Any one of these breaks the assumption the routing engine depends on: that every asset resolves, in a bounded number of hops, to a single connected tree from which it inherits criticality and location.

Disconnected tree after merge versus repaired single connected DAG Two side-by-side asset trees. On the left, after the merge, NORTH-ROOT and SOUTH-ROOT each have one healthy child building, but CHILLER-South-14 floats disconnected because its parent_id SOUTH-BLDG-02-OLD no longer exists, and a stray MERGED-ROOT node sits unattached as a duplicate root. On the right, after repair, a single ORG-ROOT sits above both facility roots, CHILLER-South-14 has been reattached under SOUTH-BLDG-01, the duplicate MERGED-ROOT has been collapsed under ORG-ROOT, and a QUARANTINE-ROOT branch holds any node the repair could not confidently reattach for manual review — every node is reachable from the one root. AFTER MERGE — DISCONNECTED 47 orphans · 3 roots · 1 cycle NORTH-ROOT SOUTH-ROOT NORTH-BLDG-01 SOUTH-BLDG-01 CHILLER-South-14 parent_id: SOUTH-BLDG-02-OLD not found — orphaned MERGED-ROOT duplicate root — unattached AFTER REPAIR — ONE CONNECTED DAG one root · zero orphans · zero cycles ORG-ROOT NORTH-ROOT SOUTH-ROOT NORTH-BLDG-01 SOUTH-BLDG-01 CHILLER-South-14 reattached ✓ QUARANTINE-ROOT manual review queue solid = resolved parent link · dashed = broken or missing parent · amber = quarantined pending review

Root Cause Analysis

Three gaps in the merge process compound into the tree above.

  1. The remap table was incomplete. The merge script built its parent_id translation from the current facility registry export, but a subset of south-facility assets still carried parent_id values from an earlier, pre-migration building code (SOUTH-BLDG-02-OLD) that had already been renamed once before the merge. Those ids were never in the source data the remap table was built from, so the script silently left them pointing at an id that had never existed in the unified table.
  2. No referential-integrity pass ran after the merge. The import logged a node count and a remap coverage percentage, and the team treated a clean exit code as proof the tree was sound. Nothing walked every parent_id against the merged id set to confirm each one resolved, and nothing checked for cycles introduced when two nodes were reassigned to each other during a manual conflict-resolution pass.
  3. The merge produced duplicate roots instead of one. NORTH-ROOT and SOUTH-ROOT both kept parent_id: null, and a placeholder MERGED-ROOT node was created by the migration tool and never wired to anything. A tree with three null-parent nodes is not one tree — it is three, and the routing engine’s inheritance walk has no rule for choosing which of three disconnected fragments an asset belongs to.

The result is a registry that a UI browse or spot-check will not catch, because the healthy majority of nodes still resolve fine — as with the multi-site tag collisions repaired in structuring CMMS asset trees for multi-site facilities, the failure only surfaces asset by asset, at dispatch time, once a work order tries to inherit criticality or location from a broken path. Left unrepaired, the same gap propagates downstream: a technician whose queue depends on geographic zone routing never receives the job at all, because the routing engine cannot assign a zone to an asset with no resolvable location.

Resolution

The fix is not re-running the same merge script with a bigger remap table — that only shrinks the orphan count, it does not guarantee it reaches zero, and it does nothing about duplicate roots or cycles. The fix is adding an integrity pass to the import itself, so no merge can complete without producing one connected tree.

Before — merge applies the remap table and stops, no validation:

def import_merged_assets(north_nodes, south_nodes, parent_id_remap):
    """Merge two facility registries into one asset table -- no integrity check."""
    unified = {**north_nodes, **south_nodes}
    for asset_id, node in unified.items():
        # Blindly apply the remap table; anything missing from it keeps its
        # original (pre-merge) parent_id, even if that id no longer exists.
        node["parent_id"] = parent_id_remap.get(asset_id, node["parent_id"])
    return unified  # never checked that every parent_id resolves to a real node

After — same merge, plus detection and repair before the tree ships to routing:

def import_merged_assets(north_nodes, south_nodes, parent_id_remap, org_root_id="ORG-ROOT"):
    """Merge two facility registries and repair the result before it reaches routing."""
    unified = {**north_nodes, **south_nodes}
    for asset_id, node in unified.items():
        node["parent_id"] = parent_id_remap.get(asset_id, node["parent_id"])

    quarantine_id = "QUARANTINE-ROOT"
    unified.setdefault(org_root_id, {"parent_id": None, "tag": "Organization root"})
    unified.setdefault(quarantine_id, {"parent_id": org_root_id, "tag": "Quarantine -- manual review"})

    # 1. Orphans: parent_id set but absent from the merged registry.
    for asset_id, node in unified.items():
        if asset_id in (quarantine_id, org_root_id):
            continue
        if node["parent_id"] is not None and node["parent_id"] not in unified:
            node["parent_id"] = quarantine_id  # hold for manual review, never drop silently

    # 2. Duplicate roots: every facility root collapses under one canonical root
    #    instead of floating as its own disconnected tree.
    for asset_id, node in unified.items():
        if node["parent_id"] is None and asset_id != org_root_id:
            node["parent_id"] = org_root_id

    return unified

The load-bearing change is that the import can no longer finish silently: every node either resolves to the canonical root through a real chain of parents, or it is explicitly parked under QUARANTINE-ROOT where a human can see it and reattach it correctly — the routing engine never has to guess.

Minimal Reproducible Pipeline

The script below builds a small tree with the same four fault classes found in the incident’s audit — an orphan with a known remap target, an orphan with none, a duplicate root, and a two-node cycle — then detects, repairs, and validates the result before resolving a work order path. It is end-to-end runnable as written.

import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Dict, List, Optional, Set

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


class Priority(str, Enum):
    CRITICAL = "critical"
    HIGH = "high"
    STANDARD = "standard"
    PLANNED = "planned"


@dataclass
class WorkOrderPayload:
    """Canonical CMMS work order -- SLA fields are mandatory site-wide."""
    work_order_id: str
    asset_id: str
    part_skus: List[str]
    required_quantities: Dict[str, int]
    location_id: str
    priority: Priority = Priority.STANDARD
    requested_completion: Optional[datetime] = None
    escalation_tier: int = 0
    status: str = "open"
    created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))


@dataclass
class AssetNode:
    id: str
    parent_id: Optional[str]
    tag: str
    criticality: Optional[str] = None
    location_id: Optional[str] = None


ORG_ROOT = "ORG-ROOT"
QUARANTINE_ROOT = "QUARANTINE-ROOT"


def find_orphans(nodes: Dict[str, AssetNode]) -> List[str]:
    """Return every asset_id whose parent_id is set but absent from the node set."""
    return [
        asset_id
        for asset_id, node in nodes.items()
        if node.parent_id is not None and node.parent_id not in nodes
    ]


def find_cycles(nodes: Dict[str, AssetNode]) -> List[str]:
    """Return every asset_id that sits on a parent_id cycle."""
    cyclic: Set[str] = set()
    for start_id in nodes:
        visited: Set[str] = set()
        current: Optional[str] = start_id
        while current is not None:
            if current in visited:
                cyclic.update(visited)
                break
            visited.add(current)
            node = nodes.get(current)
            current = node.parent_id if node else None
    return sorted(cyclic)


def find_duplicate_roots(nodes: Dict[str, AssetNode]) -> List[str]:
    """Return every root-level node (parent_id is None) other than the canonical org root."""
    return [
        asset_id
        for asset_id, node in nodes.items()
        if node.parent_id is None and asset_id != ORG_ROOT
    ]


def repair_tree(nodes: Dict[str, AssetNode], remap: Dict[str, str]) -> Dict[str, AssetNode]:
    """Detect orphans, cycles, and duplicate roots, then reattach into one connected DAG."""
    nodes.setdefault(ORG_ROOT, AssetNode(id=ORG_ROOT, parent_id=None, tag="Organization root"))
    nodes.setdefault(
        QUARANTINE_ROOT,
        AssetNode(id=QUARANTINE_ROOT, parent_id=ORG_ROOT, tag="Quarantine -- manual review"),
    )

    # Break cycles first so a cyclic parent is never mistaken for a valid target.
    for asset_id in find_cycles(nodes):
        if asset_id in (ORG_ROOT, QUARANTINE_ROOT):
            continue
        logger.warning("Cycle broken at %s -> quarantined", asset_id)
        nodes[asset_id].parent_id = QUARANTINE_ROOT

    # Reattach orphans: prefer a known remap target, otherwise quarantine for review.
    for asset_id in find_orphans(nodes):
        target = remap.get(asset_id)
        if target and target in nodes:
            logger.info("Reattached %s -> %s (remap table)", asset_id, target)
            nodes[asset_id].parent_id = target
        else:
            logger.warning("Orphan %s has no known remap target -> quarantined", asset_id)
            nodes[asset_id].parent_id = QUARANTINE_ROOT

    # De-duplicate roots: every facility root becomes a child of the org root.
    for asset_id in find_duplicate_roots(nodes):
        logger.info("Collapsed duplicate root %s under %s", asset_id, ORG_ROOT)
        nodes[asset_id].parent_id = ORG_ROOT

    return nodes


def validate_single_connected_dag(nodes: Dict[str, AssetNode]) -> None:
    """Fail loudly if the repaired registry is not one connected, cycle-free tree."""
    assert not find_orphans(nodes), "orphans remain after repair"
    assert not find_cycles(nodes), "cycles remain after repair"
    roots = [asset_id for asset_id, node in nodes.items() if node.parent_id is None]
    assert roots == [ORG_ROOT], f"expected exactly one root, found {roots}"

    children: Dict[str, List[str]] = {}
    for asset_id, node in nodes.items():
        if node.parent_id is not None:
            children.setdefault(node.parent_id, []).append(asset_id)

    reachable: Set[str] = set()
    stack = [ORG_ROOT]
    while stack:
        current = stack.pop()
        if current in reachable:
            continue
        reachable.add(current)
        stack.extend(children.get(current, []))

    assert reachable == set(nodes), "tree is not fully connected from the org root"


def resolve_path(nodes: Dict[str, AssetNode], asset_id: str) -> List[AssetNode]:
    """Walk from a leaf asset up to the org root, in inheritance order."""
    path: List[AssetNode] = []
    current: Optional[str] = asset_id
    seen: Set[str] = set()
    while current is not None:
        if current in seen:
            raise ValueError(f"cycle detected while resolving {asset_id}")
        seen.add(current)
        node = nodes[current]
        path.append(node)
        current = node.parent_id
    return path


def build_work_order(nodes: Dict[str, AssetNode], work_order_id: str, asset_id: str) -> WorkOrderPayload:
    """Resolve the asset path and inherit the nearest ancestor's criticality/location."""
    path = resolve_path(nodes, asset_id)
    location_id = next((n.location_id for n in path if n.location_id), None)
    criticality = next((n.criticality for n in path if n.criticality), "standard")
    if location_id is None:
        raise ValueError(f"{asset_id} has no location_id anywhere on its resolved path")

    priority_map = {"critical": Priority.CRITICAL, "high": Priority.HIGH, "standard": Priority.STANDARD}
    return WorkOrderPayload(
        work_order_id=work_order_id,
        asset_id=asset_id,
        part_skus=[],
        required_quantities={},
        location_id=location_id,
        priority=priority_map.get(criticality, Priority.STANDARD),
        escalation_tier=1 if criticality == "critical" else 0,
    )


if __name__ == "__main__":
    raw_nodes = {
        "ORG-ROOT": AssetNode(id="ORG-ROOT", parent_id=None, tag="Organization root"),
        "NORTH-ROOT": AssetNode(id="NORTH-ROOT", parent_id="ORG-ROOT", tag="North facility", location_id="NORTH-SITE"),
        "NORTH-BLDG-01": AssetNode(id="NORTH-BLDG-01", parent_id="NORTH-ROOT", tag="Building 1"),
        "SOUTH-ROOT": AssetNode(id="SOUTH-ROOT", parent_id="ORG-ROOT", tag="South facility", location_id="SOUTH-SITE", criticality="standard"),
        "SOUTH-BLDG-01": AssetNode(id="SOUTH-BLDG-01", parent_id="SOUTH-ROOT", tag="Building 1"),
        # Never remapped by the merge -- SOUTH-BLDG-02-OLD no longer exists.
        "CHILLER-South-14": AssetNode(id="CHILLER-South-14", parent_id="SOUTH-BLDG-02-OLD", tag="Chiller 14", criticality="critical"),
        # Same fault class, but no remap target is known for this one.
        "PUMP-South-22": AssetNode(id="PUMP-South-22", parent_id="SOUTH-BLDG-03-OLD", tag="Pump 22"),
        # A leftover placeholder root the migration tool created by mistake.
        "MERGED-ROOT": AssetNode(id="MERGED-ROOT", parent_id=None, tag="Unintended duplicate root"),
        # A manual conflict-resolution pass accidentally pointed these two at each other.
        "LOOP-ROOT-B7": AssetNode(id="LOOP-ROOT-B7", parent_id="LOOP-ROOT-C2", tag="Loop fragment B7"),
        "LOOP-ROOT-C2": AssetNode(id="LOOP-ROOT-C2", parent_id="LOOP-ROOT-B7", tag="Loop fragment C2"),
    }

    remap_table = {"CHILLER-South-14": "SOUTH-BLDG-01"}

    repaired = repair_tree(raw_nodes, remap_table)
    validate_single_connected_dag(repaired)

    work_order = build_work_order(repaired, work_order_id="WO-51190", asset_id="CHILLER-South-14")
    logger.info(
        "Resolved %s -> location=%s priority=%s escalation_tier=%d",
        work_order.work_order_id, work_order.location_id, work_order.priority, work_order.escalation_tier,
    )

Running the script logs each repair as it happens — CHILLER-South-14 reattaches to SOUTH-BLDG-01 because the remap table knows where it belongs, PUMP-South-22 and both loop nodes fall through to QUARANTINE-ROOT because nothing in the run confirms a correct destination for them, and MERGED-ROOT collapses under ORG-ROOT — then validate_single_connected_dag asserts the result is one root, zero orphans, zero cycles, and every node reachable, before build_work_order walks CHILLER-South-14 up through SOUTH-BLDG-01 and SOUTH-ROOT to inherit location_id="SOUTH-SITE" and priority=critical. That resolved payload is what a downstream queue can safely validate against work order schema standards before it reaches a technician.

Prevention Checklist

FAQ

Why does a duplicate root break routing if each fragment is internally consistent?

Because the routing engine’s inheritance walk assumes exactly one root to stop at, and a tree with three parent_id: null nodes has no rule for choosing between them when an asset’s path terminates at the “wrong” one. The engine treats an ambiguous or multi-rooted structure the same way it treats a missing node: it fails closed rather than guessing which fragment is authoritative, which is the same conservative behavior documented for tag collisions in structuring CMMS asset trees for multi-site facilities.

Should quarantined nodes stay attached to QUARANTINE-ROOT permanently?

No. QUARANTINE-ROOT is a holding pen, not a destination — a node should sit there only until someone confirms its correct parent and re-runs the repair with an updated remap entry. Leaving nodes there long-term means their criticality and location keep inheriting from a placeholder instead of a real ancestor, which silently understates their priority.

How do I find the correct remap target for an orphan I can’t identify from its old parent_id alone?

Cross-reference the orphan’s pre-merge site and building codes against the source system’s historical rename log, or match it by physical location and asset type against the destination tree. If neither resolves it confidently, leave it quarantined rather than guessing — an asset with the wrong parent silently inherits the wrong geographic zone routing assignment, which is harder to notice than a blocked work order.

Can this happen without a site merge, just from normal asset moves?

Yes — any operation that deletes or renames a parent node without walking its children first can leave orphans behind, and a manual re-parenting pass by two people working the same subtree concurrently can produce the same kind of cycle shown here. A merge just produces the fault at volume; the detection and repair logic in this guide applies to a single moved asset just as well as to 47 of them at once.

This repair sits inside asset hierarchy design alongside structuring CMMS asset trees for multi-site facilities, belongs to the broader CMMS Architecture & Maintenance Taxonomy domain, and its resolved output should be validated against work order schema standards before it reaches geographic zone routing.

Part of: Asset Hierarchy Design.