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.
Observedon 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: readbypass 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 intoerrorwould 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".