How to Structure CMMS Asset Trees for Multi-Site Facilities
Multi-site CMMS deployments stall during preventive maintenance routing when the asset tree carries no deterministic path normalization. The routing engine needs an unambiguous parent-child resolution to assign each work order to the correct crew; when site boundaries are not enforced at the schema level, duplicate asset tags across facilities trigger routing collisions, generating orphaned preventive maintenance jobs and misallocated labor hours. This guide is the field repair for that exact failure — it is the multi-site edge case of asset hierarchy design, the routing-resolution component of the CMMS Architecture & Maintenance Taxonomy domain.
Incident Profile
When preventive maintenance schedules halt across regional facilities, the routing engine throws a 409 Conflict at the dispatch API or a RoutingError: Ambiguous parent resolution inside the resolver. The downstream symptom is what facilities managers actually report: preventive jobs that simply never appear on a technician’s queue, or the same job appearing under two different sites.
[2024-05-14T09:12:03Z] ERROR: RoutingEngine.resolve_parent()
[2024-05-14T09:12:03Z] Input: asset_id="PUMP-088", site_context=["SITE_A", "SITE_B"]
[2024-05-14T09:12:03Z] Traceback: RoutingError: Ambiguous parent resolution. Multiple matches found for tag "PUMP-088".
[2024-05-14T09:12:03Z] Expected: Unique hierarchical path. Received: Flat tag collision.
[2024-05-14T09:12:03Z] Action: PM schedule halted. Work order generation aborted.
The signature to look for is a site_context array with more than one facility code mapped to a single asset tag. That is the collision: two physically distinct assets share one identifier, and the engine cannot decide which maintenance scope inherits the preventive schedule.
Run this rapid triage before touching any integration logic:
- Extract routing engine logs. Query the CMMS audit trail for
resolve_parent()failures and isolate everysite_contextarray longer than one element. - Verify schema depth. Query the asset registry. If
parent_idchains terminate early or form a loop, the tree violates strict inheritance. - Trace the sync payload. Inspect the outbound JSON from your integration job. A flat payload such as
{"asset_id": "PUMP-088", "pm_template": "HVAC-01"}will always collide in a multi-facility registry.
Root Cause Analysis
The root cause is a flat asset registration model exported from the source system. Both facilities registered identical equipment tags without a site-scoped path prefix, so the registry stores PUMP-088 twice with no structural difference between the rows. When the routing engine evaluates the preventive trigger, it matches the tag rather than a path and finds two candidates — there is no rule that lets it pick one over the other, so it fails closed and halts the schedule rather than dispatching to a guessed crew.
Two configuration defaults make this worse. First, most CMMS export jobs flatten the tree to a tag list because the integration only requested asset_tag, not the parent reference, so depth information never crosses the API boundary. Second, the routing strategy defaults to tag matching for single-site installs and is rarely switched to path matching when a second facility is onboarded. The fix is therefore two-part: bind routing to hierarchical paths at the schema level, then enforce path construction and depth in a pre-sync validation pass so a collision can never reach the engine.
Resolution
Step 1 — bind routing to hierarchical paths, not flat tags
Reconfigure the routing strategy so work orders resolve against the full asset path. Most enterprise CMMS platforms accept a strategy payload on the routing configuration endpoint:
{
"routing_strategy": "hierarchical_path",
"path_delimiter": ">",
"required_depth": 4,
"inheritance_mode": "strict_parent_only",
"fallback_scope": "site_admin"
}
This forces the engine to evaluate the full SITE_CODE > BUILDING > SYSTEM > ASSET path when assigning a preventive job, which eliminates tag collisions. With inheritance_mode set to strict_parent_only, the engine rejects any template that tries to inherit from a sibling node or cross a site boundary, so labor hours can never leak between regional budgets. Aligning the resolved path with role scope is what keeps the dispatch inside the facility’s security access boundaries.
Step 2 — construct the routing key before/after
The collision is in how the payload is built. The defective integration emits a flat tag; the corrected one emits a normalized path key derived from the parent chain.
# BEFORE — flat tag, collides across sites at the routing engine
payload = {
"asset_id": "PUMP-088", # identical string exists under SITE_A and SITE_B
"pm_template": "HVAC-01", # engine cannot pick a parent -> RoutingError
}
# AFTER — site-scoped hierarchical key, unique by construction
payload = {
"routing_key": "SITE_B>BLDG_01>HVAC_SYS>PUMP-088", # full path = single match
"asset_id": "B4", # stable internal id, never the human tag
"site_scope": "SITE_B", # lets the gateway enforce path-prefix RBAC
"pm_template": "HVAC-01",
}
The only load-bearing change is replacing the human-readable tag with a delimiter-joined path walked from the asset up to its site root. Because every site root contributes a distinct prefix, two assets that share a tag now produce two distinct keys and the engine resolves each to exactly one parent.
Minimal Reproducible Pipeline
The script below is end-to-end runnable. It loads a flat registry export that deliberately contains the PUMP-088 collision across two sites, walks each node to its root to build a deterministic path, rejects orphaned or circular nodes, enforces the required depth, and emits a collision-free routing map keyed to the canonical work order payload. The WorkOrderPayload dataclass is the same one used across the pipeline, so the normalized output drops straight into dispatch without redefinition.
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Dict, List, Optional, Set, Tuple
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
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
routing_key: str
site_scope: 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
tag: str
parent_id: Optional[str]
site_code: str
depth: int = 0
path: str = ""
errors: List[str] = field(default_factory=list)
class AssetTreeNormalizer:
REQUIRED_DEPTH = 4
DELIMITER = ">"
def __init__(self, raw_assets: List[Dict]) -> None:
self.nodes: Dict[str, AssetNode] = {}
for item in raw_assets:
self.nodes[item["asset_id"]] = AssetNode(
id=item["asset_id"],
tag=item["asset_tag"],
parent_id=item.get("parent_id"),
site_code=item["site_code"],
)
def _resolve_path(self, node: AssetNode, visited: Set[str]) -> Tuple[str, int, List[str]]:
if node.id in visited:
return "", -1, ["Circular parent reference detected"]
visited.add(node.id)
# Root node: the path is seeded with the site code so every prefix is unique.
if node.parent_id is None:
return f"{node.site_code}{self.DELIMITER}{node.tag}", 1, []
parent = self.nodes.get(node.parent_id)
if parent is None:
return "", -1, ["Orphaned node: parent_id not found"]
parent_path, parent_depth, parent_errors = self._resolve_path(parent, visited)
if parent_errors:
return "", -1, parent_errors
return f"{parent_path}{self.DELIMITER}{node.tag}", parent_depth + 1, []
def build_routing_map(self) -> List[WorkOrderPayload]:
payloads: List[WorkOrderPayload] = []
for node in self.nodes.values():
path, depth, errors = self._resolve_path(node, set())
if depth < self.REQUIRED_DEPTH and not errors:
errors.append(f"Insufficient depth: {depth}/{self.REQUIRED_DEPTH}")
if errors:
logging.warning("Node %s skipped: %s", node.id, ", ".join(errors))
continue
payloads.append(
WorkOrderPayload(
work_order_id=f"PM-{node.id}",
asset_id=node.id,
routing_key=path, # unique by construction across sites
site_scope=node.site_code, # drives path-prefix RBAC at the gateway
priority=Priority.STANDARD,
)
)
return payloads
if __name__ == "__main__":
raw_export = [
{"asset_id": "A1", "asset_tag": "SITE_A", "parent_id": None, "site_code": "SITE_A"},
{"asset_id": "A2", "asset_tag": "BLDG_01", "parent_id": "A1", "site_code": "SITE_A"},
{"asset_id": "A3", "asset_tag": "HVAC_SYS", "parent_id": "A2", "site_code": "SITE_A"},
{"asset_id": "A4", "asset_tag": "PUMP-088", "parent_id": "A3", "site_code": "SITE_A"},
# SITE_B reuses the PUMP-088 tag — the collision that halts routing.
{"asset_id": "B1", "asset_tag": "SITE_B", "parent_id": None, "site_code": "SITE_B"},
{"asset_id": "B2", "asset_tag": "BLDG_01", "parent_id": "B1", "site_code": "SITE_B"},
{"asset_id": "B3", "asset_tag": "HVAC_SYS", "parent_id": "B2", "site_code": "SITE_B"},
{"asset_id": "B4", "asset_tag": "PUMP-088", "parent_id": "B3", "site_code": "SITE_B"},
]
routing_map = AssetTreeNormalizer(raw_export).build_routing_map()
print(json.dumps([p.routing_key for p in routing_map], indent=2))
# -> "SITE_A>BLDG_01>HVAC_SYS>PUMP-088" and "SITE_B>BLDG_01>HVAC_SYS>PUMP-088"
# two distinct keys, zero 409 Conflict responses at dispatch.
The pipeline produces a deterministic routing key that survives the API handshake. Because depth and parent resolution are validated before the sync, the 409 Conflict is eliminated at the source rather than retried at dispatch. For large estates, stream the normalized payloads onto a broker and consume them through async batch processing so validation stays decoupled from CMMS ingestion. Validate each emitted payload against work order schema standards before it reaches the queue, and isolate runtime meters per facility so PM interval calculation never aggregates duty across sites.
A few multi-site boundary conditions still need explicit handling. Mobile or loaned equipment breaks a static tree: tag it with a transient site_code of SHARED and route its preventive jobs to a central fleet scope rather than a facility crew. When you add a tier such as ZONE or SUBSYSTEM, increment the routing schema version and run the normalizer in dry-run mode, logging every path mutation for the labor-allocation audit trail. And because routing keys now decide assignment, a technician scoped to SITE_A must never receive a job whose key is prefixed SITE_B — enforce that with path-prefix filtering at the API gateway, and reserve parts against the resolved asset through parts availability checks only after the key validates.
Prevention Checklist
FAQ
Why does the engine halt instead of picking one parent?
The resolver fails closed by design. A wrong dispatch sends a crew to the wrong building and starts an SLA timer against the wrong asset, which is more expensive to unwind than a halted schedule. When resolve_parent() finds more than one match, it aborts and surfaces the RoutingError so the collision is fixed in the registry rather than silently mis-routed.
Can I keep human-readable tags like PUMP-088 across sites?
Yes — that is the point of path normalization. Tags stay human-readable for technicians; uniqueness is supplied by the site-scoped path prefix, not the tag itself. The routing key SITE_B>BLDG_01>HVAC_SYS>PUMP-088 is unique even though PUMP-088 is reused, so you never have to renumber equipment across an estate.
What depth should required_depth use?
Match it to the shallowest legitimate path in your tree, which for most facilities is four tiers: site, building, system, asset. Setting it higher dead-letters valid shallow assets; setting it lower lets a flat two-tier export pass and re-introduces collisions. If a site genuinely has a deeper standard, raise the value per schema version rather than globally.
How do I handle equipment that moves between sites?
Assign mobile or loaned assets a transient site_code of SHARED and route their preventive jobs to a central fleet scope instead of a facility crew. When the asset is permanently relocated, re-parent it under the destination site root and re-run the normalizer so its routing key — and therefore its crew assignment — updates on the next sync.
Related
Anchor this repair in the broader resolver design covered by asset hierarchy design, enforce the resolved dispatch against work order schema standards, keep cross-site assignment inside security access boundaries, and isolate per-facility cadence with PM interval calculation.
Part of: CMMS Architecture & Maintenance Taxonomy.