Skip to content

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-valve shape, 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-poc carries the worked example. poc-discrete-output gains a SERVER_STATE tag beside STATE.
  • 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-cmtemplates reads all of this without a change, because its oracle is the compiler itself. Removing readBack: true from 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 direction an IOModule channel declares when it reads or writes. A template declaring readBack against a point the server will not serve fails at the driver, where an unreadable node already fails.