ADR 0082: A promoted runtime resumes from the plant¶
Status: Accepted Date: 2026-08-23 Issue: #1797 Related: ADR 0080 (the same question one node over, and the argument that does not carry), ADR 0006 (the failover this is about, and the sentence this makes true), ADR 0007 (the back-calculation a promoted loop resumes through), ADR 0066 (a different read-back, and why it is the wrong instrument here)
Context¶
The bench cut power to the active node under a running batch, three valid reps
on 2026-08-23 (#942
drill 1, on v0.5.1). The coupler held both output values for the whole
dead-node window at quality Good, which is ADR 0006 and
#1683
behaving exactly as documented. Then the standby was promoted, and both
outputs stepped to zero about four seconds before the unit reported itself
controlling.
| Rep | Cut | Outputs → 0 | Standby controlling |
|---|---|---|---|
| 1 | ipc-2 | +52.1 s | +55.6 s |
| 2 | ipc-1 | +52.1 s | +55.9 s |
| 3 | ipc-2 | +53.5 s | +57.6 s |
Rep 1 alone cannot say what the zero is, because that recipe's own shutdown phase ran five seconds earlier and the recipe ending is the obvious reading. Reps 2 and 3 refute it: the shutdown phase runs 30 s and 66 s after the outputs have already zeroed, while the offset stays put.
ADR 0080's argument is a same-node argument¶
ADR 0080 made a
runtime restarting in place come back on the values it was commanded with,
and it deliberately put no age bound on the restore. Its reason is written in
internal/adapter/network_state.go: with hold declared or undeclared the
device is still holding the value we last drove it to, so restoring is a no-op
at the terminals.
That reason holds because the snapshot and the terminals are two copies of one fact. A promotion breaks the identity. The snapshot on the standby's disk is what this node last commanded, in a stint that ended when the unit was re-bound away from it. The terminals hold what the node that just died commanded, seconds ago.
So the fix for the restart made the failover worse, and nothing said so. A standby that has hosted the unit before finds its own leftover state.json
and re-drives it. That is the bench's own topology, since the two IPCs trade
the unit back and forth. Reproduced off the rig: node A holding the plant at 50 %, the
standby takes over and writes 20 % from a four-hour-old file, annunciated as a
successful continuity restore with a beyond-failsafe-floor freshness label.
Age was the only thing recorded, and age is the wrong instrument. The
four-hour-old snapshot reads as "old but ours". It was never ours.
Five published sentences were false¶
ADR 0006 §Decision.3, ADR 0008 (twice), docs/failover-runbook.md and both
node-failure rows of docs/ha-failure-modes.md all say outputs hold last value
across the gap and the gap ends on an ISA-88 Restart. Row 70 is the sharpest:
"Held outputs during the gap, then the unit is re-bound to the standby and the
procedure running on it holds with it."
ADR 0006 §Decision.3 compares the failover gap to a pod restart, in the words "exactly as for a pod restart today". ADR 0080 fixed the pod restart. That moved the comparison and left the failover half false in a new way.
Decision¶
A promoted unit runtime adopts the operating point the device is holding. Where the device cannot answer, it writes nothing at all.
Founder ruling of 2026-08-23, taken against the measured option set.
A promotion is a binding change, and the binding epoch says so¶
state.json carries the binding epoch that wrote it. The epoch increments on
every re-bind (unit_failover.go) and deliberately does not move on a pod
restart (podAvailabilityDrift says so in as many words), so "this snapshot is
mine" is exactly an epoch match. Nothing else in the snapshot could answer it:
savedAt measures age, which is the question ADR 0080 already settled.
An equal epoch is an in-place restart and ADR 0080 applies unchanged. Epoch 1 is a unit's first binding and epoch 0 is a runtime started with no flag, and neither can be succeeding anybody. That is what keeps this off the ordinary path: a first boot, an image bump, an OOM kill and a node reboot all leave the epoch where it was.
--binding-epoch therefore goes to every unit-runtime pod and not only to
a leased one. An Autonomy-mode unit is re-bound by the same
dcs unit failover --confirm-fenced path and its epoch increments the same
way, so without the flag its promoted runtime would read another binding's
operating point as its own. Passing it enables nothing else. NewLeaseGuard
fences in Failover mode alone.
The read-back is already built, and it is not ADR 0066's¶
ADR 0080 considered adopting what the device holds, called it the best answer
on paper, and declined it because one template in the corpus declares
ADR 0066's readBack.
That is the wrong instrument. readBack is a program-authoring concept that
wires an input block to an output's address. What a promotion needs is the
address, and the product has carried it per channel since
#1689:
| Layer | What it holds |
|---|---|
pkg/deviceprofile/wago750 |
The documented 0x0200 read-back offset (750-352 manual §11.2.4, Table 101) |
discovery → IOChannel.ReadbackAddress |
holding.N → holding.N+512, coil.N → coil.N+512 |
internal/adapter/ioconfig.go |
buildReadbackMap |
internal/adapter/server.go |
ReadValue substitutes the read address transparently |
It was cross-checked against a multimeter in
#1502 and
again in #1712,
and ha-drill.sh samples holding.512/holding.513 once a second for the
whole drill. The instrument that measured this defect is the read-back itself.
The drivers are connected before the restore runs. a.Start(ctx) is at
cmd/unit-runtime/main.go:246, and RestoreAll runs inside NewHTTPServer
at line 315. So a promotion can read the terminals in the same slot ADR 0080
installs a snapshot into, after Load and before the scan goroutine.
That premise held on the bench where this was written and failed on the first failover measured after it. The withhold is still in that slot. The read is not, since #1935, and the amendment at the end of this document says where it runs now.
Adoption is offered only where a channel declares a read-back. Reading an
undeclared output address does not fail. On a WAGO coupler the write address
answers from the input process image, at Good quality, carrying another
channel's number. The drill runbook records 141 of 172 samples byte-identical
to input.0. An opportunistic read is not a best-effort adoption. It is
ADR 0076's hazard with
a different address on it. A reading the block cannot express is refused rather
than clamped, for the same reason: the block's own range is the last check
there is, and clamping would launder a wrong-image reading into a plausible
operating point.
Three output phases¶
Withholding and driving are the obvious pair. Between them sits a block that
knows exactly what the plant is at and still has no idea what it is supposed to
be, and that is every output commanded through a constant. The operating point lives in the constant. A promoted runtime's copy of it is
a compile-time default. A block that adopted 60 % and then passed IN through would write
zero on its very next scan, which is the defect wearing one more layer.
| Phase | What the block writes |
|---|---|
driving |
What IN carries, every scan. Every block starts here and an ordinary program never leaves it |
holding |
What it adopted from the device, ignoring IN, until something commands it |
withheld |
Nothing. The device goes on holding whatever it has |
A withheld output is a silence about the operating point and not a silence on
the wire. The device's own watchdog is fed by any telegram at all, whatever its
trigger mask says.
ADR 0068 measured
that on the live coupler. With FC3 excluded from the mask, once-a-second FC3
reads held the watchdog open for twenty seconds on a five-second timer, and the
mask governs only what may restart it after a timeout. The scan goes on reading
that coupler's inputs while an output withholds, and the io-probe reads it on
its own cadence. That cadence is the heartbeat
ADR 0071
measures its timeout floor against. So a coupler declared clear does not
fail-safe underneath either phase. Adoption is the better of the two answers
because it continues control, and not because withholding would cost the plant
its watchdog.
The phase is half the operating point¶
Holding and withholding are long-lived states. They are not moments. An output commanded through a constant holds what it adopted until something commands it, and one whose channel declares no read-back withholds on the same terms. The first consequence below says "for ever". For four days it was not true (#1800).
aoState and qxState persisted the last value and not the phase. The next
Load therefore brought the block back in driving, carrying a restored
operating point that nothing in that phase reads. The first scan wrote IN,
which is the constant's compile-time default. That is the step this decision
exists to prevent.
Two ordinary events reach that Load. An engineering hot-swap carries the
outgoing runtime's own state across. The kubelet restarting the pod in place
classifies as a restart and not as a promotion, because the binding epoch has
not moved. Neither event is a promotion, so neither re-adopts. The step lands on
a plant the coupler has been holding correctly since the other node died.
The phase is saved and restored beside the value now. It travels as its own
word and not as its position in the list above. The file outlives the build
that wrote it, and one of the three answers is to write nothing. A word this
build cannot name is refused, because the cost of refusing is one block
restoring nothing and the cost of guessing is a write. A snapshot written
before the field existed carries no phase at all. That decodes as driving,
which is what it always meant.
The release had to move with it. The cone an external command releases through
was built by WithholdOutputs, and only a promotion calls that. An output
restored into withheld would have been deaf to every command for the life of
the pod. Load builds the cone now, because a block can be non-driving without
this runtime having withheld anything.
A loop resumes through the back-calculation it already has¶
Seeding the output block does not carry a regulating loop across, and ADR 0080 is where that is written down: with the setpoint restored and the process already at setpoint the error is zero, a fresh integrator contributes nothing, and the loop computes the bottom of its range while every input to it looks correct. A promoted runtime cannot recover the integrator, because that is precisely the state the dead node had.
PID already back-calculates its integrator onto TRK_VAL while TRK holds
(ADR 0007). A promotion arms one
scan of that, so the loop starts on the value the device is holding. It reuses the existing machinery. A second implementation would compute the
integrator a second way, and a promoted loop now resumes exactly as an
interlock release does. One armed scan is where this started and it was not
enough on its own, for the reason the section below sets out.
It is armed only where TRK_VAL is wired to an output that actually
adopted. Both halves are load-bearing. Unwired, Bus answers nil, and nil coerces to zero. Arming would then drive
the loop to the bottom of its range by the road this exists to close. And a source still withholding has never been told
anything either, so its OUT port is the compile-time default. A loop that
will bump is reported at promotion, because that is the only moment anybody can
be told.
A loop tracks its held output for as long as that output holds¶
The armed scan is a moment, and holding is not. That is the same mistake as the one two sections above, one layer over, and it survived four days for the same reason (#1815).
resumeFromPlant logs the promise in as many words. It writes a promoted loop
will resume on the value its output is holding at promotion, and the resume it
describes cannot happen at promotion. The output it names is holding, so it
re-writes what it adopted every scan and ignores IN until something commands
the loop back. On the bench nothing does so for a minute or more. By the time
something does, the sentence is false.
ArmTrackInitialisation arms exactly one tracking scan. PID.TRK is wired
from the AO's ILCK_ACTIVE, and that signal is false. So from the second scan the loop
free-runs against the setpoint constant, and a promoted runtime's copy of a
constant is its compile-time default. The integrator winds away from the
operating point the AO is faithfully keeping, and anti-windup pins it a couple
of scans later. Then somebody commands the loop back, the AO switches to
driving on the same command, and its very next write is that wound value.
pid.OUT |
write_out.OUT |
device | |
|---|---|---|---|
| after promotion | 29.95 | 59.95 | 19644 |
| holding, with the setpoint still the compile-time 0 | 0 | 59.95 | 19644 |
| first scan after the release | 53.95 | 53.95 | 17678 |
That is a 6.00 percentage-point step down at the terminals, at the instant control resumes, against a loop whose measured operating band is 0.029 sigma (#1794). The step is the same at 0.5 s, 2 s, 10 s and 30 s of holding, because anti-windup bounds it. What sets it is the tuning and the distance between the compile-time default and the held point. How long the node was down does not come into it. The AO half of this decision worked perfectly throughout, and the plant sat at 19644 for the whole window.
A loop tracks its held output for as long as that output is not driving, and
the release is one last tracking scan. The condition is a runtime state and
not a plant fact, so the PID asks for it each scan through
FBContext.SourceOutputPhase. Declaring it on the document would be ADR 0074's
shape, and it would buy authoring visibility this does not need at the price of
re-wiring every loop in every corpus. No document changes, then, and no gate to
hold the wiring. It also makes the log line above true
whenever the resume actually arrives.
The release is a tracking scan and not simply the end of tracking. The command that releases the output is usually the same command that installs the real setpoint, so back-calculating one more time is what puts the loop on the held value at the setpoint now in force. Ending tracking a scan earlier leaves an integrator computed against the compile-time setpoint. Measured through the runtime on the bench's own tuning, that reverses the sign of the step and keeps its size: 84 % onto a plant held at 60 %, where the untracked loop handed over 24 %.
Withholding is deliberately not tracked onto, and that is the line
ArmTrackInitialisation already drew. A withheld output has never been told
anything either, so its OUT port carries a compile-time default. A loop above
one goes on running, and the promotion log says it will resume from a fresh
operating point and may step the field. That is the honest answer when nothing
anywhere knows what the plant is at.
The one armed scan stays for the case it was written for, which is an output that is already driving on the first scan.
The release, and what is never withheld¶
A withheld output that never writes again is a silence. Nobody should read it
as a safe state. The release is somebody standing behind a value: the ISA-88 Restart's own
restarting logic, an operator command, a recipe WRITE. It is scoped to the commanding block's cone, meaning every block whose value
can reach that output. So a recipe that commands one loop does not put a
second one back on the field on no evidence. The closure guards against revisiting, because a tracking wire makes
the graph cyclic on purpose.
An interlock trip and the program-halt fail state are never withheld. Each names a value the block does stand behind, and a withheld protective action is worse than a bump.
Alternatives Considered¶
Replicate the dead node's last value. The runtime already publishes every FB output tag off-node at 5 Hz. Making a standby able to consume it means a retained topic plus an ACL grant (#1781's rule). It also amends ADR 0006's "does not replicate runtime state to a standby". Considered seriously and not chosen, on one asymmetry: a replicated snapshot says what the dead node wanted, and a read-back says what the plant is. They agree under a power cut, where the last copy is at most a second old. They diverge under a cluster-path partition, where the node kept controlling and then ran its bounded hold chart and published none of it. That is exactly the failure the fencing machinery exists for. It also leaves the staleness question open, which is the question that produced this issue.
Keep writing the compile-time default where the device cannot answer. Today's behaviour, and the cheapest. It leaves the bench's headline exactly as measured on any channel with no declared read-back, so the five published sentences would have to be rewritten to say the field is bumped on every failover. Rejected in the ruling.
Refuse to promote when the operating point cannot be established. The loudest and the strongest safety posture. It converts a recoverable failover into an outage on any deployment that has not declared its read-back addresses, which today is every deployment including the bench. Rejected.
Bound the restore by age instead. This is the bound ADR 0080 already examined and declined, and the failover case does not rehabilitate it. Age was never the thing that made the standby's snapshot wrong. Provenance was.
Consequences¶
- The five documents that publish hold-last-value across a failover are now true. On a channel with a declared read-back the gap ends on a continuation. On one without, it ends where ADR 0006 always said it did, on the recipe's own Restart.
- A deployment has to declare where its outputs read back to get the good
path. Discovery does it automatically. A hand-authored IOModule does not.
The bench's own is hand-authored.
dcs_runtime_output_adoption_totalreportsno-readbackfor every channel that has not, per promotion. - A loop above a held output is tracking, so its
CVis the plant's own value and not a computed one. That is what the promotion log line has always claimed and what the 5 Hz telemetry, the HMI and anything wired downstream of the loop now read. The loop starts deciding again on the scan that releases its output, and that scan is a back-calculation. - A promoted output that nothing ever commands holds or withholds for ever.
That is the ruling, and since
#1800 the
phase survives a hot-swap and a pod restart, which is what makes "for ever"
mean it. Both long-lived phases are annunciated, and since
#1816
that sentence is true of
holdingas well. See the section below. - ADR 0080 is unchanged wherever the epoch matches, which is every restart that is not a re-bind.
- The lease endpoint does not wait on the coupler. Since #1935 the read-back runs beside the scan, and the listener the grant arrives on is up before the coupler has answered. See the amendment below.
- Adding a block that writes to a device means implementing
EstablishableOutputon it.TestEveryBlockThatWritesADeviceCanBeWithheldreads the source of every block reachingctx.Driver.WriteValueoutside its fail-state path and requires it. A hand-kept list would agree with the runtime by assumption (#1655).
A holding output is writing, so it had to be counted separately¶
The sentence above said "annunciated and never silent" from the day it was
written, and for holding it was false for four weeks
(#1816).
Measured on the bench on 2026-08-25, a unit promoted an hour and forty-five
minutes earlier with both its outputs adopted and holding ever since:
dcs_runtime_output_adoption_total{outcome="adopted"} 4
dcs_runtime_outputs_unestablished{network="loop-pid-logic"} 0
dcs_runtime_outputs_unestablished{network="aux-output-logic"} 0
dcs_runtime_writes_total{outcome="ok"} 115252
dcs_runtime_writes_resumed_total 1
Every one of those readings is correct and none of them is the answer. Four things to know.
Writing is what makes it invisible. A withheld output is a silence, and a
silence can be counted by counting the blocks that are silent. A holding output
writes at scan rate. It moves the same counter a program driving the plant
moves, at the same rate, carrying the same ok outcome.
ADR 0081's fenced outcome
separates a write that never reached the field. That is a different case and it
does not cover this one. There is nothing to subtract and nothing to compare, so
the discriminator has to come from the block.
A counter cannot say a state is still current.
dcs_runtime_output_adoption_total records that an adoption happened. The
question an operator brings is whether the block is still in the phase that
adoption put it in, and no monotonic series can answer it. That is what
dcs_runtime_outputs_holding is for.
The gauge is swept. Both gauges were set once per deploy, which was already
wrong for the unestablished one and would have been useless for this one. The
release is an operator command, a recipe WRITE or the ISA-88 Restart's own
restarting logic. None of those is a deploy, so a block released a second after
promotion would have gone on being reported as holding for the life of the pod.
Both are recomputed once a second off what each runtime reports. That is
level-triggered for
#1806's
reason: the phase changes inside the runtime on whichever goroutine a command
arrives on, and a gauge wired to one caller's edge is a gauge the next caller
defeats by existing.
No condition and no alarm, which is the line
#1669 drew
for configError. The phase reaches the gauge, the runtime's diagnostics
response, dcs get runtime and the unit's Diagnose panel. It reaches no Unit
condition and raises no alarm. Nothing here changes control behaviour, and
holding is not a fault: the plant is where the promotion found it and the
coupler is holding it there. What is wrong is that no part of the program is
driving it. That is a deployment finding about the recipe, on the same footing
as a refused simulation profile. The recipe that produced the bench reading is
the case in point. bench-hold-at-setpoint writes aux.CMD and never
loop.SP, so the loop stayed open from the failover to the end of the batch
through an ISA-88 Restart the drill recorded as recovered. That was fixed in the
bench recipe. The product cannot fix it for anybody else, and now it can say it.
While correcting one false sentence: a withheld output does not reach the unit's
health either, whatever UnestablishedOutputs said about itself before #1816.
Adapter.HealthStatus is computed from field-driver connectivity and from
nothing else
(ADR 0075),
and the unit controller's watchdog reads the status API, which carries no phase.
The plant is asked beside the scan, and the lease does not wait on it¶
(#1935
amendment, 2026-09-03.) The read-back section above placed the read between
Load and the scan goroutine, on the premise that the drivers are connected
before the restore runs. On a failover replacement that premise is the thing
most likely to be false. The runtime is starting on a node the unit was not
running on, the coupler may be unreachable from it, and the driver's connect in
Adapter.Start fails and defers to the first I/O. The promotion's read-back is
the first I/O. So the read dials, and the dial is bounded by the transport's
own timeout or by the kernel's ARP clock, neither of which the caller set.
Two deliberate failovers on the bench, chart 0.7.5, read the same six seconds
between runtime started successfully and starting runtime HTTPS server. The
health, TLS and metrics listeners between those two lines came up inside 12 ms.
The rest was two Modbus dials to a WAGO the replacement node had no route to,
3.07 s each, one per output block. They were made from inside deploy with the
manager's mutex held, inside RestoreAll, inside NewHTTPServer, before the
listener the lease grant arrives on existed. The operator's re-deploy of every
network after the grant paid the same price again, inside the HTTP handler. A
coupler that answers ARP but drops the connection would have cost the
transport's full timeout per output, 5 s for Modbus and 10 s for OPC UA. A unit
with a dozen outputs would have reached the 120 s start-up grace with no lease
endpoint to be granted on. That is
#1933's
shape one function over: a network round trip on the goroutine control depends
on, bounded by nothing.
What the slot protected was the first scan's write, and the withhold is still
in the slot. WithholdOutputs runs between Load and the scan goroutine as
before, and a withheld output writes nothing however many scans pass. The
asking moved. resumeFromPlant runs on the entry's own goroutine and context,
beside the scan. Each output adopts when its answer arrives.
Runtime.AdoptDeviceValue and Runtime.ArmTrackInitialisation take the scan
lock for the reason StateSnapshot does, so an adoption lands between two
cycles. The tracking scan is armed after the adoption, however late that is.
That is what
#1815
requires. A Replace or a Remove cancels the entry's context. An answer that
arrives for a network no longer running goes nowhere: it is not counted, and
the phase gauges are set only if the entry is still the one the manager runs
under that name. NetworkEntry.Adopted is closed when the adoption has
finished, and nothing on the control path waits on it.
On a coupler that answers, the read takes a few milliseconds and the first
scan comes a full interval after Run starts, so in practice the adoption
still lands before the first scan. The change is visible only where the
coupler is slow or away. There the outputs withhold for exactly as long as
before, while the lease endpoint listens from the start.
The driver connect inside Adapter.Start is the same class and is still on
the start path. It took 3.07 s on the same reading, before the health server
listened. That is
#1936.