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.