Skip to content

ADR 0021: IOModule.controllerRef is optional for network protocols — controller-less network I/O is monitored by a namespace-shared probe

Status: Accepted Date: 2026-07-13 Issue: #927

Amended by ADR 0042 (2026-08-08, #1329): "a network device is reachable from any node" is false on a zoned OT network. The shared probe stays node-unpinned by default, but an IOModule may now declare spec.probePlacement.nodeSelector, and the reconciler runs one shared probe pod per distinct selector.

Context

An IOModule today requires a controllerRef (api/physical/v1alpha1/iomodule_types.go; the CRD marks it required). A Controller in this codebase is a compute node that joins the cluster (ADR 0004) — not a source of tags. For a network-reached protocol (opcua, ethernetip, modbus-TCP) the I/O is read over the wire by an existing runtime node; the remote device owns no compute node of its own. Requiring every IOModule to name a Controller therefore forces one of two lies for a discovered field device: name an unrelated existing Controller, or fabricate a Controller that never joins (permanent AwaitingNode). ADR 0020 worked around this by naming an existing site Controller and flagged the root fix as follow-up A — this ADR.

controllerRef is inert on the tag-read path. The runtime reads each tag from its ioModuleRef + address regardless of controllerRef; the runtime pod schedules by the parent Unit's NodeSelector (internal/controller/physical/unit_pod.go), not by IOModule.controllerRef; and controllerRef is read nowhere in internal/adapter/, pkg/driver/opcua/, or cmd/unit-runtime/. Its only two consumers are (1) the IOModule reconciler's ControllerFound status condition and (2) io-probe scheduling — the per-Controller io-probe pod that produces the IOModule's Online/Fault/Offline state, which in turn drives the ISA-88 Clause 7.4 auto-HOLD on equipment fault.

Two facts about the current drivers sharpen the boundary:

  • Every non-simulation driver in this codebase is network-reached. The modbus driver connects over tcp:// unconditionally (pkg/driver/modbus/modbus.go); ethernetip and opcua are network protocols by nature. There is no serial / local-bus driver. The device is reachable from any node in the cluster, so its connectivity is a network fact, not a node-locality fact.
  • simulation is the one protocol that genuinely needs a Controller. A simulation IOModule's Online state is derived from the simulation Controller's Status.LastAlive heartbeat (internal/controller/physical/iomodule_controller.go, determineState); there is no real endpoint to probe. Remove its controllerRef and it has no liveness signal at all.

Decision

IOModule.spec.controllerRef becomes optional at the schema level. It is required only for protocol: simulation; for the network protocols (modbus, ethernetip, opcua) a missing controllerRef is a normal, first-class state. Connectivity for a controller-less network IOModule is monitored by a single namespace-shared, node-unpinned io-probe pod.

  • Schema. controllerRef loses its required marker (+optional, omitempty). A CEL XValidation rule on IOModuleSpec enforces protocol == 'simulation' → controllerRef set. Existing IOModules that name a Controller stay valid — the change is purely additive.
  • Reconciler — controller-bound path unchanged. An IOModule that names a controllerRef keeps today's behavior exactly: ControllerFound condition, per-Controller io-probe, sim-heartbeat state. simulation always takes this path (it must name a Controller).
  • Reconciler — controller-less network path. When controllerRef is empty and the protocol is a network protocol, the reconciler does not set a degraded ControllerFound: False condition (it removes any stale one). State is determined by querying the namespace-shared network io-probe exactly as the controller path queries the per-Controller probe: found && connected → Online (then channel discovery), found && !connected → Fault, !found → Unknown (config not yet propagated). The !found arm read Offline until ADR 0053 reserved that state for the cases the DCS determined; a module the probe has not loaded yet has not been measured.
  • Shared probe lifecycle. The IOModule reconciler ensures one fixed-name pod + ConfigMap per site namespace (network-io-probe) whenever ≥1 controller-less network IOModule exists there, and tears them down when the last one goes away. The pod carries no nodeSelector — a network device is reachable from any node — and no owner reference (it is shared across IOModules; namespace deletion GCs it, and the reconciler cleans it up at count zero). Its ConfigMap lists every controller-less network IOModule in the namespace. The probe binary (cmd/io-probe) is already controller-agnostic — it probes whatever its config file names — so no probe-side change is needed. The shared-probe reconcile runs on every IOModule event including deletes (keyed on req.Namespace), so a removed module is dropped from the ConfigMap even though its own object is gone.

This preserves the Clause 7.4 auto-HOLD safety property for exactly the devices the change is meant to support: a discovered OPC UA field device that drops offline mid-batch still transitions its IOModule to Fault and still HOLDs the running Unit.

Alternatives Considered

  • Skip the probe for controller-less IOModules (state Unknown). Rejected as a safety regression: the IOModule would never reach Fault, so a network device dropping mid-batch would not auto-HOLD the Unit. The runtime still marks affected tags Quality: Bad, but nothing at the equipment level acts on it. Cheaper, but it silently drops the one behavior — fault → HOLD — that made controllerRef's probe scheduling worth preserving.
  • Probe from the parent Unit's runtime node. Rejected: an IOModule is referenced by ControlModules that may sit under different Units, and a controller-less IOModule may be referenced before any Unit runtime exists. The connectivity signal would then blink with unrelated runtime-pod lifecycle, and a bound-but-not-yet-running device would read Offline. Network reachability is node-independent; tying it to a specific runtime node re-introduces the node coupling this ADR removes.
  • Auto-fabricate a Controller for the endpoint. Rejected for the same reason as in ADR 0020: a Controller is a compute node that joins the cluster; minting one for a remote third-party device invents a node that never joins (permanent AwaitingNode) or forces Simulation: true, a lie about a real device.
  • One shared probe pod per cluster instead of per namespace. Rejected — Site namespacing (site-{name}) is the isolation boundary for I/O, RBAC, and the existing per-Controller probes; a cross-namespace probe would break that containment and complicate mTLS cert scoping.
  • Keep controllerRef required, add a network: true escape field. Rejected as redundant: the protocol already tells us whether a Controller is meaningful (simulation vs the rest), so a second flag would be a derivable, drift-prone duplicate.

Consequences

  • CRD/API change, backward compatible. controllerRef is now optional; every existing manifest that sets it remains valid. The CEL rule rejects a new simulation IOModule with no controllerRef at admission.
  • New shared-probe surface owned by the IOModule reconciler. The reconciler now creates/updates/deletes a network-io-probe Pod + ConfigMap per namespace and needs create;update;patch;delete on pods and configmaps (previously it only read pods). RBAC (config/rbac/, Helm rules fragment) regenerates accordingly.
  • io-probe pod spec is factored out. The per-Controller and namespace-shared probes share one pod-spec builder (ioprobe_pod.go); the Controller path adds the node pin + owner reference, the shared path adds neither. Single source of truth for image, args, mTLS mounts, and health probes.
  • Discovery (#922) can drop the "select an existing Controller" step. ADR 0020's data-plane emit named an existing site Controller purely to satisfy the old required marker; with this ADR the discovered IOModule can omit it, and controller-less sites model network I/O cleanly. Updating the wizard to stop prompting for a Controller is a small follow-up on the #922 UI, not part of this CRD change.
  • Reversibility. Moderate. The schema relaxation is one-way in the sense that manifests authored without controllerRef would fail re-tightening, but the runtime behavior is additive and the shared probe is self-contained.