Skip to content

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 OperationTemplate chart never looks at SubChart, 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.