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().