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.