ADR 0070: A step above the phase names the child it stands for¶
Status: Accepted Date: 2026-08-18 Issue: #1699 Related: ADR 0069 (which ruled that a step above the phase names a child procedural element), #1696 (the webhook this extends), #1697 (what a dormant check costs), #1698 (the recipe validator's reachability fault)
Context¶
OperationTemplate, UnitProcedureTemplate and ProcedureTemplate each carry
a required spec.chart, and nothing checked that the chart was well formed.
Admission read the chart for one field. The gateway read it for none, and no
reconcile path opened it at all. The webhook
#1696 added
refused transitions[].interlock and read nothing else, and
masterrecipe_controller.validateTemplateRefs checks that a named template
exists without opening the chart.
proceduralv1alpha1.ValidateSFCChart was the obvious oracle: it already runs on
PhaseTemplate, and it catches an initialStep that is not among the steps, a
fromStep or toStep that does not exist, an unreachable step, a duplicate
step name and a conditionST that does not parse.
Running it over the corpus first, before deciding anything, is what changed the shape of this decision. It found nothing. All 33 above-phase charts the repository ships pass every check it has.
Three of them cannot be instantiated at all.
What the corpus was actually failing¶
examples/newark-plant/12-procedural-templates.yaml shipped six steps that
named no child procedural element. Five were terminal done steps carrying only
a description, and the sixth was a monitor_current step carrying an inline
actionST. Every one of them is a well-formed SFC step and a malformed
procedural one. Driving
each shipped template through CreateProceduralTree, the real batch
instantiator, gives the verdict:
OperationTemplate/charge-and-dry-mix: phase step "done" has no phaseTemplateRef or templateRef
OperationTemplate/binder-addition: phase step "monitor_current" has no phaseTemplateRef or templateRef
OperationTemplate/wet-mass-discharge: phase step "done" has no phaseTemplateRef or templateRef
That failure arrives at instantiation, which is after an operator has pressed
start. One level up the same mistake is quieter and worse: a
UnitProcedureTemplate step naming no template yields an Operation with a nil
chart, and OperationReconciler.reconcileRunning signals completion for a chart
with no steps. The batch record then carries an operation that reports
Complete having run nothing.
Which checks bind above the phase, and on what¶
The reason ValidateSFCChart reported a clean corpus that cannot run is that
the two halves of an above-phase chart are read by two different audiences, and
only one of them is the runtime.
| Field | The executor | The renderer |
|---|---|---|
steps[].name |
reads it, in declaration order; it becomes the child resource name | draws it |
steps[].templateRef / phaseTemplateRef |
resolves it to the child | draws it |
steps[].subChart |
expands it where the level allows one | draws it |
initialStep |
reads nothing | starts the graph |
transitions |
reads nothing | draws the edges |
divergences |
reads nothing | draws the branches |
internal/controller/batch.BuildSequentialChart rebuilds every instance chart
from the step names alone. A template's initialStep, transitions and
divergences therefore reach no instance, and the three reconcilers walk
chartStepNames() in declaration order and read no transition at all. The SFC
diagram (js/components/sfc-diagram.js) and the change-control diff renderer
read exactly the fields the executor leaves alone.
So ValidateSFCChart's reachability walk, transition endpoints and divergence
branches bind on the drawing. They say nothing about the run. Its Structured
Text checks bind on nothing at all up here, because a step above the phase that
carries actionST is refused for having no template long before any ST is
read.
The level asymmetry is the instantiator's¶
What a step may carry differs by level, and the difference is not cosmetic:
- A procedure-level step names a template or carries a sub-chart, whose
steps are operation-level (
createNestedProceduralTree). - An operation-level step names a template or carries a sub-chart, whose
steps are phase-level (
expandOperationStep). The level skip is deliberate. A sub-chart here holds phases. - A phase-level step names a phase template and nothing else. The loop over
an
OperationTemplatechart never looks atSubChart, so a step carrying only one takes the same refusal as a step carrying nothing.
Decision¶
Every step on a chart above the phase names the child procedural element it
stands for. That is a templateRef (or the deprecated phaseTemplateRef), or
an inline subChart at the two levels that expand one.
1. ValidateAbovePhaseChart is the one oracle. It lives on the API types
beside ValidateSFCChart, runs that in full, and adds the step-target check the
corpus was failing. Both authoring paths call it and neither reimplements it.
2. ValidateSFCChart runs in full, including the half that binds only on the
drawing. The alternative was carving it down to the checks with a runtime
consequence. The corpus passes all of it and the checks are cheap. A chart
whose drawing contradicts itself is a defect to whoever reads it, and one of
those readers is the approver signing a change-control diff. Refusing less would
also mean deciding, for each future check, whether it has earned its place. That
is more judgement than this is worth.
3. Admission refuses on CREATE and on UPDATE. AbovePhaseChartValidator
(the #1696 handler, widened from the interlock field to the whole chart) already
routes all three kinds. 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. This is a field authored on the document itself, and
carrying it would leave a template that cannot instantiate applyable for as long
as it lives.
4. The gateway refuses the same thing, through the same function. This buys
reach, and it is not a second opinion. The author is in the SFC editor, where a
refusal arriving as a 500 from a rejected Create is not something they can act
on. Every engineering stack also runs with webhooks off. On the clusters the
team authors against, the gateway check is the only refusal there is, and
#1697 is the
record of what a check that runs nowhere reachable costs.
This refusal does not trap a stored document. The interlock one did. #1695 removed the interlock checkbox, so a stored flag could not be cleared by hand. The repair had to be built into the save path instead. A step naming no child is fixed in the editor by naming one. Refusing the update therefore refuses the broken document without refusing the repair.
5. Two oracles hold it, and neither is the validator. A check and its
subject that share an implementation agree by construction
(#1454), so
the validator's claim is settled by the executor:
TestShippedProceduralTemplatesInstantiate drives every above-phase template
git tracks through CreateProceduralTree and requires it to build a tree with
no empty children, and TestAbovePhaseValidatorAgreesWithTheInstantiator puts
nine planted charts in front of both and requires the verdicts to line up,
including the silent case where the instantiator raises no error and builds an
empty child.
Alternatives Considered¶
Turn ValidateSFCChart on and call it done. This is what the issue
proposed, and it would have shipped a refusal that admits every defect the
corpus actually had. It is the reason the corpus run comes before the decision.
Refuse only what the executor reads. Carving ValidateSFCChart down to the
step-name and template-reference checks would leave a dangling toStep admitted
on the grounds that nothing runs it. The diagram would still draw an edge to
nothing, and the change-control diff would still show a transition that does not
exist. The drawing is read by a person, and that is a reader.
Check that the named template exists, too. Rejected for admission: a webhook
doing cross-object reads would refuse a template applied before the phase
template it names, which is an ordering a kubectl apply -f of a directory
routinely produces. The corpus test does check it, because there the whole tree
is present at once.
Refuse a divergence above the phase too, the way
#1698 refused
one on a recipe procedure chart. The same argument applies: the field is
dropped at instantiation and read by nothing that runs. It cannot just be
extended here, because the corpus above the phase is not clean and because the
one reader of the flag,
internal/controller/procedural/chart_helpers.go:chartHasParallelDivergence,
means something other than what the branches say. That is
#1703.
Consequences¶
The six malformed steps in examples/newark-plant/12-procedural-templates.yaml
are fixed, and binder-addition is sequential. It was written with a parallel
monitoring branch that never ran one, and that is a second finding this decision
does not resolve. A divergences: entry on a template reaches no instance,
because BuildSequentialChart rebuilds the child chart from the step names
alone. The one reader of the flag is chartHasParallelDivergence, and it returns
a single boolean for the whole chart. reconcileRunningParallel therefore starts
every child of the element and never reads branches. Both halves are
#1703, which
has to settle what a divergence above the phase means before the demonstration
can be restored. Restoring it also needs a second real phase template on the
granulator, and examples/newark-plant/ has none that monitors impeller current.
The wet-granulation ProcedureTemplate in the same file still declares a fork
and a join over its drying and sieve-milling steps. It is left as authored,
because nothing instantiates a ProcedureTemplate and the verdict belongs with
the rest of #1703.
A ProcedureTemplate is still instantiated by nothing. The rule is enforced on
it all the same, because its chart is what a recipe's procedure chart is made
of and the kind exists to be authored, read and approved.
Amendment: a divergence above the phase is refused, and the one place it still runs means the whole chart (#1703)¶
Status: Accepted Date: 2026-08-19 Issue: #1703
This settles the question the Alternatives Considered section above left open. The method is the same one #1699 used: run the refusal over the corpus before deciding anything. A clean-looking verdict from the wrong oracle is worse than none.
The corpus was not clean, and now is¶
examples/newark-plant/12-procedural-templates.yaml's wet-granulation
ProcedureTemplate declared a SimultaneousDiverge/SimultaneousConverge
fork over its drying and sieve-milling steps. Nothing instantiates a
ProcedureTemplate. This was never a runtime defect. The chart is what a
recipe's procedure chart is made of, and the kind exists to be authored, read
and approved (the same reasoning the ADR body already applies to the
step-target check). It is migrated to a straight sequential chart:
granulate, then dry, then sieve-mill, then final-blend. A comment
records why, matching binder-addition's fix from the original decision.
The one reader that is not the instantiator¶
internal/controller/procedural/chart_helpers.go:chartHasParallelDivergence
is reachable only on a hand-authored Operation, UnitProcedure or
Procedure. Nothing a batch creates can carry a divergence, because
BuildSequentialChart drops it during expansion. That is the same fact that
closes the template case. Where this one reader is reached, it means
something other than the branches say. It returns one boolean for the whole
chart. reconcileRunningParallel starts every step in chart.Steps and never
reads branches.
examples/newark-plant/09-procedural.yaml's add-binder-and-granulate
Operation is the one shipped document that reaches it, and it was wrong on
both ends of that gap.
- The meaning gap. Its chart fenced the two real phases
(
set-granulation-speed,spray-binder) with no-opparallel-start/parallel-endsteps meant to bound where the parallel section begins and ends.reconcileRunningParallelstarts every step in the chart together regardless. The fence posts fenced nothing: all four steps would start at once. - A second, independent defect the meaning gap was standing in front of.
parallel-startandparallel-endname noPhaseresource anywhere in the document.reconcileRunningParallelcallsr.Getfor every step name in the chart and requeues onNotFound. It does not treat a missing child as ready, so this Operation could never leaveRunning: it would wait forever for aPhasenamedparallel-startthat is never created. Reading the chart's topology alone shows neither half. Both surfaced from asking what actually readschart.Stepsat this level.
Decision¶
A divergence above the phase is refused at admission, on the three
template kinds, the same way #1698 refused one on a recipe procedure chart.
proceduralv1alpha1.ValidateAbovePhaseChart gains the check. A chart above
the phase cannot declare divergences, and the check recurses into every
subChart the same way the step-target check does. AbovePhaseChartValidator
and the three gateway save paths refuse it through the one function that
already carries the step-target refusal. This was not safe to add until the
corpus was clean. The original decision left it for this issue for exactly
that reason.
Real branch-aware concurrency is not built above the phase. One
alternative would make reconcileRunningParallel select the branches a
SimultaneousDiverge names, converge on SimultaneousConverge, and carry a
template's divergence into the chart the instantiator builds. That is real
topology-aware execution, the kind pkg/sfc.Engine already implements for a
single phase's action chart. Building a second implementation of it against
child Kubernetes resources across three controllers (Operation,
UnitProcedure, Procedure) is a substantially larger change to live batch
execution semantics than a documentation-honesty issue should carry as a side
effect. It would also duplicate a capability that already exists one level
down. A process step that needs real concurrent branches belongs inside a
single phase's action chart. SimultaneousDiverge already means what it says
there.
Where the flag is still read, its doc comment now says precisely what it
does. chartHasParallelDivergence and reconcileRunningParallel are
unchanged. Removing them would be a live behaviour change to a mechanism
with no admission gate in front of it: no ValidatingWebhookConfiguration
routes Operation, UnitProcedure or Procedure at all. That is a larger
and riskier move than this issue's finding calls for. Their doc comments now
say plainly that branches sits unread and that every step in the chart
starts together. The next reader will not mistake the boolean for a topology
check.
examples/newark-plant/09-procedural.yaml is migrated to match. The fence
posts are removed. The divergence now names exactly the two steps that make
up the whole chart. branches: lists everything reconcileRunningParallel
was already going to start together, so it stops being decorative. This also
fixes the independent NotFound defect, because no step names a Phase that
does not exist any more.
Alternatives Considered¶
Keep the chart-level reading and only fix the broken example. This was
rejected for the template kinds. It would leave ValidateAbovePhaseChart
silent about a field that is dropped outright, the exact gap #1698 already
closed one document over. For the hand-authored case, keeping the mechanism
exactly as it is is what this decision does. What it rejects is leaving the
mismatch undocumented.
Make reconcileRunningParallel branch-aware. Considered and set aside.
The reason is scope. It is a different, larger piece of work than this
issue's finding, laid out under Decision above. A future issue that wants
real ISA-88 parallel branching above the phase should scope it as its own
design effort. It should reuse or re-platform on pkg/sfc.Engine and avoid
hand-rolling a second topology walker three times over.
Consequences¶
docs/compliance/isa88.md's Parallel execution row now states the split
plainly. It is implemented at the phase level. It is refused above it on
every kind the instantiator can reach. One kind is never instantiated: a
hand-authored Operation, UnitProcedure or Procedure. There it is a
whole-chart flag. A future session that wants real parallel branches above
the phase has this ADR's Alternatives Considered as its starting point.