Skip to content

ADR 0069: An interlock guard is authored where equipment is driven

Status: Accepted Date: 2026-08-18 Issue: #1695 Related: ADR 0007 (the three-layer protection model), ADR 0008 (which names the middle layer the phase SFC guard), #1385 (which shipped the guard), #1693 (the DTO half), #1696 (the refusal this decision does not install)

Context

#1385 added transitions[].interlock to SFCTransition, along with the two admission invariants that make the flag mean something, the documented Pattern 3, and a checkbox in the SFC editor's transition inspector. The checkbox was never gated on the chart's level, so it rendered on all four levels the editor draws: phase, operation, unit procedure, and recipe procedure.

SFCChart is one Go type shared by six documents. Six per-state charts sit on PhaseTemplate and Phase, one sits on each of OperationTemplate, UnitProcedureTemplate and ProcedureTemplate, and one is the Unit's armed safe-state chart. Sharing the type is what made the affordance look level-agnostic, and three facts say it is not.

RecipeSFCTransition carries four fields and none of them is this one. The recipe procedure save path projected the working chart field by field and named interlock among them, so a ticked box reached a decoder running DisallowUnknownFields. The whole procedure was refused with unknown field "interlock" before any validation ran.

The three procedural template kinds do store it. Since #1693 the gateway DTO carries the field in both directions, so a tick on an operation chart saved and persisted.

Nothing above the phase reads it. ValidateSFCChart enforces the two invariants and runs in the PhaseTemplate webhook alone. pkg/sfc is the only code that orders transitions by priority, and the phase controller is its only caller. The Operation, UnitProcedure and Procedure reconcilers walk chartStepNames(chart) in declaration order and read no transition at all.

So the question the code left open is whether a guard means anything above a phase chart, and ISA-88 answers it. Part 1 Clause 5.2.1 places interlocking inside basic control, which it defines as a subset of equipment control. Clause 5.2.2.3 says basic control in a unit is performed by the equipment modules and control modules within that unit. Clause 6.6.3 draws the logical separation between recipes and equipment, and Figure 16 shows a recipe phase holding a reference to an equipment phase. The equipment phase is where the logic sits. A recipe procedural element is product dependent, and the equipment entity performs the function it describes through equipment control.

The product's own protection model already says the same thing in two places. ADR 0007 and ADR 0008 both label the middle layer the phase SFC guard, and docs/library/alarms-and-interlocks.md scopes Pattern 3 to a transition in the phase template's chart.

Decision

An interlock guard is authored on a chart that drives equipment. In this product that means a phase chart and the Unit's armed safe-state chart, both of which the SFC engine executes against a unit's own control-module tag space. A step above the phase names a child procedural element, and it drives nothing itself.

Two consequences follow for the SFC editor, which is the only surface that offers the control. The checkbox and its read-only companion row render when the chart's level is phase. The recipe procedure projection sends no interlock field, whatever the working chart holds.

The gating matches what onControlGap already does one field over (#1685, #1692, ADR 0067), and for a related reason. That verdict is a claim about a step's own dwell, and this one is a claim about a step's own outputs.

Alternatives Considered

  • Add Interlock to RecipeSFCTransition so the recipe path matches the phase path. This is the move the shape of the code suggests, and it is the one the issue was filed to rule on. Rejected because a recipe procedure step names a unit procedure, so a guard firing there would abandon a running procedural element and start a different one. That is a batch-level exception response. The product already models it as AlarmDefinition.exceptionAction, which is layer three of the same table that puts the phase guard at layer two.

  • Keep the checkbox everywhere and refuse the field server-side above the phase. Rejected on placement. The gateway is one of two authoring paths and these three kinds have no admission webhook, so kubectl apply would keep reaching a field the refusal claimed to have closed. A refusal whose absence means two different things is worse than none (#1648). The webhook that would close it properly is #1696.

  • Gate the checkbox and keep the read-only row above the phase, so a YAML-authored flag stays readable in the panel. Rejected because it leaves a field the panel reports and cannot clear. The flag already has a reader at those levels: the change-control diff renders it for every kind that routes through diffSFCTransitions.

  • Leave all four levels alone, on the grounds that conditionST and priority are equally inert above the phase. True and beside the point. Those two are ordinary sequencing whose execution is deferred to #84, so they describe an intent the product intends to honour. An interlock guard is a safety claim, and a safety claim that reads as armed while arming nothing fails towards all-clear.

Consequences

Three editor surfaces lose a control. An author who wants a guard on a procedure writes it into the phase template that the procedure's step names, which is where the sequence of safe outputs can be written down.

The recipe procedure save stops sending a field its target cannot hold. That save was refused outright before, so the ticked box cost the author the whole document.

interlock: true above the phase was still reachable through kubectl apply when this was written, where it stored and did nothing. #1696 has since closed that road. See the amendment below.

The Unit's armed safe-state chart keeps the field and is unaffected. It is authored in YAML and has no editor. make lint-edge-armable already holds it to the unit's own control-module tag space, which is the property this decision turns on.

Amendment: the refusal, and what it is refused on (#1696)

The second authoring path is closed. AbovePhaseChartValidator (internal/webhook/procedural/) refuses transitions[].interlock on OperationTemplate, UnitProcedureTemplate and ProcedureTemplate, including a guard hiding in the sub-chart of a step. The shipped corpus was clean, so nothing had to migrate: the only interlock: true in examples/ sits on a phase template.

It refuses an update as well as a create. ADR 0064's carve-out carries an already-broken document through an update, and it does not apply here. That one is about a reference inherited from elsewhere, and this is a field authored on the document itself. Carrying it would leave the change-control diff rendering Transition X -> Y: interlock guard at structural impact for as long as the document lives, which is the whole complaint: an approver signing a safety claim the engine consults never.

Refusing an update would ordinarily trap a stored flag, because the editor cannot clear a checkbox it no longer draws. So the trap is closed at the other end instead. The three above-phase save paths run the chart through dtoToAbovePhaseSFCChart, which clears the field before the write. An ordinary save from the editor is therefore the repair, which is the same move this decision already made for the recipe procedure projection. The clear is deliberately not folded into dtoToSFCChart. The phase template save path calls that one too, the phase is exactly where the flag belongs, and #1693 is the record of what erasing it there costs.

Routing a webhook over a kind is a behaviour change, and it reads like a wiring change. It puts the whole shipped corpus in front of a refusal nothing has ever run over it. TestShippedAbovePhaseTemplatesPassAdmission drives Handle() itself over every one of these three kinds that git tracks, on create and on update, and fails per kind when it finds none. It admits all 33, so nothing had to migrate.

The webhook reads this one field and nothing else. These three kinds have no structural chart validation at all. That is a separate gap with a separate risk, scoped as #1699.

A YAML author is told which transition to delete. The denial names the field path, the fromStep -> toStep pair and this ADR, because an index alone is not something an author reading YAML can act on.

The refusal needed a route before it meant anything (#1697). The PhaseTemplate validator that enforces the two guard invariants had been registered since #349 and routed by no ValidatingWebhookConfiguration, so it had never been called by an apiserver. Meanwhile docs/library/alarms-and-interlocks.md published the claim that it refuses a malformed guard at admission. pkg/sfc orders a step's transitions with a non-stable sort, so a guard left at the default priority: 0 beat the flow transition it was meant to pre-empt or lost to it by chance. Both that rule and the three new ones ship in the chart now, and deploy/helm/cloud-native-dcs/tests/webhook-routing.sh holds every registered path against a rendered rule, in both directions.