Skip to content

ADR 0076: An I/O address names the driver that answers it

Status: Accepted Date: 2026-08-21 Issue: #1745 Related: ADR 0064 (the same hazard one attribute over, in the port name), ADR 0075 (the same unconfigured simulation driver, deciding a different question)

Context

Every I/O address in this product is one string, and the unit runtime routes it by splitting on its first colon. Adapter.resolveDriver reads the prefix as an IOModule name and looks it up among the drivers the unit controller resolved for this unit. A prefix that names no such driver is refused outright.

An address with no prefix is not refused. It falls through to a.driver, the runtime's primary driver.

That driver is a simulation driver on every pod this product creates. createRuntimePod passes --protocol simulation as a literal, at one site. That is the fact ADR 0075 turns on. The primary driver is also never configured. The operator passes it no endpoint and no options, and configureSimIOModule runs per IOModule config, of which the primary driver is not one. It therefore holds no behaviours, no channel map and no stored values.

simulation.Driver.ReadValue answers an address it has never heard of with Value: 0.0, Quality: Good and no error.

So an unprefixed binding does not fail anywhere. The ControlModule compiles. The program loads. The runtime reports Running, the driver reports connected, and the health verdict is healthy. The faceplate reads a plausible number that no instrument stands behind, forever. That is #1650's hazard exactly, an AO driven from a misspelled port writing 0 at quality Good with the program reporting Running. It arrives through the address here, and through the port name there.

Nothing below the document could have caught it. pkg/templatecompiler substitutes the binding into the block's address param and inspects it for nothing. fbruntime.Load refuses a port that does not exist and never reads an address. kubectl --dry-run=server checks the CRD schema, where a binding to any string is a well-formed string in a well-formed map. The driver's answer is a successful read, so there is no error for any layer above to report.

Twenty shipped bindings had it, all on granulator-1 in examples/newark-plant/07-controlmodules.yaml, plus nine more block addresses on the same unit's ControlPrograms. Two accidents kept it latent. That unit's Controller declares no simulation: true, so it never gets a runtime pod. The pharma-CDMO demo quick-run then targets a different unit, whose bindings are prefixed. A runtime scheduled for granulator-1 would have read all twenty-nine from the unconfigured default driver.

Two facts shaped the fix. Neither of them put the decision in doubt.

First, the prefixed case has a quieter form of the same defect. A prefix that names a real IOModule but an address that module does not carry reaches a configured driver which has still never heard of the address, and a simulation driver answers that one the same way. cndcs-deploy-demo's golden-path site binds three ControlModule ports to simulation addressMap keys where an address belongs. An addressMap is expanded by the unit controller and dropped from the wire, so those behaviours run at addresses nothing reads.

Second, a channel list is a claim, and not every module makes one. Device discovery emits an OPC UA IOModule with no channels at all, because the endpoint's address space is the point list. dcs io read says as much in its own help: an address the spec does not declare yet "is read as given, which is how channels are found during commissioning". A module that declares no channels cannot be read against one.

Decision

An I/O address names the driver that answers it, and the document is refused where it does not.

The rule has two halves at two strengths, because the two questions have different answers.

Naming no driver at all is refused at the gateway. A ControlModule whose tagBindings value, or whose template-free tags[].address, carries no <iomodule>: prefix is refused on create. The test is resolveDriver's own, strings.IndexByte(address, ':') > 0. A leading colon is therefore refused too, and no cluster read is involved. A tag naming its module in ioModuleRef is exempt. That is the other road to the same routing, and the tag map rejoins the two with a colon before either reaches a driver.

Naming a driver that does not exist is not refused there. That case is loud where it lands. The runtime answers every read with unknown IOModule driver, and the tag goes empty. Refusing it at save time would make authoring order load-bearing. The discovery wizard, dcs apply -f over a directory and the engineering UI all let an engineer write a ControlModule before the module it binds to.

The stricter rule is a corpus gate. make lint-example-bindings requires every address under examples/ to name an IOModule declared in the same namespace, and to be an address that module declares as a channel. Apply order is not a question in a tree that is read all at once, and the shipped examples are what an engineer copies. A module declaring no channels is reported unanalysed and counted in the summary. It is never passed in silence, which is #1738's rule about a type the registry does not know.

Its scope is every address the corpus hands to a driver: ControlModule tagBindings, ControlModule tags[].address, deviceHealth.address, ControlProgram spec.blocks params after fbruntime.Flatten so a composite's address is read on the inner block that uses it, and ControlModuleTemplate port defaultValue, which fills the same substitution slot as a binding. Which block params are addresses comes from pkg/blockcatalog's own Binding field, so the gate carries no list of names of its own. A catalog param whose name reads like an address that the gate does not classify as one fails the gate, so a future entry cannot be skipped in silence.

A create refuses more than an update. An update is compared against what is stored, and an address that was already unroutable carries through. This product has shipped without the refusal, so a ControlModule carrying one has to stay editable. A PUT that refused what it inherited would make the one surface an engineer would repair it from refuse to save. Same asymmetry as refuseStrandedBindings and the block-type guard, for the same reason.

POST /api/v1/apply is exempt, as it is for the binding and block-type guards. dcs restore crds has to stay able to restore a backup written before this check existed.

Alternatives Considered

Refuse an unprefixed address at the runtime, in resolveDriver. This is the honest answer once nothing legitimately uses the default driver, and it would close the replay path and the raw-fallback path in the same line. It was not taken here. It is a decision about whether the primary driver should exist at all. That belongs with #1743's second option of deleting it, which was not taken either. The primary driver also remains reachable as a memory sandbox from the adapter's own HTTP surface and from most of the adapter test corpus. Refusing at that layer is a larger and separable change.

Refuse at admission with a ControlModule webhook. There is no ControlModule validating webhook today, and #1697 is the record of what routing a webhook over a kind costs. It puts the whole shipped corpus in front of a refusal nothing has ever run over it. The gateway guard is where ADR 0064's family already refuses this class of document, and it reaches every authoring surface this product ships.

Check the channel list at the gateway too. Rejected because an IOModule's channel list is legitimately incomplete on a live cluster. Discovery emits modules with none, deviceprofile.PartialDiscovery publishes what survives with the holes named, and commissioning reads addresses the spec does not declare yet by design. The stricter rule holds where the tree is complete and static, which is the corpus.

Consequences

The flagship real-PLC example is wired to a module. granulator-1's twenty-nine addresses keep their exact text and gain the granulator-plc: prefix. That is the granulator PLC's own OPC UA interface, the module already declared at that endpoint, and it now declares the twenty-five points as channels in both directions.

There is a second effect worth naming, because it is the fix working. The unit resolves a field driver now. Under ADR 0075 it had no field driver to lose, so it abstained from every health verdict taken over its I/O. TestShippedUnitsWatchdogArming logged it as abstains field=[] simulated=[]. It logs WOULD HOLD field=[granulator-plc] now, and two of the nineteen shipped Units reach the Hold verdict where one did before.

Two published claims were false, and both are corrected. docs/control-modules.md gave the binding format and said nothing about what happens without it. docs/library/parameter-binding.md had a validation table asserting that a tagBindings address "exists on the target IOModule. Enforced? Yes. At ControlModule apply time." Nothing enforced it anywhere. The row now says which half is checked, where it is checked, and that the channel half is checked on no cluster.

Amendment (2026-08-21, #1750): the stricter rule ships in the CLI

The corpus gate above found the defect it was built for outside the corpus. #1749 was cndcs-deploy-demo's golden-path site binding three ControlModule ports to simulation addressMap keys. None of the three was an address the driver carried. make lint-example-bindings found it by being pointed at that repository with no code change at all. The gate already took a root argument.

What it could not do was run from the place that needs it. Its home is hack/, which ships in nothing.

The defect class is not specific to this repository's examples. It is what happens to anyone authoring ControlModules by hand against an IOModule, and a customer's deploy repository has exactly the same exposure and exactly the same lack of a check. So the judgement moved to pkg/bindingcheck and the dcs CLI carries it as dcs lint bindings <dir>. The CLI is already what a deploy repository installs.

Two things follow from putting one judgement behind two callers. The corpus gate keeps its root, its wording and its exit codes, because make lint-example-bindings is read by CI and by the pre-commit hook. And neither caller can drift from the other, because there is nothing to drift: both call bindingcheck.Check.

The same move was made for the two sibling corpus gates in #1751, so dcs lint now carries three checks. A bare dcs lint <dir> runs all of them and reports one verdict, which is the form a deploy repository's CI step should use. This address check is one leg of it, and a check added in a later release joins that step without the deploy repository changing anything.

A CI job in cndcs-deploy-demo was considered and not taken. That repository's one workflow resolves a chart pin against GHCR, which is the whole shape of the coupling today: it consumes published artifacts and never this repository's source tree. A job there checking out cloud-native-dcs at a pinned ref to run a hack/ program would be the first exception to that, and it would protect one demo repository and nobody else's.

Amendment (2026-09-03, #1955): the driver stopped answering

The hazard this record is built on was the primary driver's 0.0 at quality Good. That answer is gone. The simulation driver now reports Bad for an address it holds nothing for, and the reason names it as one the module does not carry. See ADR 0074's fifth amendment.

The refusal here stands unchanged, and the reason is worth stating. A tag that reads -- for the life of a plant is a defect found late. The point of this record is that such a binding is refused where it is written. What changed is the cost of the one that gets through. It is now visible on the faceplate the moment it runs, where before it read as a healthy number nobody had cause to doubt.