ADR 0066: A read-back is declared on the output it reads¶
Status: Accepted
Date: 2026-08-17
Issue: #1668
Related: #1667 (the corpus gate that surfaced it, and the refusal this must not widen), ADR 0054 (the effective command is the output block's own OUT, which is what a read-back is measured against), ADR 0064 (a reference is refused where it is authored)
Context¶
A ControlModuleTemplate declares named input and output ports. A
ControlModule binds each of them to a device address. pkg/templatecompiler
substitutes those addresses into the network's I/O blocks and then checks the
directions. An input block (DI, AI) must not land on an address the
template declares as an output, and an output block (DO, AO) must not land
on one it declares as an input.
That check earns its place. examples/opcua-poc/05-controlmodules.yaml bound
both ports of a valve template to the one writable node the simulator exposes.
The compiler refused it, so the ControlModule never became a ControlProgram. A
valve template raises MISMATCH when the driven position is not confirmed by
an independent feedback contact. Point that contact at the coil it is meant
to confirm and the alarm can never be true, which is worse than no alarm.
What the check also refused was a read-back. On OPC UA a node is routinely both
the command and its current value. ns=3;s=PocBool1 in that same POC is
exactly such a node, and on Modbus a coil is readable and writable, where
reading back one you drive is ordinary practice. Reading such a point says
something the driven value cannot. write_state.OUT is what this runtime
last wrote (ADR 0054). The read is what the server now holds. A write the
server clamped, a second client writing the same node, and a write that
reported success and did not take are all invisible in the first and visible in
the second.
Both routes to one were closed, because the check compares resolved addresses.
Pointing a DI at {{.outputs.state}} is refused. So is declaring a second
input port and binding it to the same address.
The workaround was to give the instance its own tag carrying a raw address
and ioModuleRef, which buildTagMap routes to raw I/O. It costs two things.
The read lives on the instance, so it cannot be reused across instances of the
template the way a port can. Every module that wants it repeats the address the
binding already carries. And it sits outside the block network, so no block can
consume it. Most of what a read-back is for is driving a comparison, an alarm
or an interlock, and none of those can reach it there.
Decision¶
An output port declares readBack: true when the device point it drives can
also be read. That licenses an input block in the same template to take its
address from {{.outputs.<name>}}, and nothing else.
There is no read-back input port. The issue proposed one, an input port naming the output it reads back. The field is on the output instead, for three reasons.
- One point, one binding. A read-back is a second way of looking at one device point. It is not a second point. A separate port would ask every instance to write the same address twice, which is the duplication the workaround was already criticised for. It would also give an instance a way to point the two halves at different nodes.
- It cannot be offered as an independent input. The issue asks what the FB editor should show for a read-back port, since an engineer offered one as an input will wire it as one. On the output there is no such port to offer.
- The instance-level question stays where it was. An instance that binds an
independent input port to the address its output writes is the
poc-discrete-valveshape, and it is still refused. The declaration says nothing about it.
The licence is granted per block, on the address the author wrote. This is
the part that took a fix and not a flag. The direction check runs on resolved
addresses, and after substitution an address written as {{.outputs.state}}
and one an instance happened to bind to the same string are the same string. So
the compiler carries each block's address parameter through the compile in its
pre-substitution form. The exemption then goes only to an input block whose
whole address is the output-port reference. An input block reaching that
point by another route, a {{.params.probe}} an instance supplies or a
literal, is refused. The template said nothing about it, and the block would
read the driven point every scan on the strength of a declaration made about a
different block.
An address-keyed exemption would have looked equivalent and is not. In the common template the difference is masked. The output block on that address is refused by the other half of the check, because an aliased input port puts the address in the input map. The masking disappears exactly where there is no output block to catch it, which is why the test for this drives a template with no input port at all.
Two shapes are refused outright. readBack on an input port is refused. An
input is already read, so the field there can only mean the author looked for
it in the wrong place. An output port declaring readBack that no input block
reads is refused too. The declaration licenses exactly one thing, and one that
licenses nothing is a claim about the device that nothing in the template
exercises. That is the shape
#1646 and
#1660 both
name.
The FB editor derives the field from the canvas. It has never asked an
engineer to declare a port. spec.inputs and spec.outputs are generated on
Save from the address names typed into I/O block parameters. So the gesture
that means "written and read back" is typing one name on a write block and on a
read block. Before this decision that produced an input port and an output port
of the same name, and a template the compiler refused. It now produces one
output port carrying readBack, with the read block's address wrapped to
{{.outputs.<name>}}. The Template Interface panel and the editor summary name
the point under Read back. It no longer appears as a plain write. The
instance binding forms label it out, read back, so the single binding field
is not read as a missing one.
Alternatives Considered¶
A read-back input port naming the output it reads back, which is the shape the issue proposed. It survives the instance-alias question, because the pairing is declared and any undeclared alias stays refused. It loses on the other two counts. The instance still binds two keys for one device point. The duplication the workaround was criticised for therefore moves from the tag into the binding map, and an instance gains a way to point the halves at different nodes. The FB editor is the second count. It authors ports by deriving them from block addresses, so it has no gesture that produces such a pairing. It would have needed a new control whose whole job is to say that two ports are one point.
Same-named ports, an input state beside an output state. That is what
the editor's natural gesture already produced, and what the compiler already
refused. It has the one-binding property, since both resolve from one key. It
was rejected because it is a declaration only by coincidence. Nothing
distinguishes a deliberate read-back from two ports that collided, and the
binding forms would show two rows carrying one name.
Dropping the direction check for input blocks. The check is what
#1667 found
two shipped modules with, one of them a MISMATCH alarm that could never fire.
A read-back is a narrow, nameable exception to that check. It is no reason to
stop asking the question.
Letting the instance declare it, by binding an input port and an output
port to one address and accepting the pair. That is exactly the
poc-discrete-valve document. No instance-level field can tell a template
whose input is a read-back from one whose input is an independent contact. Only
the template author knows which it is.
Consequences¶
- A ControlModuleTemplate can read back a point it writes, inside the block
network, where the value can drive a comparison, an alarm or an interlock.
examples/opcua-poccarries the worked example.poc-discrete-outputgains aSERVER_STATEtag besideSTATE. - Every refusal that stood before this stands after it. The compiler still refuses an input block on an output address by default. It still refuses an instance that aliases two ports onto one point, and an output block on an input address.
- The declaration is a statement about the device, made by the template author. It says nothing about a particular instance's wiring, and an instance can neither make it nor withdraw it. A template whose point is readable on one plant and not another needs two templates, the same way a template whose point does not exist does.
make lint-example-cmtemplatesreads all of this without a change, because its oracle is the compiler itself. RemovingreadBack: truefrom the POC template turns the gate red and names the field.- The field is not a driver capability check. Nothing in the tree enforces the
directionan IOModule channel declares when it reads or writes. A template declaringreadBackagainst a point the server will not serve fails at the driver, where an unreadable node already fails.