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.