Skip to content

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.

  1. CV is accessLevel: readwrite, role: command, in both pid-loop.yaml and pid-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/fbruntime gained no notion of ISA-88 mode. It still has none and should not: mode enforcement is a gateway concern and stays one.

  2. FBContext.SelfOverridden(portName) lets a block ask whether its own named output is currently overridden. PID.Execute folds the answer into the exact trk/trkVal variables the device-interlock case already populates from Bus("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.

  1. Release is a marker interface. ManualOverridable (ManualOverridePort() string) is implemented by PID alone today. Runtime.ReleaseManualOverrides() sweeps every loaded block. For each one implementing the interface, it deletes that block's tracked port from outputOverrides. The scope is deliberate. SP is also a readwrite tag bound to a block's output port (sp_const/REAL_CONST). A release that cleared every readwrite blockRef override without distinction would wipe the setpoint back to its compiled-in default every time equipment mode cycled to Automatic. REAL_CONST computes nothing on its own. There is no automatic value for it to hand back to, only the value someone wrote. PID computes 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 as ReadOnlyOutputBlock (ADR 0018) and Bypassable (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.

  2. The gateway triggers release from the existing mode-transition handler. handleSetMode, after committing a ControlModule's mode annotation to Automatic, calls a new runtime endpoint (POST /api/v1/controlprograms/{name}/manual-overrides/release) that reaches NetworkManager.ReleaseManualOverrides and then Runtime.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_CONST computes nothing, PID computes 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/fbruntime a live feed of ControlModule mode, for example wiring a MODE signal 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_VAL input ports on PID, wired explicitly in the template's dataConnections from 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 the TRK/TRK_VAL machinery 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 (makeFBContext wiring, ReleaseManualOverrides), pkg/fbruntime/blocks/pid.go (ManualOverridePort, the self-override branch), internal/adapter/network_manager.go and fbhandler.go (the release endpoint), internal/gateway/mode.go (releaseControlModuleManualOverrides, called from handleSetMode), examples/templates/pid-loop.yaml and pid-cascade.yaml (CV: readwrite, role: command).
  • Docs corrected to match: docs/library/control-modules/pid-loop.md and pid-cascade.md already described this workflow as working. The "Bumpless transfer on Manual/Auto" line in pid-loop.md's "When to escalate" section named this as a known gap and is removed. docs/compliance/isa88.md gains a Table 1 Manual row. docs/compliance/iec61131-3.md, docs/architecture.md and docs/library/alarms-and-interlocks.md gain 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 ManualOverridable implementer. The interface exists for any future block that computes an output and wants the same handback, but only PID needs it today. Also not in scope: any new faceplate affordance distinguishing "Manual, CV never written" from "Manual, CV overridden." The tag renders like any other readwrite tag, consistent with how SP has always rendered.