Skip to content

ADR 0056: The OMF projection names an AF element after its CR and identifies every stream by the ADR 0044 path, and a name PI cannot carry is refused at the declaration

Status: Accepted Date: 2026-08-12 Issue: #1507 Related: ADR 0044 (the model this projects, which wins wherever the two disagree), ADR 0043 (device health and the instrument nameplate), ADR 0050 (the precedent for refusing a value the destination cannot carry)

Context

Epic #1506 sends process values, ISA-88 context, and alarms to a customer's PI Server as OMF over HTTPS to their PI Web API endpoint. ADR 0044 already settled what is published and how it is addressed, so what remains is one projection of an agreed model onto one wire format. That projection creates names inside the customer's PI System. A PI user binds displays, reports, and calculations to those names, and a naming scheme changed after the first deployment orphans their work. The names are therefore settled here, ahead of the component (#1508).

Four properties of OMF and of PI constrain the design.

OMF creates the structure it writes into, and three of its four names come from us. An OMF message is a Type, a Container, or a Data message. A dynamic Type defines the shape of a stream, a Container is an instance of one, and the endpoint materializes the Container as a PI Point. A static Type becomes an AF Element Template and its instances become AF elements. The predefined __Link data message relates an element to a parent element and relates an element property to a Container. The AF element name is the static Type property carrying isname. The AF attribute name is the property named on the source side of a __Link. The Container id is ours. The PI Point name is the one the endpoint derives, and it derives it from the Container id. The PI Web API administrator selects the target AF database and PI Data Archive, so the projection's own root is whatever database they configured.

PI's naming rules are narrower than ours, and one of our name sources is unvalidated. PI rejects *, ?, ;, {, }, [, ], |, \, `, ', ", and control characters, and PI names are not case sensitive. Fledge's PI-facing implementation additionally caps a name at 200 characters and substitutes _ for every character in that set. Equipment names come from metadata.name and are DNS-1123, which intersects none of that. Tag names come from ControlModule spec.tags[].name, which is a plain string with no pattern validation, so a declared tag name can carry any of those characters and any case.

An OMF type is immutable. Version 1.2 of the specification states that multiple versions of one type are not supported, that the version keyword is informational metadata, and that referencing a type by version is deprecated. A change to a type's shape is therefore a new type id, and a new type id means new Containers and new PI Points. The type set has to be small enough that growth in the ADR 0044 model does not force one.

Nobody has run any of this against a PI Server. The procedure for that run is hack/pi-omf-conformance/README.md, and it is the epic's real risk. Access is a commercial decision rather than an engineering one, carried by cndcs-business#73; the engineering issue that held both halves is closed, so the procedure in the tree is what survives to be executed. Everything below is written against the published OMF 1.2 specification, the PI naming rules that Fledge's open-source north plugin applies, and AVEVA's own OMF samples. A mock receiver built from the same reading would agree with us by construction, which is #1454 one layer up.

Decision

The OMF projection gives every AF element the name of the CR it comes from, identifies every element and every stream by its ADR 0044 canonical path, and carries all values on three dynamic types whose per-tag engineering metadata rides on the Container. A name PI cannot carry is refused at the declaration that produced it and annunciated. The projection never renames, moves, or deletes anything it has created.

1. The type set, and how it is versioned

Every dynamic type carries Timestamp (string, date-time, isindex) and Quality (string, isquality), and exactly one value property named Value.

Type id Value interpolation Carries
CNDCS.Numeric number / float64 continuous Analog tag values
CNDCS.Integer integer / int64 stepwisecontinuousleading Integer tag values, alarm severity
CNDCS.Text string stepwisecontinuousleading Boolean and string tag values, ISA-88 unit state, unit mode, runtime state, control-module health, NE 107 device health, alarm state, alarm message, shelve state, batch identity, active element

An ISA-88 state transition is the point at which a CNDCS.Text stream changes value. PI stores that as a stepped string point whose history is the transition history, so the projection adds no separate event type.

A boolean tag publishes the declared trueLabel and falseLabel from ADR 0016 as its string value, and publishes true and false where a site declared neither. A PI digital point needs a digital state set, which is server-side configuration in the customer's PI Server that OMF cannot create, so a string is the form that arrives intact at every site.

Quality carries the ADR 0044 vocabulary verbatim as Good, Raw, or Bad. PI's own system digital states are left alone, because Raw has no member there and a partial mapping would discard the one quality value that says a tag is reading an undeclared address.

The static types are the AF Element Templates a PI engineer sees in the Template field. Each carries Path (string, isindex), Name (string, isname), and Description (string).

Type id Additional properties
CNDCS.Enterprise, CNDCS.Site, CNDCS.Area, CNDCS.ProcessCell, CNDCS.Unit, CNDCS.Equipment None
CNDCS.ControlModule DeviceClass, MeasuredVariable, ModuleType, SymbolID, FieldDeviceType, Vendor, Model, Firmware, SerialNumber
CNDCS.Alarm AlarmType, ConditionType, DefinitionRef
CNDCS.Alarms, CNDCS.Batch, CNDCS.ActiveElement None (grouping elements, see §3)

Type ids carry no version. The version keyword is set to 1.0.0.0 and is metadata, per the specification. Changing the shape of a type takes a new type id, and that recreates every PI Point on it, so it is the same one-way door as a rename and needs a superseding ADR. The type set is deliberately shaped so that growth does not require one. A new context leaf in ADR 0044 is a new Container on CNDCS.Text, and a control module that gains a tag gains a Container.

2. Container identity, and what a rename does

A Container id is the ADR 0044 canonical path of the leaf it carries, with the segments joined by / exactly as ADR 0044 spells them. A static element's Path index is the canonical path of that element. For the tag PV on control module tt-101:

acme/plant-01/fermentation/cell-01/fermenter-01/tt-101/PV

The separator is / because a metadata.name is a DNS-1123 subdomain and may contain . itself, so joining on . would make the path ambiguous to anyone reading a PI Point name back to the equipment that produced it. PI Point names also take . as their own suffix convention, and the endpoint may append one.

ADR 0044 makes identity name-based, and a rename moves the subtree. In PI that means the renamed equipment arrives as new AF elements and new PI Points. The old elements and points stop receiving data and stay exactly where they are. The egress never sends action: delete, because those elements and points live in the customer's AF database and their displays, reports, and calculations reference them. Removing them would break the very work this ADR exists to protect. A control module that moves under a different parent is a rename of its path and follows the same rule.

3. The AF projection

Each ADR 0044 equipment node becomes one AF element, named after its CR and linked to its parent element by __Link.

ADR 0044 node AF placement Template Element name
Enterprise Child of the configured AF root CNDCS.Enterprise metadata.name
Site, Area, ProcessCell, Unit Child of its declared parent CNDCS.Site, CNDCS.Area, CNDCS.ProcessCell, CNDCS.Unit metadata.name
Equipment module Child of the Unit CNDCS.Equipment metadata.name
Control module Child of its Unit or equipment module CNDCS.ControlModule metadata.name
Alarm Alarms collection under the source equipment CNDCS.Alarms, then CNDCS.Alarm per Alarm CR metadata.name

The Area hop resolves through ProcessCell.spec.areaName and the equipment-module hop appears exactly when a control module declares one, which is ADR 0044's grammar unchanged. That hop takes the generic CNDCS.Equipment template because its product surface is disabled (#480) while its CRD, its controller, and its ADR 0044 path segment remain. The element is carried, and the projection stops short of publishing a level-named AF template for a level the product does not currently expose.

Declared tags and context leaves become AF attributes of their equipment element, each attribute backed by one Container through a property-level __Link. The attribute name is the tag name or the context leaf name from ADR 0044 §3, verbatim. Unit context uses child elements for the grouped nodes, because an OMF link addresses one property of one element and cannot address a nested attribute path. Batch is a CNDCS.Batch element under the Unit carrying BatchID and BatchState, and ActiveElement is a CNDCS.ActiveElement element under Batch carrying Kind, Name, and State.

A field becomes an element property when it does not change, and a stream when it does. The static side is deviceClass, measuredVariable, moduleType, symbolId, and the ADR 0043 instrument nameplate from ControlModule.spec.fieldDevice. The moving side is everything ADR 0044 §3 lists as a context node, including the ISA-88 unit state, the occupying batch, the active phase or unit procedure, NE 107 device health, and the ISA-18.2 alarm lifecycle with its shelve state from ADR 0014.

Declared tag metadata rides on the Container. engUnits becomes uom, engMin and engMax become minimum and maximum, and the tag's label becomes name and description, all through the OMF 1.2 propertyoverrides keyword. The declarations OMF has no slot for, which are role, dataType, accessLevel, and the ADR 0025 publishMode, ride as Container metadata key-value pairs.

Only declared tags are published, per ADR 0044 §2. The runtime's raw-fallback value stream stays off this surface.

One collision is reachable. On a control-module element the declared tags and the three context attributes State, Health, and DeviceHealth share a parent, and PI is case-insensitive, so a tag declared as state and the context attribute State project onto one AF attribute and one PI Point. The context attribute keeps the name, because it is a product contract identical at every site. The colliding tag is refused under §4, and the refusal names both declarations. Unit context cannot collide, because tags are declared on control modules.

4. The naming scheme, and what a customer may override

The projection never rewrites a name. Three conditions refuse a stream or an element and annunciate the declaration that caused it:

  • A name carrying a character PI rejects.
  • A projected Container id or element name longer than 200 characters.
  • A case-insensitive collision within one parent.

Substituting _ for an illegal character, or truncating an over-long name, collapses two distinct declarations onto one name, and two streams then write one PI Point with neither side visible. Fledge substitutes because its asset names arrive from arbitrary south plugins and it has nobody to ask. Ours are declared in a reviewed CRD by an engineer who can rename them, so a name PI cannot carry is a declaration to correct. ADR 0050 settled the same question for a value outside its declared range and reached the same answer.

The 200-character cap is the limit Fledge's PI-facing implementation applies, taken here as the conservative floor. Check N2 of the conformance procedure (hack/pi-omf-conformance/README.md) records the limit a real endpoint enforces.

A customer overrides two things, both deployment configuration in the Helm chart alongside the endpoint and its credential (#1510), consistent with ADR 0044 §6 keeping transport plumbing out of the CRs:

  • An AF root path. The Enterprise element is created at the root of the configured AF database by default. A site with an existing AF database gives a path, and the projection creates and links the named ancestor elements first.
  • A Container id prefix. Empty by default. A site whose PI Data Archive already holds points that would collide sets one.

Nothing else is overridable, and there is no per-name mapping. Fledge exposes four naming schemes because its names are not designed, whereas an ADR 0044 path is a declared equipment tree that a customer's own engineer authored and reviewed.

5. OMF version, and the PI Web API floor

The projection targets OMF 1.2, which requires PI Web API 2021 or later. A site on an earlier release is refused with that reason stated.

propertyoverrides decides this. It arrived in 1.2, and without it uom, minimum, and maximum can only be declared on the type. Engineering units and range are per tag and a type is immutable, so a 1.1 projection needs one type per distinct combination of unit and range. That is an unbounded set of immutable types created inside a customer's PI Server. The isquality keyword arrived in 1.2 as well.

1.2 also obsoletes the producertoken header, so authentication is whatever the PI Web API endpoint itself requires. #1510 owns that credential.

6. Backfill

The egress replays from its own durable buffer and does not read the historian tables. A gap longer than the buffer stays a gap in PI, and the egress announces it with the window it lost.

The historian is optional and ships disabled, so a backfill path that depends on it is a path most deployments do not have. OMF delivery is at-least-once, and a repeated timestamp on a PI Point is idempotent, so replaying a frame the endpoint already accepted is safe. The resume point is the last frame the endpoint acknowledged, which is the correction #1483 recorded. A resume point published ahead of the acknowledgement claims every frame below it arrived, and a buffer that silently truncates makes a gap read as continuity.

Alternatives Considered

  • One multi-property Container per control module, with each tag a property of one type. Rejected: the type would have to match that control module's exact tag set, types are immutable, and a control module gaining a tag would need a new type and a new set of PI Points. One value per Container makes a new tag a new Container.
  • Hash the canonical path into a fixed-length Container id. It fits any length cap and cannot collide. Rejected: the PI Point name is what a PI user reads when a display breaks, and an opaque id turns every such question into a lookup against a table only we hold.
  • Substitute _ for illegal characters and truncate long names, which is what Fledge does. Rejected on the collapse described in §4. A refusal is loud on the day the declaration is written, and a substitution stays silent until two tags share a point.
  • Target OMF 1.1 to reach PI Web API 2019 sites. Rejected: engineering units and range would move into the type, and the immutable type set becomes unbounded.
  • OMF enum types for our fixed vocabularies, giving PI a digital point per state set. Rejected: a digital state set is configuration inside the customer's PI Server, and our vocabularies grow. Adding an NE 107 state or an ISA-88 sub-state would then require a change on their server before our data would land.
  • Delete or rename the AF elements and PI Points a renamed CR leaves behind. Tidier in AF. Rejected: they are the customer's objects, their displays reference them, and their history is in them.
  • Per-name customer overrides, in the shape of Fledge's naming schemes. Rejected: it reintroduces the one-way door as a per-site decision, and the names it would override are already the reviewed output of the customer's own equipment declarations.

Consequences

  • #1508 builds the component against this projection and gets no naming decisions of its own. #1510 carries the AF root path, the Container id prefix, the endpoint, and the credential as chart values.
  • hack/pi-omf-conformance/README.md has a specific list to confirm against a real endpoint: how the endpoint derives a PI Point name from a Container id, whether isquality and Container metadata survive into AF, the true name-length limit, and whether a string-valued state stream serves a PI user where a digital point was available. Each of those is written from a specification and read by nobody. The run is gated on access, which is cndcs-business#73.
  • Refusals need somewhere to land. #1508 owns annunciating a refused stream so that a name PI cannot carry is visible to the engineer who declared it. A silently dropped tag is the failure mode this decision trades for, and it has to stay loud.
  • Equipment and tag names become a customer-visible contract the moment the first site publishes. ADR 0044 already advised treating them as stable, and this hardens the advice into a consequence with a named cost.
  • A rename leaves orphaned AF elements and PI Points in the customer's database. The integration documentation states this and tells an administrator how to retire them.
  • docs/integration/historians.md § AVEVA PI loses its paragraph sending a PI reader off to write glue when #1508 lands, and gains the interface contract this ADR settles.
  • Reversibility is total until the first site publishes. After that the AF names are a product contract, and changing one needs a superseding ADR and a migration the customer has to run.

Amendment (2026-08-14, #1508): alarm severity is a word, the AF root path needs a template, and §4 refuses five conditions rather than three

Building the projection found three places where this ADR said something the component could not do, or did not say something it had to. The decision is unchanged in every case. What changes is what the ADR claims.

Alarm severity ships on CNDCS.Text, not CNDCS.Integer. §1's table puts it under the integer type. AlarmSeverity is a closed set of four words — Low, Medium, High, Critical — and the product holds no numeric severity anywhere, so an integer would have meant inventing a scale in the projection. A PI engineer then thresholds on that scale, and a severity added later has nowhere to sit between two existing values. The declared word ships. Founder ruling, 2026-08-12.

CNDCS.Folder joins the static type set. §4 lets a site configure an AF root path and requires the projection to create and link the ancestor elements it names. §1's static type table lists no template for those elements, and an OMF static instance has to name one. CNDCS.Folder carries the same Path, Name, and Description as every other static type and is instantiated only when a root path is configured, so a deployment leaving it empty never creates it.

§4 refuses five conditions. The three it names stand. Two more are reachable through ControlModule spec.tags[].name, which has no pattern validation, and both were found while writing the conformance procedure rather than during a run:

  • A name beginning with __. PI reserves the double underscore for system names, and OMF's own predefined messages use it — __Link is the one this projection sends. A tag named __raw is declarable today. Fledge collapses the prefix to a single underscore, which is the substitution §4 already rejects: __raw and _raw would become one PI Point.
  • A name that is empty, or that carries leading or trailing whitespace. " PV " and "" are both declarable. PI trims the whitespace, so " PV " lands on the same object as "PV" — the §4 collision arriving through a door this ADR did not name, with the same remedy. Unlike a case collision, the two are not equally entitled: the padded name is refused and the plain one publishes, because one of them is what an engineer meant to write.

The __ refusal is a prediction rather than a measurement. Check N3 of the conformance procedure sends such a name to a real endpoint, and if PI accepts it the condition comes back out. Refusing costs one annunciated stream; sending a name PI rejects costs the whole batch, because a rejection that will repeat identically is permanent and the batch is dropped.

§4's two chart values are checked before the component starts. The AF root path and the Container id prefix are the only names in the projection that come from a Helm value rather than from a reviewed CR, and the only ones a record-time refusal cannot route around — the root elements are the parent of everything, so an endpoint rejecting them rejects every batch that carries them. The component refuses to start and names the value at fault, which is the same posture it took toward starting without a ratified projection at all.

Amendment (2026-08-15, #1583): the batch context is one reused element per unit, its identity survives the batch, and §4 refuses six conditions rather than five

§3 specified a CNDCS.Batch element under the Unit and a CNDCS.ActiveElement element under it, and #1508 shipped the two types without ever instantiating either. A customer's PI Server therefore received two AF Element Templates no element used, and the occupying batch — the field ADR 0044 names as the reason to prefer this path over a historian scraping PLCs — arrived nowhere. Building it settled four questions §3 did not answer and found one place where §3 says something that is not true.

There is one Batch element per unit and it is reused for every batch. §3 is singular about it and this confirms that reading rather than changing it. A per-batch element would accumulate one permanent object per lot inside the customer's AF database, because §2 forbids this projection from ever deleting what it has created. Which batch is on the unit is a stream on the one element, which also makes the answer a history rather than a snapshot.

BatchID is not cleared when a batch finishes. A terminal transition moves BatchState to the terminal word and leaves BatchID on the batch that just ended, so the pair reads as "this unit last ran B-42, and B-42 completed". Clearing it would need an empty string on a PI Point, which reads as a declared blank rather than as an absence, and the terminal word already says the unit is free. The same rule refuses to write an empty batch id from a record that carries none: a record whose unit resolves to no batch has made no claim about equipment, which is the absent-is-not-empty rule #1549 settled for the publishers.

BatchState carries the lifecycle word verbatim, which is lowercased. The batch lifecycle topic publishes running where a Unit's own State stream carries Running, and that spelling is a published contract of the topic. Recasing it in the projection would be this component inventing a vocabulary, which is the same objection that put alarm severity on CNDCS.Text.

BatchID is written from every record kind, deduplicated against the last value sent. Every record already carries the batch that was running on its unit when it was enqueued, so a tag value, an alarm, a health transition, and a procedural state are all witnesses to it, and the lifecycle transition is not the only one. Deduplication is what keeps that from putting a sample on the wire per scan; a repeat on a stepped point carries no information anyway. On ActiveElement the same treatment applies to Kind and Name and deliberately not to State, because two consecutive phases are both Running at some point and suppressing the repeat would leave the moment the second one began readable nowhere.

§3's claim that "Unit context cannot collide" is false for element names. It is true for the reason it gives — tags are declared on control modules, so no tag can fold onto a Unit's context attribute. Element names reach the same collapse by a different door. A Unit's children in AF are its equipment modules and its control modules, and now also Alarms and Batch, so a control module named batch and the unit's own Batch element are one AF element once PI folds the case. batch and alarms are legal DNS-1123 names. §4 therefore refuses a sixth condition: an equipment resource whose name folds onto a context element its parent carries. The context element keeps the name for the same reason the context attribute does, and the resource is the one an engineer can rename. Alarms was reachable this way before the Batch element existed and nothing refused it.

A lifecycle sample is timestamped by the transition, not by its arrival. The batch lifecycle topic is retained since #1582, so a reconnecting subscriber replays one message per batch in no defined order across topics. Stamping those on arrival would put a batch that finished last week after the one running now, and the unit's BatchState stream would end on the wrong word. The publisher's own timestamp orders correctly whatever order the replay arrives in. Control-module health already read its published since for the same reason. The alarm topic is retained too and still stamps on arrival, which is the same latent shape and is not addressed here.

One implementation note that belongs here because it is a wire contract rather than a code detail. The Phase or UnitProcedure that becomes ActiveElement is placed under its unit from a unit field the procedural operator publishes on the equipment-state payload, off spec.unitRef. Reading the Phase CR instead would mean this component watching the procedural group for one field and resolving a resource the batch operator deletes when the batch finishes, so a record drained after an endpoint outage would resolve to nothing. It is the same rule #1549 applied to the alarm and control-module health payloads: the publisher states what only it knows.