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.

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-op parallel-start/parallel-end steps meant to bound where the parallel section begins and ends. reconcileRunningParallel starts 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-start and parallel-end name no Phase resource anywhere in the document. reconcileRunningParallel calls r.Get for every step name in the chart and requeues on NotFound. It does not treat a missing child as ready, so this Operation could never leave Running: it would wait forever for a Phase named parallel-start that is never created. Reading the chart's topology alone shows neither half. Both surfaced from asking what actually reads chart.Steps at 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.