Skip to content

ADR 0035: A foreign control device is an IOModule carrying a field-device record, and no new kind is minted

Status: Accepted Date: 2026-08-04 Issue: #1239

Context

A third-party PLC running its own control logic was filed as a dumb I/O card. IOModuleSpec accepts a network protocol and an address, and ADR 0021 made controllerRef optional for exactly those protocols, so a Siemens S7 or a vendor gateway was represented by a kind whose doc comment read "a physical I/O module (e.g., Turck, Wago, Phoenix Contact) attached to a Controller". The MTP and PA-DIM work in #928 brings in more devices described by their own manifests, which widens the mismatch rather than narrowing it.

ADR 0033 put the question on a footing: the asset tree gains a Field Devices branch, and a thing gets a record of its own only when no Kubernetes object is already authoritative for it. Kubernetes knows nothing about a Modbus PLC, so a record is permitted. What ADR 0033 deliberately left to this decision is whether that record is a new kind or a field on the existing one.

Three constraints from decisions already in force bound the answer.

ADR 0001 dissolved the third parallel layer once already. The Asset CRD was removed because a peer to ControlModule and Unit forked the recipe-facing vocabulary: the same pump became a different kind depending on its transport, and protocol is a driver concern rather than a modeling one.

ADR 0033 allows one record per physical thing. Where a thing already has a citizen, the asset branch renders that citizen rather than minting a second object, and no record may carry a copy of a fact another record owns.

ADR 0021 preserved the IOModule reconciler semantics for this exact population. A controller-less network module gets its Online/Fault/ Offline state from the namespace-shared probe, and that state drives the ISA-88 Clause 7.4 auto-HOLD. ADR 0021 states the consequence plainly: a discovered OPC UA field device that drops offline mid-batch still holds the Unit.

Decision

A foreign control device is an IOModule carrying an optional spec.fieldDevice record. No new kind is minted.

FieldDeviceRecord carries type (io-module, control-device, instrument, gateway), vendor, model and firmware. The record says what the box at spec.address actually is, which is the fact that was missing. Everything about reaching it stays where it already was.

There is no schema default. An unset record means the box has not been classified, which is a legitimate state and how every IOModule authored before the field existed reads. Defaulting unset to io-module would restate the kind's old doc comment as a fact about a device nobody has looked at, which is the failure ADR 0016 refuses for undeclared tag roles. Unclassified renders as unclassified, and the fix is declaring the field.

The field is fieldDevice, never deviceClass. That name is already taken in the same API group by ADR 0016, where it is a presentation grouping on control modules whose values include controller. Two near-identical names with unrelated meanings in one group is a permanent trap. fieldDevice also matches the branch label ADR 0033 settled.

The kind keeps its name. Renaming IOModule would move every customer manifest, example, tagmap entry and CLI path for a word, and the doc comment that caused the confusion is a comment.

A vendor skid is reported rather than refused. ADR 0001 already settled that a smart skid is a Unit with a serviceBinding, so under ADR 0033's one-record rule that box's asset position belongs to the Unit. The IOModule reconciler compares its address host against the serviceBinding endpoints in its namespace and publishes status.conditions[type=DistinctAssetRecord]. False means a Unit already holds the asset position, so the module is that skid's data plane and the Field Devices branch renders the Unit instead of a second row.

This reports and never rejects, for two reasons. Admission cannot see other objects, so a validating rule could not express it. And two addresses on one host is a plant-wiring judgment that the product should surface rather than overrule. The comparison reads addresses, which are machine identifiers, so it does not reach into the naming-inference ban ADR 0016 imposes.

Tags are unaffected. A field device's tags enter the ISA-88 process tree as ControlModules exactly as ADR 0020 already has discovery emit them. The box stays in the asset tree. This composes with discovery rather than replacing it.

Alternatives Considered

A FieldDevice kind carrying the address and protocol. The obvious reading of "a peer control device deserves its own kind", and it would let the type carry device-specific fields without qualifying them. Rejected because it replaces IOModule for that box, so tags need a second reference beside ioModuleRef. That forks the io-probe configuration, the runtime tagmap, the drift check, the Clause 7.4 auto-HOLD path and ADR 0020's discovery emit. It is the third parallel layer ADR 0001 dissolved, arriving under a new name, and it re-forks the transport-agnostic recipe vocabulary that ADR 0001 required.

A FieldDevice kind sitting beside the IOModule as a descriptive record. Cheaper than replacement, and it keeps the tag path intact. Rejected because one box then holds two records, which ADR 0033's one-record rule forbids, and the descriptive record's copy of the address drifts from the operational one in exactly the way the no-mirror rule exists to prevent.

A deviceClass discriminator, reusing the ADR 0016 word. Rejected on the collision described above.

Renaming IOModule to something honest about the population it now covers. Rejected on migration cost against a benefit that a corrected doc comment delivers for free.

A zone field on the record. ADR 0031 places pods by taint and a field device runs no pods, so nothing would consume it. Omitted until something does.

A forward list of the ControlModules a device provides. The relationship already exists in reverse through tag.ioModuleRef, and a forward copy is a second source of truth for one fact.

Consequences

  • No migration. The field is optional and additive, every existing IOModule stays valid, and an unclassified device renders as unclassified.
  • #928 needs no kind either. An MTP PEA is a skid, already settled as a Unit with a serviceBinding, and PA-DIM describes an instrument, which is an endpoint publishing tags. Both land inside this model.
  • #1220 has one thing to write. The Linux-native OPC UA commissioning client populates spec.fieldDevice on what it commissions rather than choosing between two kinds.
  • The Field Devices branch renders IOModule rows. The branch built in #1241 groups them by declared type, and it must suppress a row whose DistinctAssetRecord condition is false so a skid appears once.
  • The reconciler gains a namespace-wide Unit read to compute that condition. It is a report rather than a gate, so a failure to compute it degrades to an absent condition rather than to a blocked reconcile.
  • The compliance posture is unchanged. ADR 0033 §9 holds the ISA-88 boundary and nothing here crosses it: the tags are already ControlModules and the box was never in the physical model.
  • Reversibility is good in one direction. Adding fields to the record is additive. Extracting a FieldDevice kind later would be a migration, which is the cost this decision is choosing not to pay now and would pay then.