ADR 0072: A manual override releases through the block that tracked it¶
Status: Accepted
Date: 2026-08-20
Issue: #1715
Related: ADR 0007 (the TRK/TRK_VAL
back-calculation this reuses), #1255
(the equipment-mode write barrier this relies on), ADR 0064
and ADR 0018 (the marker-interface
precedent this follows)
Context¶
Found at the bench working #1502: the pid-loop.md commissioning workflow
teaches "Switch to Manual mode from the faceplate. The operator owns CV
directly. Step the CV by 10-20% and watch how PV responds." It wasn't
possible. CV was declared accessLevel: read in pid-loop.yaml, and
identically in pid-cascade.yaml's inner loop, hardwired to the pid
block's OUT port. pkg/fbruntime had no mode-awareness anywhere in the
package. It had no Manual/ModeManual reference and no mode-conditional
branch. The PID block computed CV from SP/PV/gains and wrote it
every scan regardless of the ControlModule's declared mode. The only way to
get a PID-free read of a physical AO/AI pair was to stop the FB scan out of
band. Deleting the unit-runtime pod, or scaling the operator to 0, worked at
the bench. Neither is a supported operator action.
Two things made this look larger than it turned out to be.
The override mechanism already does most of the work, for free. A
readwrite tag bound to a block's output port (blockRef/portRef) does not
write into the block. It calls Runtime.SetOutputValue(blockName, portName,
value), which installs an entry in outputOverrides. Two places read that
map. executeScanCycle prefers it over Outputs() when collecting each
block's published value. makeFBContext's Bus closure prefers it over the
source block's live output when resolving a data connection into the next
block that reads it. This is exactly how SP already works. It is bound to
a REAL_CONST (sp_const) whose Execute does nothing at all. The
override is the value, permanently, until the next write. Making CV
readwrite and routing it through the identical mechanism costs nothing new.
write_out (the AO downstream of PID) already reads pid.OUT through
Bus, so once an override sits on pid.OUT the physical output follows it
on the next scan with no template or runtime change at all.
What the override mechanism does not do is expire. Nothing in the
codebase before this issue ever deleted an entry from outputOverrides.
That is correct for SP. An operator or recipe setpoint should persist
until someone changes it again, whatever the mode. It is wrong for CV.
Switching back to Automatic has to hand control back to the loop's own
computation, or Manual mode becomes a one-way trap and Automatic never
again means what Table 1 says it means. Getting that release right, and
bumplessly, is what this ADR actually decides.
Decision¶
CV becomes a normal readwrite computed tag, gated by the barrier that
already exists. The PID block detects its own override and tracks it with
the mechanism it already has. Release is a runtime capability scoped to the
block, triggered by the existing mode-transition handler. It needs no new
CRD field and no new mode concept in the FB network.
-
CVisaccessLevel: readwrite,role: command, in bothpid-loop.yamlandpid-cascade.yaml's inner loop. No gateway change was needed to gate it.equipmentModeBarsWrite(#1255) already refuses a direct tag write on any ControlModule that does not resolve to Manual, independent of which tag. A second, PID-specific mode check would have duplicated a barrier that already covers this write correctly.pkg/fbruntimegained no notion of ISA-88 mode. It still has none and should not: mode enforcement is a gateway concern and stays one. -
FBContext.SelfOverridden(portName)lets a block ask whether its own named output is currently overridden.PID.Executefolds the answer into the exacttrk/trkValvariables the device-interlock case already populates fromBus("TRK")/Bus("TRK_VAL"):
if ctx.SelfOverridden != nil {
if manualVal, manual := ctx.SelfOverridden("OUT"); manual {
trk = true
trkVal = manualVal
}
}
Every line below that is unchanged PID code from ADR 0007: the
back-calculated integral when ki != 0, the frozen-but-memoryless case
when ki == 0, the resume behavior on release. A Manual CV write is
tracked exactly like a device forcing the output through an interlock,
because to the loop it is the same shape of problem. Something outside
the PID's own computation is driving CV, and the integrator must not
fight it or wind up against it. Bumpless transfer in both directions
follows with zero new PID logic and zero new wiring in either template's
dataConnections.
-
Release is a marker interface.
ManualOverridable(ManualOverridePort() string) is implemented byPIDalone today.Runtime.ReleaseManualOverrides()sweeps every loaded block. For each one implementing the interface, it deletes that block's tracked port fromoutputOverrides. The scope is deliberate.SPis also a readwrite tag bound to a block's output port (sp_const/REAL_CONST). A release that cleared every readwriteblockRefoverride without distinction would wipe the setpoint back to its compiled-in default every time equipment mode cycled to Automatic.REAL_CONSTcomputes nothing on its own. There is no automatic value for it to hand back to, only the value someone wrote.PIDcomputes something every scan whether or not it is overridden. That is precisely what makes handing control back to it meaningful. The interface encodes that distinction once, at the type that has the answer. It follows the same shape asReadOnlyOutputBlock(ADR 0018) andBypassable(ADR 0010): a block declares a capability, and the runtime asks for it with a type assertion. No fourth piece of CRD metadata enters the picture. -
The gateway triggers release from the existing mode-transition handler.
handleSetMode, after committing a ControlModule's mode annotation toAutomatic, calls a new runtime endpoint (POST /api/v1/controlprograms/{name}/manual-overrides/release) that reachesNetworkManager.ReleaseManualOverridesand thenRuntime.ReleaseManualOverrides. This mirrors the existing interlock bypass proxy (handleSetInterlockBypass): resolve the owning Unit, resolve the runtime's base URL, POST, audit the outcome. It is best-effort. The CRD mode annotation is the ISA-88 source of truth for mode and has already landed by the time release is attempted. A runtime that is briefly unreachable must not turn a mode transition into a failure. A missed release leaves a narrow window that the operator's next Manual/Automatic cycle closes on its own. The override, once installed, is otherwise stable. It does not decay or drift.
Alternatives Considered¶
-
A new CRD field on
Tag(e.g.releaseOnAutomatic: bool) to drive release. Rejected. It would need a CRD schema change,make manifests, and a webhook or the gateway re-deriving something the block's own nature already answers:REAL_CONSTcomputes nothing,PIDcomputes something. The interface answers "do I have anything to hand back to" at the one place that actually knows. It adds no CRD surface and gives a template author nothing new to set on the wrong tag. -
Give
pkg/fbruntimea live feed of ControlModule mode, for example wiring aMODEsignal into the FB network as a first-class Bus value. Rejected as a far larger change for no added correctness. The FB runtime today knows nothing about ISA-88 concepts. Mode enforcement lives entirely in the gateway (#1255), and this issue does not need that boundary to move. The override mechanism already carries everything the runtime needs to know: that something wrote a value. It does not need to know why. -
New dedicated
MAN/MAN_VALinput ports onPID, wired explicitly in the template'sdataConnectionsfrom some new manual-override signal. Rejected. Nothing in the FB network can produce that signal without the mode-feed alternative above, and the ports would duplicate theTRK/TRK_VALmachinery for a case that is, to the block, identical. -
Trigger the release from a ControlModule reconciler. Rejected. The release still has to reach the running unit-runtime process over HTTP. The gateway already resolves the CM to Unit to runtime chain synchronously inside
handleSetMode. A reconciler would need the identical proxy plumbing plus an extra reconcile-loop round trip, for a request that is fundamentally the one that just committed the mode annotation.
Consequences¶
- Code:
pkg/fbruntime/types.go(SelfOverridden,ManualOverridable),pkg/fbruntime/runtime.go(makeFBContextwiring,ReleaseManualOverrides),pkg/fbruntime/blocks/pid.go(ManualOverridePort, the self-override branch),internal/adapter/network_manager.goandfbhandler.go(the release endpoint),internal/gateway/mode.go(releaseControlModuleManualOverrides, called fromhandleSetMode),examples/templates/pid-loop.yamlandpid-cascade.yaml(CV:readwrite,role: command). - Docs corrected to match:
docs/library/control-modules/pid-loop.mdandpid-cascade.mdalready described this workflow as working. The "Bumpless transfer on Manual/Auto" line inpid-loop.md's "When to escalate" section named this as a known gap and is removed.docs/compliance/isa88.mdgains a Table 1 Manual row.docs/compliance/iec61131-3.md,docs/architecture.mdanddocs/library/alarms-and-interlocks.mdgain a short cross-reference each. - The release is best-effort and can race a runtime that is briefly unreachable at the exact instant Automatic is requested. The override then survives until the next Manual/Automatic cycle. It does not release immediately in that narrow case. A retry loop was judged out of scope. The window is narrow and self-healing on the next mode cycle, and a retry would need its own state machine for a case that has not been observed to matter in practice.
- Not in scope: a second
ManualOverridableimplementer. The interface exists for any future block that computes an output and wants the same handback, but onlyPIDneeds it today. Also not in scope: any new faceplate affordance distinguishing "Manual,CVnever written" from "Manual,CVoverridden." The tag renders like any other readwrite tag, consistent with howSPhas always rendered.