The Iceberg Medallion: Bronze, Silver, Gold for Maximo Objects on watsonx.data

🎯 Who this is for: Data engineers building the actual Silver/Gold transformation jobs, architects deciding how many conformed entities their Maximo lakehouse actually needs, and anyone who read Part 2's extraction patterns and immediately asked "okay, the data's landing in Bronze β€” now what turns it into something a dashboard or a model can actually use."

Series: Part 3 of 6 β€” MAS 9 + IBM watsonx.data: Building the Maximo Open Lakehouse | Read time: 18 minutes

πŸ“– The Question This Post Actually Answers

Part 2 answered how Maximo data physically leaves Manage and lands in a Bronze Iceberg table β€” MIF, Kafka, or asynchronous bulk export, matched per table to its actual access pattern. That gets you raw WORKORDER and ASSET rows sitting in object storage. It does not get you a table a reliability engineer can point a dashboard at, or a feature set a watsonx.ai model can train on. The gap between "the data arrived" and "the data is usable" is exactly what the medallion architecture β€” Bronze, Silver, Gold β€” exists to close, and it's the question this post actually answers: what specific, named tables does a Maximo Iceberg lakehouse produce at each layer, and what does Apache Iceberg itself contribute that a plain Parquet lake wouldn't?

This isn't an abstract data-engineering pattern borrowed wholesale from generic lakehouse literature. IBM has made Iceberg the primary, default table format across watsonx.data specifically because its properties β€” ACID transactions, schema evolution, time travel β€” solve problems that show up constantly in EAM data: thousands of sensors and batch extracts writing concurrently, a Maximo attribute change landing in the middle of a fiscal quarter, an auditor asking what an asset's health score looked like six months before a failure. Every mechanic this post covers is in service of making the Bronze-to-Gold pipeline trustworthy enough that a VP looking at a Gold-layer KPI dashboard doesn't have to ask "but is this actually current, and can I trust the number three months from now."

πŸ’‘ Key insight: The medallion pattern isn't three storage tiers with progressively better data β€” it's three tiers with progressively different shapes. Bronze is append-only and as-arrived. Silver is joined, deduped, and keyed for consistency. Gold is aggregated and denormalized for one specific consumption pattern. Building all three as flat copies of the same schema is the most common early mistake, and it defeats the entire point of the layering.

πŸ—ΊοΈ The Medallion, Mapped to Real Maximo Tables

Here's the load-bearing table this post builds from β€” DOC13's reference architecture translated into names you'd actually see in a Maximo schema browser:

LayerContentsMaximo Mapping
BronzeRaw, as-arrived, append-onlyWORKORDER, ASSET, FAILUREREPORT, MEASUREMENT extracts (from Part 2's MIF/Kafka/bulk export); Monitor streams; AI Service logs; OEM manuals and SOPs via Docling
SilverCleansed, conformed, deduped EAM entitiesASSET_DIM, WORKORDER_FACT, FAILURE_FACT, MEASUREMENT_FACT β€” standardized keys, synonym-decoded codes, time-aligned sensor readings, deduplicated transactions
GoldBusiness-ready, aggregated, consumption-specificPer-asset/per-time feature tables for predictive maintenance, reliability strategy tables, cost marts, PM effectiveness scores, executive KPI rollups

Notice what changes shape between layers, not just quality. Bronze WORKORDER is one row per raw extracted record, exactly as MIF or Kafka delivered it β€” duplicates and all, if a retry double-delivered a page. Silver's WORKORDER_FACT is one row per logical work order, deduplicated, with STATUS decoded from its synonym-domain internal value and foreign keys pointing at ASSET_DIM. Gold might turn that same underlying data into a single row per asset per month, with mean-time-between-failure and total cost already computed. Three layers, three different grains, on purpose.

πŸ’‘ Key insight: If your Silver-layer table has the exact same row count and grain as its Bronze source, you probably haven't conformed it yet β€” you've just filtered it. Real conformance changes the grain: raw event rows become one-row-per-entity facts and dimensions.

πŸ₯‰ Bronze: What Actually Lands, Unmodified

Bronze is deliberately the least interesting layer to design, because its whole job is to not make decisions. Every extraction method from Part 2 writes here: MIF's paged REST/JSON pulls, the Kafka connector's streaming events, and MAS 9.1's asynchronous bulk export all terminate in Bronze Iceberg tables that mirror the source system's shape as closely as possible.

Maximo MIF (REST/JSON, OSLC)  ─┐
Maximo Kafka / Event Streams  ─┼─→  Bronze Iceberg tables (append-only)
MAS 9.1 async bulk export     β”€β”˜         WORKORDER, ASSET, FAILUREREPORT, MEASUREMENT

Three design rules keep Bronze from becoming a liability later:

  1. Append-only, never update-in-place. A Bronze WORKORDER table should accumulate every extracted version of a record as a new row (with an extraction timestamp), not overwrite the previous one. This is what lets Silver-layer dedup logic decide which version is authoritative, and it's what makes Iceberg's time-travel genuinely useful at Bronze β€” you can inspect exactly what a MIF poll returned at 3 AM on a specific date.
  2. Schema stays close to source, not to destination. Resist the urge to rename columns or apply business logic in Bronze. STATUS stays as the raw synonym-domain internal value here; decoding it into a human-usable label is Silver's job. Mixing transformation into extraction makes both harder to debug independently.
  3. Partition by extraction time, not business date. A MEASUREMENT Bronze table partitioned by the timestamp a Kafka event arrived β€” not by the meter reading's own timestamp β€” keeps ingestion-time troubleshooting fast even when upstream data arrives late or out of order.

Monitor streams, AI Service interaction logs, and unstructured documents (OEM manuals, SOPs) processed through Docling also land in Bronze, even though they're not classic relational extracts β€” the layer's job is "everything as it arrived," regardless of source shape.

πŸ₯ˆ Silver: The Four Conformed Entities

Silver is where the real engineering work happens, and it's also the layer most Maximo lakehouse builds under-invest in β€” teams tend to spend their design effort on flashy Gold-layer dashboards and treat Silver as a mechanical cleanup step. It isn't. Silver is where raw extracts become four specific, named, reusable entities:

Silver TableGrainBuilt FromKey Transformations
ASSET_DIMOne row per assetASSET, LOCATIONS, CLASSSTRUCTURESlowly-changing dimension; class/hierarchy/criticality standardized; synonym-decoded status
WORKORDER_FACTOne row per work orderWORKORDERDeduplicated across extraction retries; STATUS/FAILURECODE/PROBLEMCODE synonym-decoded; foreign key to ASSET_DIM
FAILURE_FACTOne row per failure eventFAILUREREPORTSymptom/cause/remedy codes decoded and standardized; linked to WORKORDER_FACT
MEASUREMENT_FACTOne row per reading, time-alignedMEASUREMENTDeduped, gap-filled or flagged, time-aligned across sensors for the same asset

Why these four and not one denormalized table. ASSET_DIM is a classic dimension β€” attributes that describe an asset and change slowly (its class, its install date, its criticality tier). WORKORDER_FACT and FAILURE_FACT are transactional facts β€” one row per event, with measures you sum, count, or average, and a foreign key back to ASSET_DIM for every join a downstream consumer needs. MEASUREMENT_FACT is a fact table too, but a high-volume, time-series one with a fundamentally different query pattern β€” you scan a time window for one asset rather than aggregating across a site for a month β€” which is exactly why it's partitioned and queried differently even though it's conceptually "just another fact table." Collapsing all four into one wide table would force every consumer to pay the join and volume cost of columns they don't need.

The synonym-decoding step, concretely. Part 2 already established why this matters for ML: a WORKORDER.STATUS of COMP might display as "Complete," "Completado," or a site-specific label depending on who's looking, but the internal synonym value is what MIF can return and what a Silver job needs to standardize on. Maximo administers this through the Domains application β€” synonym domains hold the mapping from internal value to display synonym, and that mapping is itself configurable per organization or site. A Silver-layer transformation job's decode step is functionally re-implementing that domain lookup once, centrally, so every downstream Gold table and every feature pulled into watsonx.ai sees the same stable code regardless of which site or language produced the original record. Get this step wrong β€” decode inconsistently, or skip it and pass through display strings instead of internal values β€” and WORKORDER_FACT.STATUS silently fragments into multiple strings meaning the same thing, and every GROUP BY STATUS downstream quietly undercounts.

Deduplication. Because Bronze is append-only, the same logical work order can appear multiple times β€” once per MIF poll that happened to catch it, or once per Kafka retry. Silver's dedup logic typically keeps the latest version per business key (WONUM + SITEID + ORGID for WORKORDER_FACT), using Iceberg's snapshot metadata to identify which Bronze rows are newest without needing an external state store.

πŸ’‘ Key insight: ASSET_DIM being a dimension table, not a fact table, is a deliberate modeling choice that pays off every time a downstream job needs to join asset attributes against transactional data β€” you join once against a stable, deduplicated dimension instead of re-deriving "what class was this asset" from raw ASSET extract history every time.

πŸ₯‡ Gold: Business-Ready, One Purpose Per Table

Gold tables exist to answer one specific business question well, not to be a general-purpose queryable dataset. Where Silver is reusable across many downstream consumers, Gold is intentionally narrow and denormalized for its one consumer:

Gold Table ExampleGrainBuilt FromConsumed By
Asset health feature tableOne row per asset per dayASSET_DIM + MEASUREMENT_FACT + FAILURE_FACTwatsonx.ai predictive maintenance models
MTBF / reliability KPI martOne row per asset class per monthWORKORDER_FACT + FAILURE_FACTExecutive dashboards, Cognos or a BI tool
PM effectiveness scoreOne row per PM per asset per cycleWORKORDER_FACT (PM-type work orders) + FAILURE_FACTReliability engineering review
Cost martOne row per site per cost category per periodWORKORDER_FACT (labor/material/tool cost columns)Finance / maintenance budget reporting

The feature table for a predictive maintenance model is the clearest example of why Gold shape matters. A model training job wants one row per asset per time window with pre-computed features β€” degradation rate, operating hours since last PM, rolling failure count, ambient/load factors β€” not a set of tables it has to join and aggregate itself on every training run. Doing that aggregation once, in a Gold-layer Spark job, and materializing it as an Iceberg table means both the model training pipeline and a Cognos dashboard querying the same underlying health signal get a consistent, already-computed answer instead of two teams independently reinventing the aggregation logic (and inevitably getting slightly different numbers).

🧊 Why Iceberg, Specifically, for a Schema That Keeps Changing

Every layer above assumes a table format underneath it that can absorb three things Maximo lakehouses hit constantly: concurrent writes, schema drift, and the need to look backward in time. This is where Apache Iceberg stops being an implementation detail and becomes the reason the whole architecture holds together.

Iceberg FeatureWhy It Matters for Maximo Data
ACID transactionsConsistent writes when thousands of sensors and batch MIF/Kafka/bulk-export jobs land concurrently in the same Bronze table
Schema evolutionA Database Configuration change β€” new attribute, widened type, renamed column β€” doesn't break Silver/Gold jobs already running against the table
Time travel (snapshots)Query what an asset's health data or a work order's status looked like six months ago, for an audit, a trend comparison, or debugging a Silver job that produced a wrong number
Open format, multi-enginePresto, Spark, Db2, and Netezza all read the same Iceberg copy β€” no per-engine data duplication between the dashboard team and the ML team

ACID transactions, concretely. A Bronze MEASUREMENT table might be receiving a continuous Kafka stream from meter readings at the same moment a nightly bulk-export job is landing a historical backfill into the same table. Without transactional guarantees, that's a recipe for partial writes or readers seeing an inconsistent mix of old and new data mid-write. Iceberg's snapshot-based commits mean every write β€” streaming or batch β€” either fully lands as a new snapshot or doesn't land at all; a query running concurrently always sees one consistent, complete snapshot, never a half-written one.

Schema evolution, concretely. Say a Maximo admin adds a new custom attribute to WORKORDER in Database Configuration β€” a common, routine change, not an edge case. On a plain Parquet lake, every downstream reader expecting the old schema either breaks or silently ignores the new column depending on how it was written, and reconciling the change usually means a full table rewrite. Iceberg tracks schema as versioned metadata independent of the underlying files: the new attribute becomes a metadata-level add, existing Parquet files keep working exactly as they are, and old snapshots continue to read under the schema that was active when they were written β€” supporting add, drop, rename, and (within limits) type-widening, with no side effects on data already committed. This is precisely why the medallion pattern maps cleanly onto Maximo data specifically β€” the source schema is not static, and Iceberg is the piece absorbing that fact so Silver and Gold jobs don't have to.

Time travel, concretely. watsonx.data exposes Iceberg's snapshot history through a query-time syntax: SELECT <columns> FROM <iceberg-table> FOR VERSION AS OF <snapshotRef>. Every write, update, delete, or schema-evolution commit creates a new, complete, self-contained snapshot of the table's state at that instant. For a reliability team investigating why an asset's health score dropped, that means running the exact same Gold-layer query against a snapshot from six months ago and comparing it directly against today β€” not reconstructing history from a separate audit log, and not hoping someone kept a manual export from that period.

πŸ’‘ Key insight: IBM's own framing calls Iceberg "the linchpin of data sharing" across its databases and lakehouse engines β€” and for a Maximo shop specifically, that linchpin role is what prevents the dashboard team, the ML team, and the finance cost-mart team from each maintaining their own divergent copy of the same underlying WORKORDER history.

πŸ”¨ A Worked Example: WORKORDER-to-ASSET Silver Enrichment on Iceberg

The series index introduced the base pattern for creating a native Iceberg table from Db2 Warehouse β€” one of watsonx.data's fit-for-purpose engines β€” by adding an iceberg.catalog property to a CREATE DATALAKE TABLE statement. Here's that same pattern extended into an actual Silver-layer job: joining Bronze WORKORDER against Bronze ASSET to produce an enriched WORKORDER_FACT table that carries asset context directly, rather than forcing every downstream consumer to re-join it themselves.

First, the target Silver table, created directly in the watsonx.data catalog as a first-class Iceberg table:

CREATE DATALAKE TABLE iceberg.workorder_fact (
    WORKORDERID     BIGINT,
    WONUM           VARCHAR(20),
    SITEID          VARCHAR(20),
    ORGID           VARCHAR(20),
    ASSETNUM        VARCHAR(20),
    ASSET_CLASS     VARCHAR(50),
    ASSET_CRITICALITY VARCHAR(20),
    LOCATION        VARCHAR(20),
    STATUS          VARCHAR(20),
    STATUS_DECODED  VARCHAR(50),
    STATUSDATE      TIMESTAMP,
    FAILURECODE     VARCHAR(20),
    PROBLEMCODE     VARCHAR(20),
    LABORCOST       DECIMAL(15,2),
    MATERIALCOST    DECIMAL(15,2),
    TOOLCOST        DECIMAL(15,2),
    EXTRACTED_AT    TIMESTAMP
)
TBLPROPERTIES('iceberg.catalog'='watsonx_data_catalog')
STORED AS PARQUET
STORED BY ICEBERG
LOCATION 'DB2REMOTE://iceberg-bucket//iceberg/workorder_fact';

Then, the enrichment logic that populates it β€” a Spark job (or a Presto INSERT ... SELECT for smaller volumes) joining Bronze WORKORDER against Bronze ASSET on ASSETNUM, decoding the synonym-domain STATUS value, and writing the result as a new Iceberg snapshot:

INSERT INTO iceberg.workorder_fact
SELECT
    wo.WORKORDERID,
    wo.WONUM,
    wo.SITEID,
    wo.ORGID,
    wo.ASSETNUM,
    a.ASSETCLASS       AS ASSET_CLASS,
    a.CRITICALITY      AS ASSET_CRITICALITY,
    wo.LOCATION,
    wo.STATUS,
    dom.SYNONYM_LABEL  AS STATUS_DECODED,
    wo.STATUSDATE,
    wo.FAILURECODE,
    wo.PROBLEMCODE,
    wo.LABORCOST,
    wo.MATERIALCOST,
    wo.TOOLCOST,
    CURRENT_TIMESTAMP  AS EXTRACTED_AT
FROM iceberg.bronze_workorder wo
JOIN iceberg.bronze_asset a
  ON wo.ASSETNUM = a.ASSETNUM AND wo.SITEID = a.SITEID
LEFT JOIN iceberg.status_synonym_domain dom
  ON wo.STATUS = dom.INTERNAL_VALUE
WHERE wo.EXTRACTED_AT > (SELECT MAX(EXTRACTED_AT) FROM iceberg.workorder_fact);

Two details make this more than a generic join example. First, TBLPROPERTIES('iceberg.catalog'='watsonx_data_catalog') is what registers this as a first-class watsonx.data-managed Iceberg table rather than an external file Db2 merely reads β€” the moment that commit lands, Presto, Spark, and Netezza can all query workorder_fact immediately, with no export step and no second copy. Second, the WHERE clause filtering on EXTRACTED_AT is the incremental-processing pattern this format enables: rather than re-joining the entire Bronze history on every run, the job only processes rows newer than the last successful Silver write, which is exactly the kind of snapshot-aware incremental read Iceberg's metadata layer is built to support at scale.

Two mechanical constraints worth knowing before you build on this pattern: direct ALTER on a Db2-created datalake table is restricted, reflecting Iceberg's metadata-driven schema evolution model β€” schema changes go through Iceberg's evolution path, not a raw Db2 ALTER TABLE. And Db2 Warehouse's datalake table support with the Iceberg format requires Db2 Warehouse 11.5.9 or later β€” worth checking against your environment before assuming this exact syntax runs unmodified.

πŸ”— Registration Granularity: Bucket-Level vs. Table-Level

This is the mechanic most teams don't discover until an external Spark job's Iceberg writes mysteriously don't show up in a watsonx.data query β€” and it's a distinction the series index and Part 2 both flagged as a Part 3 topic, so it's worth being precise about.

Existing Iceberg tables register at the bucket level. When you point watsonx.data's Infrastructure Manager at a storage bucket containing Iceberg tables β€” written by an external Spark job, for instance, entirely outside watsonx.data β€” it can discover and register the Iceberg tables in that bucket as a batch, because Iceberg's own metadata (the table's manifest and metadata JSON files) is self-describing enough for watsonx.data to enumerate what's there.

Delta and Hudi tables register at the table level. Those formats don't expose the same bucket-scannable metadata convention, so each Delta or Hudi table has to be registered individually β€” one API call or UI action per table, rather than one operation covering everything in a bucket.

IcebergDeltaHudi
Registration scopeBucket-level (batch discovery)Table-level (per-table registration)Table-level (per-table registration)
Practical effectNew tables written externally can appear via one Sync metadata passEach new table needs its own registration step (e.g., the Register Delta Table API)Each new table needs its own registration step

The practical effect: a team standardized on Iceberg for its Maximo Bronze/Silver/Gold tables gets a genuine operational win here. A Spark job that creates a brand-new Gold table this month doesn't need a corresponding "go register the new table" ticket β€” running Sync metadata against the bucket picks it up. A Delta-based pipeline, by contrast, needs that registration step built into the pipeline itself or remembered as a manual follow-up every time a new table gets created.

πŸ”„ Sync Metadata: The Three Modes That Keep External Writes Visible

Bucket-level registration only helps if something actually triggers it, and that's what the Sync metadata capability inside Infrastructure Manager does. It matters most in the common real-world case where an Iceberg table is written by a process outside watsonx.data β€” a standalone Spark cluster, a separate ETL tool β€” and watsonx.data's catalog needs to be told the object store changed without physically moving the underlying data.

ModeWhat It DoesWhen to Use
Register new objects onlyAdds tables that exist in the bucket but aren't yet in the watsonx.data catalogAfter an external job creates a brand-new Gold or Silver table for the first time
Update existing objects onlyRefreshes metadata for tables the catalog already knows about, reflecting changes made in the bucketAfter an external job appends data or evolves the schema of a table watsonx.data already tracks
Sync all objectsCombines both β€” registers new objects and updates existing ones, including deletionsA full reconciliation pass, typically scheduled periodically rather than run ad hoc

For a Maximo pipeline where Silver and Gold jobs run as scheduled Spark jobs outside watsonx.data's own query engines (a common pattern once volumes get large enough that dedicated Spark cluster capacity makes sense), a practical operating rhythm is: run Register new objects only immediately after a job that might introduce a brand-new table, and schedule Sync all objects as a nightly reconciliation pass so nothing drifts silently out of sync between the bucket's actual contents and what the catalog believes is there. Skipping this step entirely doesn't corrupt anything β€” the Iceberg files in the bucket remain completely valid β€” but every watsonx.data-side query against an unregistered or stale-registered table keeps returning the old state, which is a confusing failure mode to debug if you don't know Sync metadata is the missing step.

🧭 Choosing the Right Layer for a New Requirement: Three Worked Scenarios

Scenario 1 β€” "Reliability wants a live count of overdue PMs by asset class." This is a Gold-layer table, built from WORKORDER_FACT (filtered to PM-type work orders) joined against ASSET_DIM for class, aggregated to one row per asset class per day. It should not be a Silver query run live against WORKORDER_FACT on every dashboard refresh β€” pre-aggregating into Gold keeps the dashboard fast and keeps the aggregation logic in one place instead of embedded in a BI tool's query.

Scenario 2 β€” "An auditor needs to see what an asset's criticality rating was a year ago, before a classification change." This is a time-travel query against ASSET_DIM's snapshot history β€” SELECT ... FROM iceberg.asset_dim FOR VERSION AS OF <snapshot-from-a-year-ago> β€” not a request that needs a separate audit table maintained alongside the dimension. If ASSET_DIM is genuinely append-on-change (a proper slowly-changing dimension pattern) rather than updated in place, the snapshot history already contains the answer.

Scenario 3 β€” "A new IoT sensor type is being piloted on 50 assets and needs its readings joined against existing MEASUREMENT_FACT." This starts in Bronze β€” the new sensor's raw readings land in their own Bronze table first, matching this series' rule of keeping Bronze close to source shape β€” and only gets conformed into MEASUREMENT_FACT once the pilot validates the sensor's data quality and the team decides its readings genuinely belong in the standardized fact table. Iceberg's schema evolution means adding the new sensor type's columns to MEASUREMENT_FACT later, once the pilot graduates, doesn't require rebuilding the table from scratch.

None of these three needed a workaround or a special case β€” matching the requirement to the correct medallion layer, the way Part 2 matched extraction methods to tables, is most of what keeps a Maximo lakehouse's architecture legible as it grows.

πŸ—ΊοΈ Practical Notes Before Part 4

  • Don't skip the dedup step in Silver. Bronze's append-only design means duplicates are expected, not a bug β€” WORKORDER_FACT and its siblings need explicit dedup logic keyed on the real business key, not an assumption that Bronze arrives clean.
  • Build the synonym-decode step once, centrally, in Silver. Every Gold table and every feature pulled into watsonx.ai should read already-decoded values β€” don't leave decoding to each downstream consumer, or you'll get the exact fragmentation problem Part 2 warned about.
  • Run Sync metadata on a schedule, not just reactively. If any part of your pipeline writes Iceberg files from outside watsonx.data's own engines, treat "Sync all objects" as a standing nightly job, not a step you remember only when a query looks stale.
  • Design Gold tables around one consumer, not general reuse. A feature table for a specific model and a KPI mart for a specific dashboard are allowed to look nothing alike, even when they're built from the same Silver sources β€” that's the layer doing its job, not duplication to clean up later.
  • Bring your named Silver entities to Part 4 already stable. The engine-routing decisions in Part 4 β€” which workload runs on Presto versus Spark versus Db2 Warehouse β€” assume ASSET_DIM, WORKORDER_FACT, FAILURE_FACT, and MEASUREMENT_FACT already exist as the stable join targets those engines query against.

Key Takeaways

  • The medallion isn't three copies of the same data at different quality levels β€” Bronze, Silver, and Gold each have a different grain and shape, built for append-only capture, conformed joining, and single-purpose consumption respectively.
  • Silver's four named entities β€” ASSET_DIM, WORKORDER_FACT, FAILURE_FACT, MEASUREMENT_FACT β€” follow standard dimensional-modeling logic applied to EAM data, with synonym-domain decoding done once, centrally, rather than left to every downstream consumer.
  • Apache Iceberg's ACID transactions, schema evolution, and time travel map onto three concrete Maximo problems: concurrent sensor/batch writes, Database Configuration changes that would otherwise break pipelines, and audit/trend queries needing historical accuracy.
  • Iceberg tables register at the bucket level while Delta and Hudi register at the table level β€” a distinction that determines whether external writes need a Sync metadata pass or individual per-table registration.
  • The Db2 CREATE DATALAKE TABLE pattern from the series index extends directly into real Silver-layer enrichment jobs β€” the WORKORDER-to-ASSET join in this post is the concrete version of the pattern the index only previewed.

References

Series Navigation

Previous:Part 2 β€” Getting Maximo Data into watsonx.data
Next:Part 4 β€” Fit-for-Purpose Engines

About TheMaximoGuys: We help Maximo developers and teams navigate the move to MAS 9 with practical, no-hype guidance grounded in how the platform actually behaves.

Published by TheMaximoGuys | July 2026