Skip to content

ADR 0085: A write posture is declared, and refused where the write happens

Status: Accepted Date: 2026-08-31 Issue: #1889 Related: #1303 (the consult that named the gap and sized it), #1884 (the consult that found a read-mostly install needs it enforceable), #1565 and ADR 0060 (UI friction is not enforcement), ADR 0050 (the precedent for refusing a value the destination cannot accept), ADR 0068 (the precedent for what an unset field means)

Context

The product asserts a boundary it could not enforce.

outreach-kit.md § 1C says this system sits as the orchestration layer above the OEM islands, and that each island keeps its own control loops. Every buyer-facing surface repeats it. #1731 exists to film it. #1303 established that the first real deployment on somebody else's controller is where the claim gets tested, and #1884 found that a read-mostly installation on a host's live line is the shape both academic candidates can actually accept.

Until now this was a claim about our behaviour. It was not a property of the software. We declared the channels we intended to use as inputs, and then we behaved.

Nothing refused a write. Adapter.WriteValue resolved a driver and wrote. The FB scan loop reached it by a road with no barrier on it at all. The Adapter is itself the driver.DeviceDriver the network manager runs against, so an AO block calling ctx.Driver.WriteValue landed there with a raw address and nothing consulted.

That last detail is worth stating on its own, because it is #1565's shape sitting inside the thing this ADR is about. A ControlModule tag has carried accessLevel: read since #1277, and TagMap.CheckAccess refuses a write to one. But CheckAccess answers about a TAG PATH. The scan loop writes by ADDRESS. So a control program with an AO bound to a read-only tag's address wrote straight through the barrier, at scan rate, on whatever controller the address named. An operator typing the same value into the HMI was refused. The barrier was on the road nobody drives.

The distinction #1303 asked for

The consult named it precisely, as gap 5 in its sized list:

A supervisory write-policy boundary. Time-scheduled setpoint profiles written to the vendor's exposed setpoints are layer 2. A setpoint computed from feedback (feed rate from DO) is a regulating loop and crosses the guardrail. We should make that distinction enforceable in the write path rather than behavioral, so "we are not in your loop" is provable in diligence.

No issue was filed off that consult. It stayed an unwritten gap for three weeks.

Decision

A deployment declares what this product may do to a device, and the product refuses what the deployment did not declare.

spec.writePosture on Unit and on IOModule takes one of three values.

Posture What it means Where it is refused
Observed This product writes nothing to the device. Every write, at the write path.
Supervisory It may write, but may not compute an output from a live measurement. The network, at load.
Regulating It holds the loop. Nothing. This is what we do on our own equipment.

The two levels compose by taking the more restrictive. A Unit declared Observed cannot be loosened by a module underneath it declaring Regulating. The alternative would let the narrower record override the broader one. A posture that can be widened from below is not a boundary.

Unset means Regulating, and says so

An undeclared posture behaves exactly as the product did before this ADR. Defaulting the other way would stop a running plant's outputs the moment it upgraded. That is the hazard ADR 0068 refused to create when it made an unset fail-safe write nothing.

But the omission is not quiet. WritePostureDeclared on both kinds goes False and says what the record fails to say. The question this ADR exists for is asked in a diligence conversation, and a record that does not answer it cannot be produced. The condition carries the effective posture in its message whether it is True or False, because a condition's STATUS is not its answer (#1768). "Declared" splits three ways, and a surface reading True alone would offer the same remedy for all of them.

Two refusals, because they are two different questions

A write is refused at the write path. Adapter.WriteValue is the one point every road reaches. WriteTag arrives there, and through it ST WRITE, the HMI, the gateway's tag route and the WebSocket. So does the FB scan loop, the safe-output writer on the way down, and the runtime's own HTTP write route. Prober.WriteValue is the second binary with a socket to the same devices, and it carries the same barrier. A boundary enforced on one of the two roads is not enforced.

The read-only address barrier moved to the same place. Both now key on the same thing, and one of them was already wrong for being written twice.

A network is refused at load. Whether an output is computed from a measurement is not a property of any single write. A write carries a number and an address, and nothing in it says whether the number came from a clock or from a probe. Only the network says that. So Supervisory refuses the program at NetworkManager.deploy, before anything can scan. A program that never loads also never writes.

Observed is deliberately not refused at the network. A network is free to drive outputs on several modules and only some may be Observed. Refusing the whole program would refuse the reads too.

The rule that separates a loop from an interlock

Over a flattened network, walk backwards from every block that writes the field. Reaching a block that reads the field means that output is computed from a live measurement.

The walk propagates along a connection only when the destination port's declared data type is not BOOL. That single clause is what separates a regulating loop from a protective interlock. It names no block and no port.

It has to exist, and the shipped corpus is why. examples/templates/vfd.yaml reads the drive's fault bit, inverts it, ANDs it into the run command and SELects the speed to zero on a trip. A rule phrased as "a field reading reaches a field output" calls that a regulating loop. A gate refusing it would oblige a supervisory deployment to strip its fault interlocks, which is demanding a defect. That is the mistake #1741 avoided when it declined to widen the PV_BAD rule to SP.

What separates them is what the port can do with the signal. Every BOOL input port in the shipped catalog is a permission, an enable, a selector, a latch, an edge or a count pulse. AND.IN1, SEL.SEL, AO.ILCK, PID.TRK, TON.IN, RS.S and CTU.CU are all of that kind. None of them is where an operating point arrives. The ports that carry a value are REAL. One is ANY, and that is DO.IN, the command port of a digital output.

ADR 0074's quality flag falls out of this rule. It needed no carve-out. PID.PV_BAD is declared BOOL, so a PV_BAD wire propagates nothing.

What the rule cannot decide, it discloses

A boolean chain from a measurement to a DIGITAL output is genuinely ambiguous. DI -> NOT -> DO is a fault permissive, and it is also on-off level control. The two are structurally identical. Guessing either way is wrong. Calling it regulating refuses vfd, and calling it supervisory lets bang-bang control into a deployment that promised none.

So it is neither. Such a chain is reported as a Gate. Gates do not make a network regulate. The safe reading of the shipped corpus is that they are interlocks, and refusing them at load would strip protection from precisely the installation where we hold the least authority. They are counted in the verdict, and the corpus gate obliges each one to carry a written verdict. A row says permissive with its reason, or gap naming an open issue.

That is ADR 0068's shape, applied to a judgement where 0068 applied it to a device. What the product cannot decide is written down. The omission is never silent.

Alternatives Considered

Enforce it in the authoring gates alone. Rejected. The failure being prevented is a live write on a customer's controller. A bad document is not the subject. A gate proves the property about what we ship. Only a runtime refusal proves it about a deployment on somebody else's line, which is the diligence question.

Enforce it at runtime alone. Rejected. The gate is the half a security review can read on paper, and it is what stops the corpus this product ships from contradicting the boundary it asserts.

Refuse an undeclared posture, the ADR 0057 shape. Rejected. Every document authored before this field existed is undeclared. Refusing them would stop every running plant on upgrade to buy a boundary nobody had asked for. ADR 0068 faced the same fork on the same kind and made the same call.

Judge "a PV read from the same device", as #1889 phrases it. Rejected as too narrow. A bench skid presents more than one endpoint. A DeltaV Application Station and a separate analyser are two IOModules on one piece of equipment, so reading dissolved oxygen from one and writing feed rate to the other is the very loop #1303 named. A same-device rule would pass it. The host's automation engineer is asking whether OUR software computes an operating point from a measurement, and whose sensor it was does not change the answer.

A permissive flag on the connection. Rejected for now. It would let an author declare intent exactly where the ambiguity lives, which is attractive. But it changes the wire format of every network for a case the corpus has once. The verdict file carries the same claim at the same granularity, and it can be promoted later if the count grows.

Consequences

  • "We are not in your loop" is a property of the software. A Supervisory unit refuses to load a regulating network. The runtime, the gateway's save path and the corpus gate all consult one judgement in pkg/loopcheck. They cannot answer differently.
  • A read-mostly installation is a configuration. Observed on an IOModule refuses every write to it, including the fail-safe configuration write. A watchdog register on somebody else's instrument is a larger intrusion than a process value.
  • The accessLevel: read bypass is closed. A tag declared read-only is now refused to the FB scan loop as well as to the HMI.
  • A refusal is counted apart from a failure. dcs_runtime_writes_total{outcome="refused"} is its own series, for #1809's reason. On a read-mostly installation every write is refused by design, and folding that into error would show a permanently failing device on a healthy deployment.
  • examples/supervised-skid/ is the corpus this is held to. Every other example drives equipment the fiction says we own. Without it the gate would have nothing to compare and would report all-clear.
  • A disclosed Gate needs an answer. There is no allowlist. The verdict file has two values, and neither of them means "do not ask".

References

  • Issue #1889
  • Issue #1303, gap 5, where the distinction was named
  • Issue #1884, the read-mostly install
  • pkg/writeposture, the write-path judgement and the refusal wording
  • pkg/loopcheck, the network verdict, the BOOL-port rule and the Gate disclosure
  • examples/supervised-skid/, the corpus