Skip to content

ADR 0073: A batch does not command equipment an operator holds

Status: Accepted Date: 2026-08-21 Issue: #1716 Related: #1255 (the gateway half of this barrier), ADR 0006 (why the runtime cannot answer this question), ADR 0008 (the safe-state path that stays mode-blind), ADR 0047 (why the restarting chart is barred alongside the action chart)

Context

Found at the bench on 2026-08-20 while prepping the #942 HA drills. loop-pid had been left in Manual mode from earlier work. The hadrill-* batch's hold-at-setpoint phase commanded it anyway. SP and CV moved to the phase's values, nothing switched the module to Automatic, and no refusal appeared anywhere in the chain.

ISA-88 Part 1 Table 1 draws the line this crossed. In Manual the operator manipulates the output. In Automatic the control algorithm does. A phase that drives a module held in Manual erases that distinction with no evidence that it did. There is no error on the phase, no annotation on the module, and the faceplate still paints Manual while a batch writes through it. From the operator's seat that looks exactly like the affordance with no state check behind it that ADR 0060 was written about.

The barrier existed and could not be reached. equipmentModeBarsWrite (internal/gateway/tags.go) refuses an operator write to equipment that is not in Manual, on every gateway route that writes a tag. A phase's WRITE(...) never traverses the gateway. stbridge.RuntimeBridge.Write POSTs straight to the unit runtime's own /api/v1/write, which lands on Adapter.WriteTag.

Adapter.WriteTag's doc comment calls it "the one chokepoint every write reaches", and for access level and declared engineering range that is true. For mode it was false, and #1255's closing comment says why nobody had noticed:

The SFC engine and FB scan write in-process via adapter.WriteTag, so Automatic control is untouched.

That is right about the routing and wrong about the conclusion. It reads "Automatic control" as a description of what a phase does. Automatic is a mode the equipment is actually in, and a phase can run against equipment that is not in it. Nothing enforced the assumption, and the bench run was the counterexample.

Decision

A phase's WRITE is refused when the ControlModule it addresses is in ISA-88 Manual mode. The refusal is applied where the writer's identity still exists, and it covers the two chart kinds that command production.

Where the barrier goes

Two facts keep it off Adapter.WriteTag.

The runtime pod holds no apiserver client at all. That is the ADR 0006 edge-autonomy design and not an oversight. The runtime has to keep controlling through a control-plane partition, so it works from a pushed tag-map snapshot and asks nobody anything. Equipment mode is live operator state. Carrying it in the tag map would turn a safety barrier into a polled snapshot on the far side of the partition the runtime exists to survive.

The second fact is decisive. Mode does not produce a verdict about the write. It produces one about the writer. The gateway refuses an operator write unless the module is Manual. A phase write has to be refused when it is. Both spellings arrive on the same runtime route and are indistinguishable there. A check at Adapter.WriteTag would therefore enforce one of those two directions against both callers. The question can only be answered where the caller is still known.

The phase controller is that place, and it already is that place for everything else ST needs a client for. MODE, CALL_SERVICE, MTP_COMMAND and MTP_STATE are all controller-supplied callbacks on the bridge, each carrying the same note about the client the runtime pod does not have. The barrier is one more of them. stbridge.WriteGuardFunc is consulted before a write is marshalled, so a refused write reaches no runtime at all.

Which charts are barred

Chart Barred Why
Action yes Commands production. This is the bench case.
Restarting yes Re-establishes the production posture the hold interrupted (ADR 0047). Hold, intervene, restart is the likeliest way a module comes to be in Manual under a live batch.
Holding no Exists to reach the sequenced held state.
Stopping no Exists to reach a controlled stop.
Aborting no Exists to reach safe state on fault.
Resetting no Parks equipment on the way back to Idle.

The exemption is the better of two hazards. A barred aborting chart fails silently to reach safe state. handleTransitionalChartError turns a barred holding, stopping or resetting chart into an aborted lot. Refusing a safe-state write also trades a dual-writer hazard for equipment left wherever a failed phase abandoned it, which is worse in every case we could construct.

There is precedent for the line. The strongest safe-state path in the product is already mode-blind by design. The ADR 0008 hold armed at the edge runs inside the runtime with no apiserver to ask, on the partition where nothing could be checked anyway. Keeping the control-plane copies of those charts consistent with the edge copy is the same decision carried through.

What a refused write costs

Failure semantics fall out of the two paths that already exist.

An action-chart refusal is an execution error, so selfHoldOnError carries it. The phase goes Running to Holding to Held, Status.Message names the module and the reason, an execution alarm is raised, and an AuditRecord is written on the transition. Held is a resting state with a defined way out. The operator who put the module in Manual is the one who can act on the message.

A restarting-chart refusal takes an explicit branch away from the transitional default, which aborts. Restarting to Hold to Held is a legal transition. The holding chart that runs on the way is exempt from the barrier, so equipment still reaches its sequenced held state. A switch left flipped must not destroy a lot.

How mode is read

A mode change is requested by stamping dcs.io/mode. The physical operator applies it, writing Status.Mode and stripping the annotation on its next reconcile. effectiveEquipmentMode resolves the pending annotation first, then Status.Mode, then Spec.Mode, then Automatic.

That differs from the gateway's effectiveMode, deliberately. The gateway answers "what does the faceplate show", so it reports the applied mode. This answers "may this write land". A pending request is the freshest statement of who owns the output, and it is the fail-safe reading in both directions. A chart's own MODE('valve', 'Automatic') takes effect for the WRITE on its heels without racing a reconcile. An operator's pending switch to Manual bars the batch a reconcile sooner than Status.Mode would.

Unset reads as Automatic, matching the CRD default. For a phase that is the permissive answer and the correct one. Automatic means the control algorithm owns the output, which is what a running batch is. No shipped chart changes behaviour.

Alternatives Considered

Carry equipment mode in the pushed tag map and enforce it at Adapter.WriteTag. This keeps the barrier at the place that already holds access level and range, which is where the doc comment says it belongs. It fails on both of the facts above. The runtime would be judging a live operator decision from a snapshot pushed by a controller it may be partitioned from. It also still could not tell an operator write from a phase write on the one route they share. It would therefore enforce one of the two opposite verdicts against both callers. Adding a writer-identity field to the write request would answer the second half and leave the first standing.

Refuse at the start of the phase, before any write. Checking every bound ControlModule's mode before the action chart runs is cheaper and fails earlier. It is also coarser in both directions. It bars a phase over a module it never commands. It misses a switch flipped while the chart is running, which is the case an operator is most likely to create. The per-write refusal names the tag that was actually refused, which is what the chart's author needs.

Bar the transitional charts too. Uniformity is attractive here and wrong. handleTransitionalChartError aborts on a failed holding, stopping or resetting chart. Barring those converts an operator's deliberate intervention into a destroyed lot. A barred aborting chart is worse still: the abort completes anyway and the equipment never reaches safe state, silently. The restarting chart is the one transitional case where the hazard outweighs that, and it gets an explicit branch back to Held so it does not pay the abort either.

Refuse the Restart command up front. This would keep the phase in Held without a round trip through the restarting chart. It needs the command path to enumerate the modules a chart might command. That is a second and weaker answer to a question the guard already answers exactly. The round trip costs one reconcile and ends in the same Held state.

Consequences

The MODE then WRITE pattern the shipped CIP templates already use becomes load-bearing. examples/riverbend/15-cip-phase-templates.yaml asserts MODE('inlet_valve', 'Automatic') in its own step before the step that opens the valve. That is the authoring requirement now, and the refusal message names the MODE call the chart is missing.

structuredtext.STError gained an Unwrap(). The interpreter used to rebuild every execution-context error from err.Error() alone, so a typed refusal from the bridge arrived upstairs as prose and a caller wanting to branch on one was left matching on message text. The position stamp is what the wrap is for. The cause now survives it.

Three things this deliberately leaves alone. It does not consult the Unit's own mode, because the gateway barrier is ControlModule-scoped too and widening both of them is its own decision. It does not bar an FB scan's own writes, which are the control algorithm Automatic mode refers to. It does not refuse the Restart command up front. The refusal happens when the restarting chart writes, which costs one round trip to the same Held state and keeps the verdict on the tag the chart actually commands.