Work Order Automation Scripts: 12 Patterns That Survive a MAS 9.1 Upgrade
Most Maximo automation scripts break at upgrade time — usually because they were written against undocumented behaviors. Here are 12 patterns that have survived MAS 8.x to MAS 9.1.x upgrades in production at three enterprise customers.
Automation scripts are the most under-tested surface in a Maximo Manage deployment. They run on launch points that fire thousands of times a day. They depend on internal APIs that are not in the public documentation. They were written by a configuration analyst who left the company three years ago. And they are the first thing that breaks at upgrade time.
This article is a catalog of 12 patterns I have seen survive MAS 8.5 → 8.7 → 8.8 → 9.0 → 9.1 upgrades in production at three enterprise customers (a US electric utility, a Canadian transit authority, and a global facilities management company). The patterns are written for Jython 2.7 (the still-supported scripting language as of MAS 9.1.4) and call out the migration path to Python 3.11 when MAS 9.2 ships Jython's replacement in late 2026.
Pattern 1: The object-event model
The most common mistake is to treat automation scripts as state machines that mutate the database. The right model is event handlers that operate on the in-memory MBO. If you mutate the database directly (mbo.getMboSet("...UPDATE...SET...")), you bypass the Maximo business object framework, you skip the cron events, and your change is invisible to downstream listeners.
# WRONG — direct SQL
ps = mbo.getMboSet("INVENTORY").getUserInfo().getConnection().prepareStatement(
"UPDATE INVENTORY SET CURRENTQTY = ? WHERE ITEMNUM = ?"
)
ps.setDouble(1, newQty)
ps.setString(2, itemNum)
ps.executeUpdate()
# RIGHT — MBO mutation
invSet = mbo.getMboSet("INVENTORY")
invSet.setWhere("ITEMNUM = :1")
invSet.setParam(1, itemNum)
inv = invSet.getMbo(1)
if inv is not None:
inv.setValue("CURRENTQTY", newQty, MboConstants.NOACCESSCHECK)
invSet.save()
The wrong version is faster, but it does not fire the MBO events, it does not update the live cache, and it does not respect the rowstamp / optimistic locking. The right version goes through the framework. Always go through the framework.
Pattern 2: The "save-on-save" check
A common requirement: "if the work order status changes to APPR (approved), send an email." The naive implementation listens on the status attribute, checks the new value, and triggers the email. The upgrade-breaking bug: the script fires twice if the user clicks "Save" twice in rapid succession, or fires even when the value did not actually change.
def onSave():
if not mbo.isModified("STATUS"):
return
newStatus = mbo.getString("STATUS")
if newStatus != "APPR":
return
# Avoid duplicate sends on rapid saves
if mbo.getString("SENDSTATUSEMAIL") == "Y":
return
mbo.setValue("SENDSTATUSEMAIL", "Y", MboConstants.NOACCESSCHECK)
# ... send email logic ...
The isModified check prevents the second fire. The SENDSTATUSEMAIL flag is a guard column that gets reset on a real status change. Add the column to the database if it does not exist (ALTER TABLE WORKORDER ADD COLUMN SENDSTATUSEMAIL VARCHAR(1) DEFAULT 'N').
Pattern 3: The cross-MBO transaction
You need to update the work order and the related asset and the related inventory in one transaction. The naive implementation calls save() on each MboSet separately. If the second save fails, the first save is already committed. Disaster.
# In one launch point — the work order save
wo = mbo
asset = wo.getOwnerAssset()
inv = wo.getInventory()
asset.setValue("LASTWO", wo.getInt("WONUM"), MboConstants.NOACCESSCHECK)
wo.setValue("ASSETLASTWO", "Y", MboConstants.NOACCESSCHECK)
inv.setValue("QTYUSED", inv.getDouble("QTYUSED") + wo.getDouble("QUANTITY"), MboConstants.NOACCESSCHECK)
# No explicit save() — the framework commits the whole transaction
# when the parent workorder.save() returns
wo.save()
The trick: the work order is the root MBO in the transaction. Updating child MBOs and letting the parent save do the work means everything is one transaction. If the parent save fails, all the child changes roll back. This is the correct pattern. The wrong pattern is to call save() on each MboSet independently.
Pattern 4: The cron-like event listener
Maximo has the "cron task" framework, but sometimes you need something simpler: a script that fires whenever a work order is saved, but only outside business hours. You can do this with a launch point on the work order save event, and a check against the system date.
from java.util import Calendar, GregorianCalendar
from psdi.server import MXServer
def onSave():
if not mbo.isModified("STATUS"):
return
if mbo.getString("STATUS") != "APPR":
return
cal = GregorianCalendar()
hour = cal.get(Calendar.HOUR_OF_DAY)
if 8 <= hour < 18:
return # business hours, skip
# Off-hours — log the approval
mbo.setValue("OFFHRSAPPROVAL", "Y", MboConstants.NOACCESSCHECK)
This pattern survives upgrades because it does not depend on undocumented internal state — it only uses the public MBO API and the standard Java Calendar class.
Pattern 5: The async-after-save
The work order save event runs synchronously. If you trigger an external system call (REST, email, Kafka publish) in the save event, you block the user. The right pattern is to use the publish event bus or to write a row to a "queue" table and let a cron task pick it up.
def onSave():
if mbo.getString("STATUS") == "APPR":
# Write to a queue table for async processing
qSet = mbo.getMboSet("APPROVALQUEUE")
q = qSet.add()
q.setValue("WONUM", mbo.getInt("WONUM"))
q.setValue("QUEUETIME", MXServer.getMXServer().getDate())
qSet.save()
The cron task that processes the queue runs every 5 minutes, picks up rows, fires the external system calls, and marks the rows as processed. This pattern is the single most upgrade-resilient pattern in the catalog because it decouples the user-facing transaction from the slow downstream work.
Pattern 6: The "do not run in the cron context" guard
A launch point on the work order save event will fire from the cron task framework if the cron task modifies the work order. This is a common source of "my script is sending duplicate emails" complaints. The fix: detect the cron context and skip.
from psdi.server import MXServer
def onSave():
server = MXServer.getMXServer()
if server.getUserInfo().getLoginUserName() == "MAXADMIN":
# The cron task framework uses MAXADMIN by default
# If you are running as MAXADMIN, you are in the cron context
return
# Real user save
if mbo.isModified("STATUS") and mbo.getString("STATUS") == "APPR":
# ... send email logic ...
This pattern is fragile — a customer might run the cron task as a different user, in which case the guard fails. The more robust pattern is to add a launch point restriction in the Automation Scripts application: "do not run from cron." Available since MAS 8.7.
Pattern 7: The BIRT-aware data fetch
A launch point that needs to query a different database table for a BIRT report should not use getMboSet for tables it does not need to write to. It should use the read-only Maximo Query API. This is faster and avoids accidental writes.
def getAssetHistory(assetNum):
historySet = mbo.getMboSet("$WO_HISTORY", "WORKORDER", "assetnum = :1 and historyflag = 1")
historySet.setParam(1, assetNum)
historySet.setOrderBy("actstart desc")
history = []
for i in range(historySet.count()):
h = historySet.getMbo(i)
history.append({
"wonum": h.getString("WONUM"),
"description": h.getString("DESCRIPTION"),
"actstart": h.getDate("ACTSTART"),
"actfinish": h.getDate("ACTFINISH")
})
return history
The $ prefix on the MboSet name tells the framework this is a read-only temporary MboSet — it is not added to the save transaction. Faster, cleaner, safer.
Pattern 8: The error-handling pattern
A launch point that throws an exception will roll back the entire save. Sometimes that is the right behavior. Often it is not — a failed email send should not block the work order from being saved. The pattern: catch the exception, log it, and continue.
from psdi.util import MXException
from java.lang import Exception
def onSave():
try:
if mbo.isModified("STATUS") and mbo.getString("STATUS") == "APPR":
sendEmail()
except Exception, e:
# Log to the Maximo log framework
MXServer.getMXServer().log("WorkOrderEmailScript", "Failed to send email for WO " + str(mbo.getInt("WONUM")) + ": " + str(e))
The Maximo log framework is the right place for this. Do not use print — print goes to System.out, which is captured by the pod logs, but it is not searchable in the Maximo UI. The log framework is.
Pattern 9: The "do not modify the rowstamp" pattern
The work order has a rowstamp column that the framework uses for optimistic locking. If you accidentally modify it (via a direct SQL UPDATE or a poorly-written script), the next user save will fail with a "rowstamp has changed" error. The pattern: never touch the rowstamp from a script. The framework manages it.
# WRONG
ps = mbo.getMboSet("WORKORDER").getUserInfo().getConnection().prepareStatement(
"UPDATE WORKORDER SET ROWSTAMP = ? WHERE WONUM = ?"
)
ps.setLong(1, mbo.getLong("ROWSTAMP") + 1)
ps.setLong(2, mbo.getInt("WONUM"))
ps.executeUpdate()
# RIGHT — let the framework manage the rowstamp
# Just modify the columns you care about
wo.setValue("DESCRIPTION", "New description", MboConstants.NOACCESSCHECK)
wo.save()
Pattern 10: The launch point ordering
Multiple launch points can fire on the same event. They fire in alphanumeric order by launch point name, not in the order they were created. If you have a "WO_SEND_EMAIL" launch point and a "WO_UPDATE_HISTORY" launch point, they fire in alphanumeric order. The pattern: prefix your launch point names with a number so you can control the order.
01_WO_SEND_EMAIL
02_WO_UPDATE_HISTORY
03_WO_NOTIFY_KAFKA
The numbers are a convention, not a framework feature, but every Maximo team uses it. The 01_ prefix is a strong pattern that has been in use since Maximo 7.6.
Pattern 11: The "use the public API only" rule
There is a temptation to reach into psdi.app.workorder.WOSetRemote or psdi.app.workorder.WORemote directly from a script. Don't. These are Java classes, and their signatures can change between MAS minor versions. The pattern: use only the MboConstants, MboRemote, MboSetRemote, and MXException interfaces that are documented in the IBM Knowledge Center.
# WRONG — uses internal class
from psdi.app.workorder import WORemote
wo = WORemote(mbo.getMboSet("WORKORDER").getMbo(0))
# RIGHT — uses public interface
wo = mbo # the script's `mbo` is already the right type
Pattern 12: The migration test harness
This is the only meta-pattern in the list. Before every MAS upgrade, run the automation script test harness. It is a hidden gem in the Maximo Application Framework that executes a sample of your scripts against a snapshot of the database and validates the output. The test harness is available at https://<mas-host>/maximo/oslc/script/test. Document the test results, the upgrade, the test results after, and diff. That diff is your upgrade safety net.
The test harness was experimental in MAS 8.5, beta in MAS 8.7, and GA in MAS 9.0. If you are not using it, start now. It is the single best tool for catching upgrade-induced script breakage before the user finds it.
The migration to Python 3.11
MAS 9.2 (late 2026) will replace the Jython 2.7 runtime with a CPython 3.11 runtime. The patterns above are already compatible with the new runtime — they use only the public MBO API, no Jython-specific features (no __future__ imports, no from java.util import ... syntax), and standard Python 2.7 syntax that 2to3 or LibCST can convert.
If your scripts are written in the patterns above, the migration is a 1-2 week code-translation project, not a 6-month rewrite. If your scripts are written in anti-patterns (direct SQL, internal class imports, Jython-only syntax), the migration is a 6-12 month project. Start now.
The bottom line
The 12 patterns above are not theoretical. They are in production at three enterprise customers with millions of work orders per year. They have survived five MAS minor versions and four major versions. The 12 anti-patterns (the WRONG examples) are in production at other customers and they break every time. Pick the patterns that match your use case, retire the anti-patterns, and your next MAS upgrade will be the boring one you have been waiting for.