"not ready should NEVER run" · "validate before running" — maintainer, 2026-09-06

A readiness probe takes a pod out of the load balancer. It does not take the process out of the mesh. On 2026-09-06 that difference cost the control instance a two-hour client-facing outage (#3478, #3472) — and it did so through a protection that was working correctly.

What happened

A roll to 3.0.0-ci.7926 started at 19:17Z. The NodeType bake readiness gate measured a regression and refused readiness:

Health check nodetype_bake with status Unhealthy:
  'NodeType bake regressed on this image — refusing readiness so the rollout stalls with the
   previous image still serving. 1 NodeType(s) regressed on this image: Feedback/Feedback'

That verdict was right, and the rollout correctly stalled. Two hours later:

pod image ready started
79ddd44d77-k8qlc rc9.ci.7693 true,true,true 18:54:57Z
79ddd44d77-xzp5d rc9.ci.7693 true,true,true 18:57:30Z
f69cd9d56-jczb7 ci.7926 FALSE,true,true 19:17:02Z

The refused pod kept running, kept its hubs activated, and kept stamping NodeType compile records. Crm/Offer (19:28:04) and Crm/Opportunity (19:31:30) came to name assemblies stamped sc273ee39f… while the serving replicas ran s2f227642d…; both types went unloadable and every deal and offer page on the client portal was dead until 21:50Z. compilationStatus read Ok throughout — the types compiled, just not for the identity that had to load them.

The protection produced the outage, not by misjudging the image but by leaving the refused process a participant.

Where a process actually joins

The first instinct — "do not join until validated" — does not survive contact with the ordering. A portal process becomes addressable long before it can have validated anything, and the validation itself needs the mesh: the bake sweep reads every NodeType and its sources through mesh queries. So membership-for-reading cannot be moved after the verdict.

Membership-for-writing can, and that is the half that does the damage. The mesh-visible acts that carry a process's own build identity are few and enumerable:

act where what it says
NodeType compile stamp, batch path NodeTypeBatchBake.WriteStamp CompiledFrameworkVersion, CompiledModulesHash, CompiledDependencies, assembly coordinates
NodeType compile stamp, activation path NodeTypeCompilationHelpers (post-compile write-back) the same field-set — the two paths share it literally
NodeType prebuilt adoption stamp PrebuiltAssemblySeeder (the bundle seeding every boot runs, and on-demand adoption) CompiledFrameworkVersion + assembly coordinates — the same poison, reached without a compile
module-set adoption ModuleSetStore.RecordAdoption "a replica is serving set N"

🚨 The adoption row was missing until #5544, and the gap was measured. the control instance's 9260 pod (2026-09-23/24) never became Ready across five restarts, and each boot's bundle seeding logged Prebuilt assembly ADOPTED … (framework s9e58a…) 168 times before its sweep began. The two serving 9218 replicas' LIVE RECORD CENSUS then read "21 NodeType record(s) were RE-KEYED to a framework this replica does not run AFTER it booted" — the #3472 outage shape, produced by a process the gate was holding for every OTHER stamp. It is the inverse of #5353's bind-path yield: that one stops a LEAVING process re-keying backwards, this one stops a process that has not JOINED re-keying forwards. A held adoption is still an adoption for this process: the bytes are on the store under this framework's key and the definition goes into NodeTypeAdoptionRegistry, so the sweep classifies the type as baked exactly as before — only the shared record waits for the verdict. The seeder learns which verdict applied from MeshPublicationGate.Offer, never from a second read of Admission.

Assembly BYTES are deliberately not gated. The assembly store is keyed (nodeTypePath, version) with the producing framework identity baked into the key, and NodeTypeBakeStatus.ClassifyDetailed cannot look for bytes at all until the RECORD names a collection, a path and a version. Bytes nobody's record points at are inert. The pointer is the poison, so the pointer is what is gated.

The mechanism

MeshPublicationGate (src/MeshWeaver.Mesh.Contract/Services/MeshPublicationGate.cs) is a mesh-scoped singleton every one of those writes goes through. It reads its verdict from the registered IMeshAdmissionAuthority implementations — today exactly one, the NodeType bake gate — and takes the most restrictive answer.

admission meaning what a publication does
Unarmed no authority is armed runs, unchanged
Provisional armed, no verdict yet held, released on admission
Admitted validation passed runs
Refused validation failed never built, never written

Publications are offered as a Func<IObservable<Unit>>, not as an observable: a refused publication is never constructed, so no storage read is issued and no RequireSubscribeObservable is minted only to be dropped. A held one is constructed at release time, so its compare-and-set reads the row it is about to race rather than replaying a version read before the verdict existed.

Provisional membership, stated

A provisional member may read the mesh and compile. It may not publish. Everything it would publish is held, in offer order, and settled the instant a verdict exists.

That is what makes validate before running implementable without reordering startup. The bake sweep runs exactly as before; what changed is that its 240 stamps land after its verdict instead of during it.

The second witness — why gating the compile stamp does not break self-healing

The bake gate already retracts a regression when the type it condemned is afterwards seen to build on this image (#1214 — a bake that compiled a half-applied plugin update recorded four false regressions and hung a rollout until the content converged seconds later). That watch read the type's shared record, because that is where the compile watcher publishes.

🚨 Gating publication breaks that proxy, and leaving it broken would trade one outage class for another. A refused pod recompiles the type successfully, the stamp is withheld — correctly, it names an identity the serving replicas cannot load — and a record-only watch waits forever for a row that will never move. #1214's self-healing stall becomes a permanent one.

So the retraction now has two witnesses, and takes whichever answers first:

🚨 The activation sweep's per-type wait needed the same second witness, and did not get it until #5544. DynamicTypePreWarmer.WarmOne activates a type and waits for its record to show a usable build — and on a gated pod that record cannot move until the bake passes, which cannot happen until this wait answers. A deadlock by construction: the control instance's 9260 pod fell back to the activation path when its batched source discovery failed, and every one of its 107 pending types logged → TimedOut — 300.0 s. 107 × 5 minutes outlasts the three-hour startup probe, so the pod was killed and restarted into the same sweep five times. WarmOne now merges LocalNodeTypeBuilds exactly as the recovery watch does. What it still cannot hear locally is a compile that ran on ANOTHER silo (the type's grain placed on a serving replica) or a local FAILURE (only successes are published locally) — those still time out as unevaluated, never as a verdict.

🚨 "Compiled, its stamp is held" is announced BEFORE the stamp is held (#6050). The witness fires first, synchronously on the compile thread, and the stamp is offered to MeshPublicationGate a moment later in the same callback — that ordering is the point of the witness, so it is pinned, not "fixed". Consequently MeshPublicationGate.HeldCount read once, right after WarmOne answered, can still be 0: anything that needs the hold (a test, a diagnostic) WAITS for it rather than sampling it. AGatedSweepHearsItsOwnCompileTest asserts both halves — the gate's count read inside the witness's own notification is unchanged, and the hold then arrives; swapping the two statements in NodeTypeCompilationHelpers turns the first assertion red.

The readiness message names the step it is waiting on

The gate's detail used to be set once — enumerating dynamic NodeTypes — before the bundle seeding, and held for the whole sweep, so the pod above reported "enumerating" for eleven hours while it was 30 types deep in an activation sweep. NodeTypeBakeGateState.MarkProgress now rewrites it at every step while the phase is Running: waiting for the static import, adopting bundles, the enumeration count, the build-claim election, following another replica's build (the one unbounded wait — it ends only on a GO or on the claim falling free), the batched discovery, and per type building k of n … waiting on <type>, for up to <budget>. A terminal verdict is never overwritten by a late progress line.

One predicate, two consumers

The defect #3478 names is a divergence: two verdicts, one of them enforced. So admission is not a second gate with its own table — it is derived from the readiness predicate:

public bool ReadinessGranted => Phase switch
{
    BakePhase.NotStarted => true,       // the sweep is switched OFF — the fail-OPEN state
    BakePhase.Complete   => true,
    BakePhase.Faulted    => AllowUnprovenBake,
    _                    => false,      // Running, Regressed
};

public MeshAdmission Admission
    => !GatesReadiness ? MeshAdmission.Unarmed
        : ReadinessGranted ? MeshAdmission.Admitted
        : Phase is BakePhase.Running or BakePhase.NotStarted ? MeshAdmission.Provisional
        : MeshAdmission.Refused;

The invariant is Admitted ⟹ ReadinessGranted — admission is never more permissive than readiness, which is the maintainer directive reduced to one line a test can falsify over the whole state space.

It is deliberately less permissive in exactly one place: an armed gate at NotStarted. Readiness is granted there (a probe cannot tell "switched off" from "has not started"), but no verdict exists, so publications are held rather than run. The incident's pod walked through exactly that window — its bake starts at ApplicationStarted, minutes after the process does.

🚨 Unarmed means unarmed. With PreWarm:GateReadiness off — the chart default, every dev host and every test mesh — nothing consumes the bake verdict and nothing is enforced from it. Making an unarmed gate withhold writes would be enforcement nobody opted into, and would turn a configuration default into an outage. "Registered" and "armed" stay separate, exactly as they already do for the readiness half.

Becoming unhealthy after joining

The verdict is level-triggered, re-read at every publication rather than latched at admission. A regression recorded hours after a clean sweep stops the process publishing from that moment; a RetractRegression (the type has since built on this image) resumes it. That answers the symmetric question with the same mechanism and no second one.

Note what it does not do: it never withdraws a pod that is serving correctly. Withholding publication is not the same act as flipping readiness — the pod keeps answering requests from the build it already has, it simply stops telling the mesh about new ones.

Exit, or stay up inert? — the decision, and its trade-off

A refused process stays up and inert. It does not exit.

The case for exiting is real: a non-zero exit turns a stalled rollout into a CrashLoopBackOff, which is louder, harder to overlook, and arguably a more honest description of a process that will never serve.

Why inert is what ships:

It is a maintainer-level call and it is decidable now: with admission derived from one predicate, "exit when Admission is Refused" is a single call in DynamicTypePreWarmerHostedService's terminal handler. It is deliberately not taken here.

The other half: what a HEALTHY process must refuse to adopt

This page is about membership — a refused process must not publish. Its counterpart is adoption: a healthy, admitted process must never load an assembly compiled for a framework build identity that is not its own, and must never report compilationStatus: Ok for a type whose hubs cannot activate. Neither subsumes the other — this one stops a refused process producing the foreign records, that one stops any process consuming them, whatever their origin (a peer replica, a node repo's committed record, a prebuilt bundle).

See Build Identity Admission for the gate, for why Ok was a lie that no writer-side rule can fix alone, and for the derived-never-persisted CompilationStatus.Foreign that keeps a reader-relative verdict out of the shared record.

What this does not fix

The Bake Gate Only Stalls a Roll · Build Identity Admission · Module Set Convergence · NodeType Compilation · Modules · Deployment (AKS) · Reading CI Signals