Enforcing Lifecycle Transitions with Attribute Rules

A lifecycle state machine that lives in application code is a convention. It governs the edits that pass through that application and nothing else — not the migration script, not the mobile client, not the analyst with edit rights and a table view. Since every estate eventually acquires all three, the states drift: features appear as retired without ever having been active, abandoned mains regain an active status during a bulk update, and the status field becomes something nobody trusts enough to filter a trace on. Moving the guard into an attribute rule puts it in the geodatabase, where every edit path meets it. This guide covers what the rule should encode, how to deploy one onto a class that already contains violations, and what belongs outside the rule. It implements the machine defined in lifecycle state machines for utility assets.

Environment Prerequisites

  • ArcGIS Pro 3.2+ with a Standard or Advanced licence, and a geodatabase that supports attribute rules on the target feature classes.
  • The lifecycle domain loaded with exactly the permitted values, because a rule can only compare against values the field can hold.
  • The legal transition map as data — the same adjacency the state machine uses — so the rule and the application agree by construction rather than by review.
  • A measured violation count per class before deployment, since that number decides the rollout.
  • An append-only audit table with insert rights for the rule’s execution context, so accepted transitions are recorded where they cannot be edited.
  • A test geodatabase where the rule can be exercised against realistic edits before it reaches anyone’s working version.

Schema-Aware Validation Protocol — Run Before Deploying the Rule

  1. Count existing violations. Features already holding an impossible state, or pairs whose history implies an illegal transition, will meet the rule on their next edit. That count is the size of the clean-up.
  2. Confirm the domain matches the transition map. A value in the domain with no transitions defined is a state assets can enter and never leave.
  3. Check every edit path. Enumerate the applications, scripts and services that write the status field, and confirm each will surface the rule’s rejection message rather than swallowing it.
  4. Decide the cancelled-proposal case explicitly. A proposed asset that is never built is not retired; the estate needs either a cancelled state or a deletion policy, and the rule will force the decision.
  5. Test against a bulk edit. Attribute rules run per row, and a rule that is acceptable on one edit can make a ten-thousand-row update unusably slow.

Minimal Reproducible Implementation

The rule itself is a short expression evaluated by the geodatabase on every update. What follows is the deployment and the pre-flight measurement, which are the parts that take the time.

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("lifecycle-rule")

LEGAL = {
    "PROPOSED": {"ACTIVE", "CANCELLED"},
    "ACTIVE": {"ABANDONED", "RETIRED"},
    "ABANDONED": {"RETIRED", "ACTIVE"},
    "RETIRED": set(),
    "CANCELLED": set(),
}

RULE_EXPRESSION = """
// Refuse any lifecycle transition the state machine does not permit.
var legal = {
  'PROPOSED':  ['ACTIVE', 'CANCELLED'],
  'ACTIVE':    ['ABANDONED', 'RETIRED'],
  'ABANDONED': ['RETIRED', 'ACTIVE'],
  'RETIRED':   [],
  'CANCELLED': []
};
var was = $originalfeature.LIFECYCLESTATUS;
var now = $feature.LIFECYCLESTATUS;
if (was == now) { return true; }
if (IsEmpty(was)) { return true; }          // new feature: any initial state is allowed
var allowed = legal[was];
if (IsEmpty(allowed)) { return {'errorMessage': was + ' is terminal'}; }
if (IndexOf(allowed, now) == -1) {
  return {'errorMessage': was + ' cannot transition to ' + now};
}
return true;
"""


@dataclass
class Violation:
    global_id: str
    from_state: str
    to_state: str


@dataclass
class PreflightReport:
    violations: list[Violation] = field(default_factory=list)
    rows: int = 0

    @property
    def safe_to_enforce(self) -> bool:
        return not self.violations


def preflight(feature_class: str, history_table: str) -> PreflightReport:
    """Count transitions already recorded that the rule would have refused.

    The rule only governs future edits, so the question before deployment is how much
    of the existing history it contradicts — which is what an editor will meet the next
    time they touch one of those features.
    """
    report = PreflightReport()
    with arcpy.da.SearchCursor(history_table,
                               ["GLOBALID", "PRIOR_STATUS", "NEW_STATUS"]) as cursor:
        for global_id, prior, new in cursor:
            report.rows += 1
            if prior and new and new not in LEGAL.get(prior, set()) and prior != new:
                report.violations.append(Violation(str(global_id), str(prior), str(new)))

    LOG.info("%d historical transition(s), %d would be refused", report.rows,
             len(report.violations))
    return report


def deploy_rule(feature_class: str, name: str = "LifecycleTransitionGuard") -> None:
    """Attach the constraint rule to a feature class."""
    arcpy.management.AddAttributeRule(
        in_table=feature_class,
        name=name,
        type="CONSTRAINT",
        script_expression=RULE_EXPRESSION,
        is_editable="EDITABLE",
        triggering_events="UPDATE",
        error_number=51001,
        error_message="Illegal lifecycle transition",
        exclude_from_client_evaluation="INCLUDE",
    )
    LOG.info("attribute rule %s attached to %s", name, feature_class)
Where a lifecycle guard can live, and what each placement can and cannot stop A guard in application code stops the edits that go through that application. An attribute rule in the geodatabase stops every edit, including the ones made by a script, a mobile client or a direct table update. That difference is the entire argument: a state machine enforced only in code is enforced only where somebody remembered to call it. Placement Stops app edits Stops script edits Stops direct edits Application code yes no no Service-side validation yes yes no Attribute rule yes yes yes Only the bottom row is a constraint; the others are conventions.

exclude_from_client_evaluation="INCLUDE" is the setting that matters. A rule excluded from client evaluation runs only on the server, which means a disconnected editor discovers the rejection at synchronisation rather than at the moment of the edit — long after the context that would let them correct it.

The transitions an attribute rule permits, and the one it always refuses The rule encodes the same adjacency the state machine defines: proposed advances to active on commissioning, active to abandoned on de-energisation, abandoned to retired on removal. What it refuses is the shortcut — a feature moving from proposed straight to retired, which means an asset that was never in service is being recorded as though it had been removed from it. PROPOSED design ACTIVE in service ABANDONED in place, inert RETIRED removed commissioned de-energised removed refused by the rule A cancelled proposal is deleted or marked cancelled — not retired. The refused edge is the one that quietly rewrites an asset’s history.

What Belongs Outside the Rule

An attribute rule is the right place for the transition adjacency and the wrong place for everything that needs to look beyond the row being edited.

Cascade logic — refusing to retire a container while it still holds active contents — needs to query other features, which a constraint rule cannot do cheaply or reliably. That check belongs in the retirement workflow, where it already lives.

Authorisation — whether this editor may make this transition — is a permissions question, and encoding it as data in a rule expression produces a permissions model nobody can audit.

Side effects, such as writing an audit row, belong in a calculation rule or in the workflow rather than in the constraint, because a constraint that has side effects is a constraint that runs differently depending on whether the edit succeeds.

Deploying a lifecycle attribute rule without stopping the estate A constraint rule applied to a class with existing bad data rejects the next edit to every affected feature, which in practice means a queue of angry editors. Deploy it in the reverse order: measure the violations, clean them, deploy the rule in warning mode where the platform supports it, then promote to a hard constraint once the violation count is zero and staying there. MEASURE count existing violations CLEAN fix or annotate the backlog WARN rule reports, does not block ENFORCE promote to a hard constraint skipping the first two DEPLOYED COLD every editor meets the rule on their next edit to a feature that was already wrong The backlog decides the rollout; the rule itself is the easy part.

Production Deployment Pattern

  1. Measure, clean, warn, enforce. Never deploy a constraint onto a class with a violation backlog; the first person to meet it will be an editor doing unrelated work.
  2. Version the rule expression with the transition map. They are the same policy in two languages, and letting them drift reintroduces the problem the rule was meant to solve.
  3. Test the rejection message. Editors read it, and “Illegal lifecycle transition” is less useful than naming the states involved.
  4. Benchmark a bulk update. A per-row rule that costs a millisecond costs ten seconds on a ten-thousand-row edit, and that is the edit an operator will make during a migration.
  5. Keep the workflow checks. The rule is the floor, not the ceiling: cascade and authorisation checks still belong upstream.
  6. Audit rule rejections. A spike in rejections from one application usually means that application encodes an older version of the policy.

Conclusion

An attribute rule moves the lifecycle guard from a convention that governs one edit path into a constraint that governs all of them. Encoding the adjacency and nothing else keeps it fast and auditable; measuring and clearing the existing violations before enforcement keeps the rollout from landing on editors; and leaving cascade and authorisation upstream keeps each check where it can actually be evaluated. What results is a status field that a trace can filter on without hedging, which is the whole point of modelling lifecycle at all.

For authoritative reference, consult the ArcGIS Pro attribute rules documentation and the Arcade expression reference.