The NodeType bake gate (PreWarm:GateReadiness, the nodetype_bake check, read by /ready)
refuses readiness when a type that used to build no longer builds on this image. Its message states the
contract: "refusing readiness so the rollout stalls with the previous image still serving". The
gate is only safe while that clause is true. On 2026-09-25/26 it was false, and the gate took the
whole control instance down (#5544).
What happened
After the V58 migration on memex.systemorph.com:
- the new
ci.9332pod refused readiness with1 NodeType(s) regressed on this image: BinaryClickerV2/BinaryToggle; - the last pod of the previous image (
ci.9218) then restarted and refused ITS OWN readiness with2 regressed: BinaryClickerV2/BinaryToggle, Store/Review; - nothing was serving, and nginx answered 503 until the gate was switched off by hand.
BinaryClickerV2/BinaryToggle is a demo type whose layout source calls a
LayoutAreaHost.GetData<T>() that does not exist (CS1929, #3883).
It has failed on every image since it was written. It is not a regression of any image.
Why a type that never built read as "regressed"
Two defects, and each one hid the other.
- "Not known to be broken" was read as "working".
NodeTypeBakeEntry.WasHealthyis true for every state exceptPreviouslyBroken, includingNeverBuilt. That is correct for what it says, because a type nobody has built is not damaged goods. But the regression baseline was built from it. A never-built type has nothing to regress from, so it was filed as a regression anyway. The first-bake rule (#4472) covered only the case where every type in the report isNeverBuilt. One never-built type on an established instance was still treated as a regression. - The record could never learn the type was broken. A failed compile writes an
Errorstamp, and that stamp is aMeshPublicationGatepublication. The gate holds it while the sweep runs and discards it when the pod is refused. So the failure refused the pod, the refused pod could not record the failure, and the next pod read the record asOkagain. That loop is why the record saidOkwhile the compile failed on every boot (#3883), and why #1391 was closed on aNot foundand then came back.
The two rules
The per-type question and the report-level question are both answered in one stamp,
DynamicTypePreWarmer.BaselineStamp. The sweep and BuildProtocolDriver.OutcomesOf both use it, so
the GO and the gate cannot disagree
(#4496). The answer is carried to the gate
as PreWarmOutcome.HasRegressionBaseline. WasHealthyBeforeBake keeps its meaning.
Rule 1: a regression needs a working build to regress FROM, built by another, older image.
NodeTypeBakeEntry.IsRegressionBaselineFor(livePlatformVersion) is true only when:
- the record names a working build (
HadWorkingBuild: notNeverBuilt, notPreviouslyBroken), and - that build was produced by a DIFFERENT platform build that is NOT newer
(
ProducedByPlatformBuild, read fromNodeTypeDefinition.CompiledPlatformVersion).
If this same build produced the working build, the image can build the type, so a failure now comes from content or the environment. If a NEWER build produced it, this process is the OLD image of a roll, and refusing it only removes the replicas the rollout falls back on. A record with no producer stamp keeps the strict reading.
Rule 2: an image that has already served this mesh never refuses itself.
NodeTypeBakeReport.ThisBuildHasServed is true when either of two witnesses says so. Both are
admission-gated publications, so a refused pod can write neither:
- The durable admission marker (
ServedBuildWitness). When the gate is armed, the sweep offers a row atAdmin/ServedPlatformBuilds/<build>toMeshPublicationGate. The gate holds the offer while the bake measures, writes the row when the pod is admitted, and discards it when the pod is refused. It is read and written straight throughIStorageAdapter, the same pattern as the build claim lock. There is no hub and no point read of a missing node. A read that fails counts as "not served", which is the strict reading. The report carries the answer asServedBefore. - A record whose working build this very build produced. A compile stamp carrying this build's identity is released only once the stamping process was admitted.
The marker is the witness that matters in the ordinary case. The first cut of this fix relied on
record provenance alone, and review showed why that is not enough. An ordinary roll compiles
nothing, because the compatibility key is equal across builds of one epoch. Prebuilt adoption keeps
the PRODUCER's platform version on purpose. So a serving image can leave no record naming itself,
and a restart of it would read as a stranger. A pod that finds either witness is a restart of a
serving image, not a roll candidate, so nothing it reports may gate. NodeTypeBakeReport.GateRelevant,
which the witness-unreadable path refuses on, applies the same two rules.
Together: a pod can still refuse on a regression only if both hold:
- its build is strictly newer than the build that produced the working build it failed to reproduce;
- no replica of its build has ever been admitted here.
Once a build has been admitted with this code, its own pods never meet the second condition, so
the old ReplicaSet can always come back. Before that, rule 1 still holds: a type is never a
regression of an image that is not newer than the image that built it. Failures that no longer gate are still recorded and named in the health payload
(WithoutBaseline). Once the pod is admitted, the held Error stamp is released, and the record
finally says Error. That ends the loop in defect 2.
What the refusal looked like from outside: a death every 3.03 hours
The refusal had one more cost, and it was mistaken for a separate fault. Between 2026-09-25 ~18:00Z
and the break-glass at 04:05Z, every memex portal container on both images died about 3.03 h after
it booted. There was no crash dump for these deaths. Silo departures of this kind produce the
Orleans timeout family filed as #5704
(folding #5709, #5730, #5705). That issue was opened from earlier, 2026-09-24 samples, so most of
its evidence predates this window and is only related. The in-window evidence is narrower:
Dequeue timeouts at 2026-09-25 18:57:39–41Z against silo S10.244.3.247, the incarnation of
56fbdcd48f-cbms9, after that pod's 18:55:12Z SIGABRT. The in-window samples tie these
timeouts to a crash, not to a probe kill. That the probe kills produce the same lines is
inferred, not observed.
Nothing deleted those pods. The kubelet killed each container when its startup probe ran out of budget. The readings, all taken 2026-09-26:
| reading | value |
|---|---|
memex startupProbe (from the record, deployments/aks/memex/values.memex.public.yaml) |
/health, periodSeconds: 10, failureThreshold: 1080, so 10 800 s = 3 h |
boots of 7bd794f9b5-ggf88 ([PlatformStartup]) |
17:58 → 20:59 → 00:01 → 03:03, a 3 h 01–02 m period |
its [LIVENESS] heartbeat (10 s) |
reaches tick ~1085 and never 1088+, so death comes at about 10 850 s |
| its log, once per 10 s up to its last minute (e.g. 05:51:57Z) | Health check nodetype_bake with status Unhealthy … 1 NodeType(s) regressed on this image: BinaryClickerV2/BinaryToggle |
| kubelet event 05:52:27Z | Startup probe failed: HTTP probe failed with statuscode: 503 on the same pod, 3 h into its fourth boot |
56fbdcd48f-cbms9 (ci.9218) |
had served for ~29 h, SIGABRTed at 18:55Z (#4654), came back 18:58Z, then died at 22:00 and 01:02 |
So the loop was:
- A container starts on any image.
/healthstays red because the gate refuses.- After 1080 failed probes the kubelet kills the container and restarts it in place, with the same
pod name and
restartCount+ 1. - The next attempt reaches the same verdict.
The previous image did not stay safe either. A serving pod that crashed for an unrelated reason (cbms9 above) had to pass the startup probe again, and could not. The replicas that the stall clause counts on were exactly the ones the gate turned away.
The pods that looked "deleted and recreated" after 04:05Z are a different event. The break-glass
kubectl set env created a new ReplicaSet, and the ordinary rollout then scaled the old ones down
(SuccessfulDelete … 84f4fb6dcc-wnhdr at 05:53:47Z). Its second pod had waited Pending from
04:07Z to 05:52Z on Insufficient cpu (silos requests at 55–91 %, autoscaler at max node group size reached), which is why that roll took 1 h 47 m.
How to recognise it:
- The cadence equals
periodSeconds × failureThresholdof the running Deployment. - The pod name stays the same while its boots repeat.
/healthis red on that pod for the whole window.
Sample now records lastTerminationReason, lastExitCode and containerStartedAt per replica
(Systemorph/MeshWeaver.Plugins#2397). That separates this kill from a crash (134/139) or an OOM kill
without kubectl. Before that change a restart count was all it carried.
Not established: why these kills left no Application is shutting down line in Loki when the
two rollout deletions did. The /drain endpoint's Drain: lines, which would record whether
preStop and SIGTERM ran, returned zero lines in Loki over the 14 h, including for the rollout
deletions. So these images do not emit them, and their absence proves nothing either way.
The gate holds readiness only
Policy bake-gate-readiness-only. The startup probe proves only that the
process booted. The gate's verdict is read by readiness (/ready) alone. A refusal therefore
stalls a roll: the new pod stays alive, it is not in the Service, the previous image keeps
serving, and nothing is killed. A restarted pod of the previous image keeps serving too, because
the startup probe it must pass no longer carries the verdict.
The two rules above made the gate unable to refuse on the serving image. They could not stop the
second failure: the verdict rode the startup probe. On memex the startup probe read /health with
periodSeconds 10 × failureThreshold 1080, a three-hour budget. A startup probe that never records
a success kills the container at the end of that budget and restarts it into the same verdict. So
from 2026-09-25 ~18:00Z every portal container of both images died 3.03 h after it booted, and a
restarted pod of the serving image had to pass the same probe. The control instance was down from
20:54Z to 04:07Z (#5704).
How it is wired
ProbeEndpoints.RollGateTag(roll-gate) marks a check as a roll gate. The portal's service defaults (ServiceDefaults.TagRollGates, aPostConfigureover every host's registrations) add it to the checks named inServiceDefaults.RollGateChecksand strip anylivetag from them. Those areNodeTypeBakeGateExtensions.HealthCheckName(nodetype_bake) andProbeEndpoints.RequiredModulesCheckName(required_modules). The host registers both checks in MeshWeaver.Plugins, by name. The rule holds whatever tags those registrations carry, so no host can put a roll gate back on a killing probe by forgetting a tag./readyreads checks taggedreadyplus the roll gates. Its body names every check that is not Healthy, so the kubelet's probe-failure event says why./healthstill runs every check, and it still prints the gate's reading, whatever it says, with the suffix[roll gate: read by /ready only, never by the startup probe]. Its status code and the word on line one are the startup verdict: the worst status over every check that is not a roll gate. The operator's instrument (/health, theSampleaction'shealthDetail) keeps showing the gate.- The chart. The readiness probe was already on
/ready. The rollout'sprogressDeadlineSecondsnow addsprobes.rollGate.bakeSeconds(1800 s) in every render, because the bake's time is spent after startup, holding readiness, and the gate can be armed by an env source the render cannot see. Running out of it reportsProgressDeadlineExceededand kills nothing. Arming the gate no longer means raisingprobes.startup. - The guards.
RollGateReadinessOnlyTestdrives the real endpoints over HTTP, on the probe paths the chart ships. A refusingnodetype_bakemust leave the startup probe at 200, turn readiness to 503, leave liveness at 200, and still print on/health. An ordinary Unhealthy check must still fail the startup probe. The same test holds an Unhealthyrequired_modulesto the same three answers, shows that its Degraded verdict (a store-delivered module still expected) holds nothing, and pinsProbeEndpoints.RequiredModulesCheckNameto the literal the host registers today. That pin sees the core side only: the host still registers a literal, so a rename there would not turn it red. Invariant 10b ofcheck-chart-invariants.shrefuses an armed gate whose readiness probe is not on/ready, and any render whose rollout deadline does not cover a cold bake.PreWarmGateReadinessGuardholds the chart's own prose and prerequisites to the same rule.
Which checks may fail the startup probe
The question for each check is whether killing and restarting the container is the right answer to its "no". If the "no" is a property of the image, or of the roll, a restart cannot change it, and the check belongs on readiness.
| Check | Registered in | Can fail the startup probe? | Why |
|---|---|---|---|
nodetype_bake |
Plugins host, if (gateBake) |
No: roll gate, /ready only |
Its verdict is about the image. A restart reaches the same verdict, and killing a previous-image pod removes the roll's fallback. |
db_version (+ DbVersionGate) |
Plugins host | Yes | A portal ahead of its schema must not serve. A restart is the right retry once the migration Job has run. DbVersionGate stops the process itself at startup. |
required_modules |
Plugins host | No: roll gate, /ready only (policy required-modules-readiness-only) |
A declared-required module that the image should ship is absent from the pod's shelf, or a present one did not install against this platform (both Unhealthy). A store-delivered module not here yet is Degraded, a 200 that holds nothing, as before. If the registry cannot serve a module, it is missing for every image, so on the startup probe it would kill the previous image's restarted pods too, the same failure as the bake gate. As a roll gate a missing module stalls the roll and keeps the pod out of the Service. The cost: a restart no longer re-runs the bundle-fetch init container by itself. A pod whose modules arrive later needs a governed Restart. |
process_progress (live) |
Plugins host | Yes, and it restarts via /alive |
A GC-bound process is fixed by a restart. |
PostgreSql |
Plugins host | Yes | No database, no portal. |
self (live, ready) |
core | Never fails | The process can run a delegate. |
content-types, storage_capacity, data_volume_free_space, pending_module_activation, bundle_adoption, entitlement_anchor, view_packs |
core / Plugins host | No: Degraded at worst, which is a 200 | Readings, never verdicts. |
bake-report, source-discovery, publication-seal (census) |
core | No: Degraded at worst, which is a 200 | They always print; they never gate. |
This table is correct for core's main and Plugins' main on the day it was written. A check added
later that can answer Unhealthy must be placed deliberately: startup (/health, the container is
killed) or roll gate (/ready, the roll stalls).
What this does not cover
- A
Faultedsweep on an image that has NOT served here still refuses readiness unlessPreWarm:AllowUnprovenBakeis set. That is deliberate: an unproven new image is exactly what the gate exists to hold back. On an image that HAS served (the marker exists), aFaultedsweep no longer refuses. The gate reads the same witness directly (NodeTypeBakeGateState.ServedBefore), because a sweep that errored has no per-type evidence for rule 1 to judge. - A
Faultedsweep is no longer retried by a restart. While the gate rode the startup probe, a refused pod was killed at the end of the budget and the restart re-ran the sweep. Now a refused pod stays alive and out of the Service, so a sweep that errored stays errored until someone acts: a governedRestartof the instance, orPreWarm:AllowUnprovenBake. That is the price of never killing anything, and it is paid on purpose. - Legacy records that carry no
CompiledPlatformVersionget the strict reading until they are next stamped. - The first armed boot after this ships writes no marker for the images already serving. A marker exists only once a pod of that build is admitted with the gate armed. Until then, a restart falls back on rule 1 and the provenance witness.