Skip to content

ADR 0054: A command an interlock refused is not a device fault

Status: Accepted Date: 2026-08-12 Issue: #1482 Related: ADR 0007 (device interlocks, reaffirmed by this ADR), ADR 0008 (holding charts drive the safe state), ADR 0010 (gated bypass)

Context

Commanding the fermenter's harvest valve open while the media inlet valve was open tripped the device interlock the example declares. The refusal was correct and it was visible on the faceplate. Four seconds later the valve was open anyway, and nobody had re-authorised it.

The chain was entirely in-product, and every link in it was behaving as written:

  1. The device interlock refused the open and forced the solenoid to its safe value.
  2. The solenoid-valve template raised MISMATCH, because it computed the mismatch as CMD AND NOT fb_open — the requested position against the feedback.
  3. MISMATCH carried exceptionAction: Hold, so the batch went to Holding.
  4. The holding chart drove the unit's safe state, which closed the media valve.
  5. The media valve's open limit switch was the interlock's trip signal, so the interlock cleared.
  6. harvest-valve.CMD was still true from step 1, so the valve opened.

The loop is what makes this worth a decision rather than a bug fix: the refusal caused the state change that granted the request. A protection whose own response removes its trip condition is a recognised hazard shape, and a customer will meet it in their own interlock set long before they meet it in ours.

The issue offered three resolutions. Two of them are wrong for reasons worth recording, and the third is not sufficient on its own.

Making the runtime drop a refused command, product-wide, reverses ADR 0007's recorded decision that the device interlock is non-latching. It also trades a visible hazard for an invisible one. A momentary or chattering trip would permanently drop a running output — a pump, an agitator, a cooling loop — with no annunciation and no reset path, because there is no latch to notice and nothing to acknowledge. Loss of function is not the safe direction here, and the failure would be discovered by its consequences rather than by an operator who cannot do their job.

Latching the example's command off on a trip leaves CMD reading Open while the valve reads Closed, so the mismatch stays true for as long as the latch holds and the Hold fires anyway. It does not break the loop unless the mismatch computation is also changed, which is this ADR.

Documenting it as correct-as-designed is right about the runtime and insufficient about the templates, because step 2 is not correct as designed. While the interlock is forcing the safe value, the valve is doing exactly what the DO block drove it to. Reporting that as a command/feedback mismatch classifies a working device as a faulty one — and only that misclassification gave the refusal the authority to Hold a batch.

Decision

A refused command and a failed device are two different conditions, and they are annunciated separately. The distinction is drawn in the control module's own logic, where both conditions are already visible.

  1. The runtime is unchanged. ADR 0007 stands in full: the device interlock is non-latching, it forces safeValue for exactly as long as the trip condition holds, and a maintained command resumes when the trip clears. Latching remains composed upstream with the SR block. No block, no port, and no parameter changes.

  2. MISMATCH is measured against the effective command, never the requested one. The library valve templates (solenoid-valve, discrete-valve) source their mismatch computation from the output block's OUT port — the last value actually written to the device — instead of from the CMD constant. OUT already accounts for the interlock trip and for an ADR 0010 bypass, so no template needs to know how either works.

This narrows MISMATCH to what its name claims and nothing else: the device did not go where it was driven. A valve that fails to reach the forced safe position still raises it, interlocked or not, so gating the fault on the interlock does not blind the fault detection.

  1. A defeated command annunciates on CMD_BLOCKED. Both valve templates publish a new read-only tag, true while the interlock is forcing the safe value and the standing command disagrees with what was driven:
CMD_BLOCKED = ILCK_ACTIVE AND (CMD != driven)

It declares role: interlock — a refusal is an interlock status, not an alarm flag — so it renders beside ILCK on the Device interlocks panel. It is deliberately narrower than ILCK, which is true through every scheduled arming of the protection and is therefore not alarmed at all (annunciating a protection doing its job is nuisance alarming, ISA-18.2 § 6). CMD_BLOCKED is true only when something is actually being refused.

  1. An AlarmDefinition on a refusal carries no exceptionAction. This is the link that closes the loop, and it is the one this ADR forbids. An exception action invokes the holding chart, the holding chart drives the unit's safe state (ADR 0008), and a safe state is free to manipulate exactly the process conditions an interlock reads. A refusal is annunciated so a human decides what to do about it; an automatic response decides for them, and in the #1482 case it decided in the one direction that grants the refused request.

A device fault still holds the batch. That has not changed and should not: a stuck valve is a condition the batch cannot proceed through, and nothing about the holding response makes a stuck valve un-stick.

What this leaves with the interlock designer. A refusal now stands until someone clears the trip condition or withdraws the command, and clearing the trip still releases the maintained command to the device — that is ADR 0007 and it is ordinary DCS behaviour. What the product guarantees is that nothing in the product will clear the trip on the operator's behalf. Whether a particular interlock's trip condition can be disturbed by some other automatic response remains a hazard-analysis question for whoever writes the interlock, and it is written up in the interlock guide.

Alternatives Considered

  • Suppress MISMATCH entirely while ILCK_ACTIVE. Simpler to wire — one AND term — but it blinds the fault detection in the state where it matters most. While the interlock is forcing a valve closed, a valve that is not closed is a genuine and dangerous fault, and this alternative would report nothing. Measuring against the effective command keeps that case and drops only the false one.
  • SEL(ILCK_ACTIVE, safeValue, CMD) as the effective command. Equivalent in behaviour, but it plumbs the safeValue parameter into a second place in every template and re-derives what the output block already computed. The OUT port is the authoritative answer to "what did we drive", it is already published, and it stays correct if the interlock gains further trip sources.
  • Refuse the CMD write at the gateway while the interlock is tripped. Attractive — the operator gets an immediate, reasoned rejection — but it puts an edge condition behind a control-plane check that races it, cannot cover a phase writing the tag during a partition, and does nothing about a trip that arrives after the write. It would be an ergonomic addition on top of this decision, never a substitute for it.
  • A lint gate over examples/ for the loop shape ("no interlock whose trip address is written by its own unit's holding chart"). Deferred, not rejected. The shape is real and the house has gates for narrower things, but the trip address resolves through the binding chain and the holding chart writes CM tags rather than addresses, so a sound gate is a meaningful piece of work. Filed as a follow-up; this ADR plus the docs are what ship now.

Consequences

  • Templates: examples/templates/solenoid-valve.yaml and examples/templates/discrete-valve.yaml re-source their mismatch computation and gain the CMD_BLOCKED tag with three blocks (cmd_eq, cmd_differs, cmd_blocked). Both already declared their output block before the mismatch blocks, which is what makes the new source resolve within the same scan — a block's input bus reads its source's live outputs at execution time, so declared order is load-bearing here.
  • Examples: the fermentation plant gains harvest-valve-command-blocked and riverbend gains mt1-outlet-valve-command-blocked, both without an exceptionAction. The two mismatch rules keep their Hold and lose the interlock clause from their messages. Riverbend carried the identical loop — its holding chart closes the inlet valve that its outlet interlock reads — and needed the same pair.
  • A CM with an interlock but no refusal rule now annunciates nothing when it refuses a command. That is the one regression in this change: before, the refusal arrived (misclassified) as a mismatch. Any deployment that interlocks an output and relies on the mismatch alarm to see a refusal should add a CMD_BLOCKED rule, which is what both shipped examples now model.
  • No CRD change, no migration, no runtime change. CMD_BLOCKED is a template tag; existing compiled programs are unaffected until their ControlModule is re-reconciled against the updated template.
  • These templates now read an output block's port, which the ST view cannot round-trip (#1484). NetworkToST renders such a connection as a bare write_sol.OUT token and STToNetwork drops it with no diagnostic, so a save through the gateway's FBD/ST toggle silently deletes the edge. This is pre-existing and independent of this ADRpid-loop and pid-cascade already wire write_out.ILCK_ACTIVE / write_out.OUT into the PID for bumpless transfer and lose them the same way — but this ADR takes the count from two shipped templates to four, so it is recorded here rather than left to be discovered. Sourcing from the output block is not avoidable: it is the only thing that knows what it drove, which is the entire content of this decision. Re-deriving the trip upstream would move the interlock out of the output block, which ADR 0007 rejects, and would miss an ADR 0010 bypass.
  • Tests: pkg/templatecompiler/interlock_refusal_test.go compiles the shipped templates and scans them against a simulation driver, asserting that a refusal raises CMD_BLOCKED and not MISMATCH, that a device that misses its forced safe position still raises MISMATCH, and that an uninterlocked valve behaves exactly as before.
  • Docs: the three-layer protection table in docs/library/alarms-and-interlocks.md gains the refusal-versus-fault split and the self-clearing-loop hazard.