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);ethernetipandopcuaare 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. simulationis the one protocol that genuinely needs a Controller. A simulation IOModule'sOnlinestate is derived from the simulation Controller'sStatus.LastAliveheartbeat (internal/controller/physical/iomodule_controller.go,determineState); there is no real endpoint to probe. Remove itscontrollerRefand 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.
controllerRefloses itsrequiredmarker (+optional,omitempty). A CELXValidationrule onIOModuleSpecenforcesprotocol == '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
controllerRefkeeps today's behavior exactly:ControllerFoundcondition, per-Controller io-probe, sim-heartbeat state.simulationalways takes this path (it must name a Controller). - Reconciler — controller-less network path. When
controllerRefis empty and the protocol is a network protocol, the reconciler does not set a degradedControllerFound: Falsecondition (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!foundarm readOfflineuntil 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 nonodeSelector— 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 onreq.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 reachFault, so a network device dropping mid-batch would not auto-HOLD the Unit. The runtime still marks affected tagsQuality: Bad, but nothing at the equipment level acts on it. Cheaper, but it silently drops the one behavior — fault → HOLD — that madecontrollerRef'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
Controlleris a compute node that joins the cluster; minting one for a remote third-party device invents a node that never joins (permanentAwaitingNode) or forcesSimulation: 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
controllerRefrequired, add anetwork: trueescape field. Rejected as redundant: the protocol already tells us whether a Controller is meaningful (simulationvs the rest), so a second flag would be a derivable, drift-prone duplicate.
Consequences¶
- CRD/API change, backward compatible.
controllerRefis now optional; every existing manifest that sets it remains valid. The CEL rule rejects a newsimulationIOModule with nocontrollerRefat admission. - New shared-probe surface owned by the IOModule reconciler. The reconciler
now creates/updates/deletes a
network-io-probePod + ConfigMap per namespace and needscreate;update;patch;deleteon 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
requiredmarker; 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
controllerRefwould fail re-tightening, but the runtime behavior is additive and the shared probe is self-contained.
Related Documentation¶
- ADR 0020: OPC UA discovery classifies each node onto a control plane — named this as follow-up A
- ADR 0004: Node join is a deployment-layer concern — why a Controller is a compute node, not a tag source
- docs/device-discovery.md — the discovery flow that emits network IOModules