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.
Root Cause Analysis
Three gaps in the merge process compound into the tree above.
- The remap table was incomplete. The merge script built its
parent_idtranslation from the current facility registry export, but a subset of south-facility assets still carriedparent_idvalues 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. - 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_idagainst 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. - The merge produced duplicate roots instead of one.
NORTH-ROOTandSOUTH-ROOTboth keptparent_id: null, and a placeholderMERGED-ROOTnode was created by the migration tool and never wired to anything. A tree with threenull-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.
Related
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.