"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 memex.systemorph.com 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
module-set adoption ModuleSetStore.RecordAdoption "a replica is serving set N"

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:

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

Build Identity Admission · Module Set Convergence · NodeType Compilation · Modules · Deployment (AKS) · Reading CI Signals

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.