Skip to content

ADR 0047: The action chart's position belongs to the action chart alone, a transitional chart may read it through STEP_ACTIVE() but never resumes from it, and a resumed step still never re-runs its action

Status: Accepted Date: 2026-08-10 Issue: #1389, #1382

Context

A Phase carries six SFC charts. One of them, the action chart, is the sequence. The other five — holding, restarting, stopping, aborting, resetting — are the ISA-88 transitional programs that run when the phase leaves or re-enters Running. Phase.status.sfcStatus records a position: which steps are active, which have completed, which transitions have fired, and per step whether its action has finished.

Two questions had never been separated, and both were answered wrong.

Whose position is it? executeSFCChart applied sfc.WithRestoredState to every chart it ran, from the one status.sfcStatus, and then published every chart's status back over it. A transitional chart is a different program with different step names, so this was not a smaller version of the same thing. Restoring happens instead of activating a chart's initial step, and restoreState skips restored names the chart does not define — so a HoldingChart handed the action chart's close_media activated nothing, and its first scan found zero active steps, which the engine reports as a clean completion. The safe-state writes an operator Hold exists to perform never ran, on any hold taken from a running chart, which is every real hold. The chart then published its own single execute step over status.sfcStatus and erased the position, so the Restart that followed found nothing to restore and the action chart re-entered at its initial step. A held lot came back having silently replayed the phase from the beginning, with the hold posture never commanded. Nothing errored and nothing was recorded.

What may a transitional chart know? With the position preserved, a Restart genuinely resumes — and the second defect, the one both issues were filed against, becomes reachable. A restarting chart is one program for a phase with several steps, and it re-commands exactly one posture. charge-media re-opened the media inlet unconditionally, which is right for a hold taken during the charge and wrong for one taken during the settle: the resumed close_media does not re-run the WRITE that would shut the valve, so the guard waited on a limit switch nothing would ever satisfy and the phase died on a bare step-budget overrun. fermenter-cip-rinse carried the same trap with a comment that claimed the resumed step re-asserts its own commands. It does not, and never did. An audit of the fixture set found 52 such conflicts across eight files, so this is the shape of the mistake rather than two authoring slips.

Three answers were on the table.

A resumed step could re-run its action, which would make every restarting chart nearly empty. The engine deliberately does not, and that refusal is load-bearing: a step action may PROMPT an operator, emit a MESSAGE into the batch record, or CALL_SERVICE against a skid. Replay turns a resume into a second dose, a second signature request, and a duplicated record. The one case it fixes — re-asserting a commanded output — is the one case that is also safe to do from somewhere else.

Per-step restart logic (steps[].restartingST) would co-locate the knowledge with the step that needs it. It also splits restart into two mechanisms, and a reviewer approving a recipe under Part 11 would have to assemble the restart procedure from N scattered fragments instead of reading one program.

The restarting chart could see the position. This is what a real restart routine does: branch on where the sequence was interrupted. It keeps restart as one auditable program, needs no schema change, and does not touch the side-effect rule.

Decision

status.sfcStatus is the action chart's position, and only the action chart's. A transitional chart neither restores from it nor publishes over it. chartKind.ownsSFCStatus() is the single predicate; a transitional chart always enters at its own initialStep and always runs.

A transitional chart may read that position through STEP_ACTIVE(). STEP_ACTIVE('<step>') returns BOOL and names a step of the action chart, never of the chart it is written in. It is available in the holding, restarting, stopping, aborting, and resetting charts, and refused in the action chart — there the question is about the chart asking it, and answering FALSE would read as a definite "not on that step" for a step that may well be running. A phase that never got a position answers FALSE for every name, because there genuinely is no active step. Admission rejects a literal name that is not a step of the action chart: a wrong name never fails at runtime, it is simply FALSE, so a restarting chart that misspells its hold step re-establishes nothing on every resume for the life of the recipe.

A resumed step still never re-runs its action. A restored step whose action had completed stays done. Re-establishing the equipment posture a resumed step depends on is the restarting chart's job, and it now has the information to do it.

Alternatives Considered

Re-run the resumed step's action on Restart. Rejected: replays prompts, messages, service calls, and doses. A per-step reassertOnResume opt-in would confine the damage but leaves authors choosing per step whether a resume is safe, which is the judgement the platform should be making once.

Per-step restartingST. Rejected: two restart mechanisms for one job, and a restart procedure a Part 11 reviewer has to reassemble.

Declare the pattern unsupported — a phase whose steps command conflicting postures may not carry a single restarting chart. Rejected: that describes nearly every real phase. charge-media has two steps.

Expose the held step as a reserved ST variable rather than a builtin. Rejected: it collides with the recipe's own parameter namespace, cannot express a parallel divergence with several active steps, and gives admission nothing to validate.

Consequences

An operator Hold performs its safe-state action, and a Restart resumes at the step the phase was held on. Both were claimed by the documentation and by ADR 0008's control-plane half; neither happened.

A transitional chart's own progress is no longer visible in status.sfcStatus. Nothing loses anything: the gateway renders status.sfcStatus against the action chart, so a holding chart's step names highlighted nothing there even while they were overwriting the position the diagram needed.

Restarting charts stop being safe to write blind. A phase whose steps command conflicting postures of the same equipment needs a STEP_ACTIVE() branch, and one written without it is wrong for every step but the one its author had in mind. The fermentation fixtures (charge-media, ferment-hold, harvest-broth, fermenter-cip-rinse, and the 09-procedural.yaml mirrors) are corrected here. The remaining 43 conflicts, across the distillery, flavors, newark-plant and riverbend fixtures, were swept in #1400 together with make lint-restart-posture, which finds them mechanically — the gate and the sweep landed together so make verify never went red on a backlog.

STEP_ACTIVE() is the first ST builtin whose answer comes from the phase's execution history rather than from the plant or the operator. That is a deliberate boundary and it stays narrow: it reports position, not values. A transitional chart that wants a process value still reads it with READ().