Repository navigation
fix(#3478): a process its readiness validation refuses no longer JOINS the mesh - #3491
Merged
meshweaver-cloud[bot] merged 1 commit intoSep 6, 2026
Conversation
…S the mesh Readiness gates traffic; it never gated membership. On memex.systemorph.com, 2026-09-06, the NodeType bake gate correctly refused ci.7926 and Kubernetes correctly stalled the rollout — and the refused pod then ran for two hours inside the mesh, stamping NodeType compile records with its own framework identity. Crm/Offer and Crm/Opportunity came to name assemblies stamped sc273ee39f… while the serving replicas ran s2f227642d…, and every deal and offer page on the client portal was dead until 21:50Z (#3472). The protection produced the outage, by leaving the refused process a participant. The join point is not process start and not Orleans membership — it is the first mesh-visible WRITE. A portal is addressable long before it can have validated anything, and the bake sweep must READ the mesh to have something to validate, so membership-for-reading cannot move after the verdict. Membership-for-writing can, and that is the half that does the damage: the NodeType compile stamp on both write-back paths, and the module-set adoption record. Assembly BYTES stay ungated on purpose — the bake probe cannot look for them until a RECORD names them, so bytes nobody points at are inert. MeshPublicationGate holds every such publication while this process is provisional, releases them when its validation passes, and never builds them when it does not. Admission is DERIVED from the readiness predicate (Admitted ⟹ ReadinessGranted), so "not ready" and "not admitted" cannot drift apart; it is level-triggered, which answers the become-unhealthy-after-joining direction with the same mechanism. Unarmed deployments — the chart default — are byte-for-byte unchanged. A refused process stays up INERT rather than exiting: the refusal is already real without a crash loop, exiting destroys the /health payload that is the only place the verdict lives, and each restart re-runs a full cold bake. That trade-off is stated in the PR body and in Doc/Architecture/MeshAdmission; the option is a single call away now that the state is named. Gating the activation-path stamp would have broken #1214's self-healing retraction, which watched the SHARED record a refused pod can no longer move — so LocalNodeTypeBuilds publishes the process-LOCAL fact the record was ever a proxy for, and the watch takes whichever witness answers first. Falsification (two replicas on image A, roll to image B whose bake regresses, real mesh, production write): with the fix, 0 of 2 stamps land and both records still read image A; reverted, both records name image B's assembly and the held count collapses 3 → 0 — the two-image window verbatim. The unarmed arm keeps that pre-fix behaviour permanently observable in the suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
There is at least one compile-breaking typo and a concurrency/disposal hazard in the new process-local signal that should be fixed before merge.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Implements “mesh admission” so a pod whose startup validation (NodeType bake gate) refuses it can still read/compile, but cannot publish mesh-visible identity-bearing writes (NodeType compile stamps and module-set adoption), preventing a refused rollout from poisoning shared records.
Changes:
- Introduces
MeshPublicationGate+IMeshAdmissionAuthorityand wiresNodeTypeBakeGateStateto derive mesh admission directly from the readiness predicate. - Gates NodeType compile-state stamping (batch + activation paths) and module-set adoption so refused/provisional pods cannot publish; adds
LocalNodeTypeBuildsas a process-local witness to preserve #1214 self-healing. - Adds falsification tests and architecture/What’s New documentation for the admission model.
File summaries
| File | Description |
|---|---|
| test/MeshWeaver.Hosting.Test/RefusedProcessDoesNotJoinTheMeshTest.cs | End-to-end falsification suite asserting refused/provisional processes cannot stamp shared NodeType records. |
| test/MeshWeaver.Hosting.Test/MeshAdmissionIsNeverMorePermissiveThanReadinessTest.cs | Pins admission/readiness invariants and MeshPublicationGate semantics (refuse/hold/release/discard). |
| src/MeshWeaver.PluginCatalog/ModuleSetAdoptionService.cs | Defers module-set adoption through MeshPublicationGate so refused pods don’t record “serving set N”. |
| src/MeshWeaver.Mesh.Contract/Services/MeshPublicationGate.cs | Core admission gate: holds/releases/refuses publications based on most-restrictive authority verdict. |
| src/MeshWeaver.Mesh.Contract/Services/LocalNodeTypeBuilds.cs | Process-local “usable build” signal used to retract false regressions even when shared stamps are withheld. |
| src/MeshWeaver.Hosting/Persistence/PersistenceExtensions.cs | Registers MeshPublicationGate and LocalNodeTypeBuilds as mesh-scoped singletons. |
| src/MeshWeaver.Hosting/NodeTypeBatchBake.cs | Routes batch-path compile stamps via MeshPublicationGate (factory-based, cold). |
| src/MeshWeaver.Hosting/NodeTypeBakeGate.cs | Makes NodeTypeBakeGateState an IMeshAdmissionAuthority and defines ReadinessGranted + Admission derivation. |
| src/MeshWeaver.Hosting/DynamicTypePreWarmerHostedService.cs | Calls Reconsider() on phase edges so held publications are settled promptly as verdict changes. |
| src/MeshWeaver.Hosting/DynamicTypePreWarmer.cs | Adds second witness (LocalNodeTypeBuilds) to recovery watch and triggers gate reconsideration on retraction. |
| src/MeshWeaver.Compiler.Pipeline/NodeTypeCompilationHelpers.cs | Gates activation-path stamp; emits LocalNodeTypeBuilds before possibly-withheld stamp. |
| memex/Memex.Portal.Shared/MemexConfiguration.cs | Switches module-set adoption from inline boot write to deferred hosted-service registration. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-07-a-portal-refused-by-its-own-checks-no-longer-joins-the-mesh.md | What’s New entry describing the operational behavior change. |
| src/MeshWeaver.Documentation/Data/Architecture/ModuleSetConvergence.md | Links convergence design to the new admission mechanism. |
| src/MeshWeaver.Documentation/Data/Architecture/MeshAdmission.md | New architecture doc defining join points, provisional admission, and inert-vs-exit trade-off. |
Review details
Suppressed comments (1)
src/MeshWeaver.Mesh.Contract/Services/LocalNodeTypeBuilds.cs:60
- Disposing the underlying
Subjectcan race withRecordUsableBuild(it checksdisposedthen callsOnNext), leading toObjectDisposedExceptionduring mesh teardown. Completing the subject is enough to drop later observations without throwing; avoid disposing it (or guard dispose/OnNext with a lock/Interlocked).
public void Dispose()
{
if (disposed)
return;
disposed = true;
built.OnCompleted();
built.Dispose();
}
- Files reviewed: 15/15 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // `hub.ServiceProvider`). Every phase transition below calls Reconsider(): that is the EDGE | ||
| // the gate's own level-triggered read cannot supply, and it is what releases the stamps a | ||
| // passing sweep held and discards the ones a failing sweep produced. | ||
| var admission = mesh.ServiceProvider.GetService<Mesh.Services.MeshPublicationGate>(); |
Comment on lines
+31
to
+33
| private readonly Subject<string> built = new(); | ||
| private volatile bool disposed; | ||
|
|
Contributor
Contributor
Contributor
Contributor
Contributor
Contributor
Contributor
This was referenced Sep 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3478
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 the NodeType bake gate on
memex.systemorph.comcorrectly refusedci.7926,Kubernetes correctly stalled the rollout — and the refused pod then ran for two hours inside the
mesh, stamping NodeType compile records with its own framework identity.
Crm/OfferandCrm/Opportunitycame to name assemblies stampedsc273ee39f…while the serving replicas rans2f227642d…, and every deal and offer page on the client portal was dead until 21:50Z (#3472).The protection produced the outage — not by misjudging the image, but by leaving the refused
process a participant. This makes the refusal real.
Where a process actually joins
"Do not join until validated" does not survive contact with the ordering: a portal 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 move after the verdict. Membership-for-WRITING can, and that is the half that does the
damage. The acts that carry this process's own build identity into the shared mesh are few and
enumerable:
NodeTypeBatchBake.WriteStampCompiledFrameworkVersion,CompiledModulesHash,CompiledDependencies, assembly coordinatesNodeTypeCompilationHelperspost-compile write-backModuleSetStore.RecordAdoption, fromMemexConfigurationAssembly bytes are deliberately not gated: the store is keyed
(nodeTypePath, version)with theproducing framework identity in the key, and
NodeTypeBakeStatus.ClassifyDetailedcannot look forbytes 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) — amesh-scoped singleton every one of those writes goes through, reading its verdict from the
registered
IMeshAdmissionAuthorityimplementations (today exactly one: the bake gate).UnarmedProvisionalAdmittedRefusedPublications are offered as a
Func<IObservable<Unit>>, not an observable, so a refused one is neverconstructed — no storage read is issued and no
RequireSubscribeObservableis minted only to bedropped. 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. That is what makes validate before running implementable without reordering startup — the
sweep runs exactly as before, but its stamps land after its verdict instead of during it.
One predicate, two consumers
The defect is a divergence — two verdicts, one enforced — so admission is derived FROM the readiness
predicate rather than being a second table:
Invariant:
Admitted ⟹ ReadinessGranted— pinned over the whole phase × flag space byMeshAdmissionIsNeverMorePermissiveThanReadinessTest. It is deliberately less permissive in oneplace: an armed gate at
NotStarted, where readiness is granted (a probe cannot tell "switchedoff" from "has not started") but no verdict exists. The incident's pod published through exactly that
window — its bake starts at
ApplicationStarted, minutes after the process does.🚨 Unarmed means unarmed. With
PreWarm:GateReadinessoff — the chart default, every dev host,every test mesh — nothing consumes the bake verdict and nothing is enforced from it. This change is
byte-for-byte inert there.
A process that becomes 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 publication from that moment, and a
RetractRegressionresumes it. Same mechanism, no second one. It never withdraws a pod that isserving — withholding publication is not flipping readiness.
The second witness — and the regression this change had to close
Gating the activation-path stamp breaks #1214's self-healing retraction: it watched the type's
SHARED record, which a refused pod can no longer move, so a false-positive regression would become a
permanent stall instead of clearing itself. The retraction's question was always process-LOCAL, and
the record was only ever a proxy for it — so
LocalNodeTypeBuildspublishes the fact directly (thecompile write-back raises it before the possibly-withheld stamp) and the watch takes whichever
witness answers first. The record watch stays: a type baked by a PEER on the same image is a real
recovery the local signal cannot see.
🚨 The decision this issue asked for: exit non-zero, or stay up inert?
A refused process stays up and INERT. It does not exit. Stated explicitly because this is a
maintainer-level call and the disruptive option must not be taken quietly.
The case for exiting is real: a non-zero exit turns a stalled rollout into a
CrashLoopBackOff,which is louder and arguably a more honest description of a process that will never serve.
Why inert is what ships:
it only changes how the stall is presented. Loudness is not the defect A process refused by its readiness gate still JOINS the mesh — readiness gates traffic, not membership, and a refused pod stamped module generations for 2h (caused #3472) #3478 describes.
/healthpayload naming the regressedtypes is the only place that verdict lives; a crash-looping pod has no readable health endpoint and
its logs rotate every restart.
learn the same verdict again, on a cluster we pay for.
evidence this change is the first to produce.
ModuleSetConvergencedeclined the neighbouringoptions ("have a behind replica restart itself, or flip readiness") for the same reason and noted
they become decidable once the state is named. This is that state, now named.
It is decidable now: with admission derived from one predicate, "exit when
Admission is Refused" isa single call in
DynamicTypePreWarmerHostedService's terminal handler. Deliberately not taken here.Falsification — both arms, with numbers
Scenario, exactly as the issue specifies: two replicas on image A (records stamped
s2f227642d…/Offer|Opportunity|Feedback/v11-s2f22764-A.dll/ module hash8f251f57…), roll to image B, B's bake regresses onFeedback. Assertions are on the DURABLE rows,read back through the mesh's own
IStorageAdapter— the rows a peer replica reads. Real mesh(
MonolithMeshTestBase), production write (NodeTypeBatchBake.WriteStamp), no mocking.WithheldCount = 2,PassedCount = 0,ReleasedCount = 0.Admission = Refused.HeldCount = 3, records unchanged; afterMarkComplete,ReleasedCount = 3,WithheldCount = 0, all 3 records now name image B. Proves the test can see a stamp and the happy path still records what it compiled.PassedCount = 2. The pre-fix behaviour, permanently observable, so a change that quietly disarms the gate cannot look like a pass.Publishforced toUnarmed, measured by hand, then restored)Offer→(3366f713…, "", nodetype-cache, Offer/v22488-sc273ee3-B.dll, 22488)where image A's stamp had been,Opportunitylikewise, andHeldCountcollapses 3 → 0. The two-image window reproduced verbatim. The one case that still passes under the revert is the unarmed arm — correctly, since it asserts the pre-fix behaviour.LocalNodeTypeBuildsalthough the record never moves;AdmissionleavesRefused.Local verification (Release,
-warnaserror, one project per invocation):MeshWeaver.Mesh.Contract,MeshWeaver.Compiler.Pipeline,MeshWeaver.Hosting,MeshWeaver.PluginCatalog,MeshWeaver.Documentation,Memex.Portal.Shared— 0 warnings, 0 errors each.MeshWeaver.Hosting.Test393/393,MeshWeaver.Documentation.Test304/304 (link integrity,What's New integrity and every ratchet guard), plus the 18 new cases.
Scope boundaries (deliberate)
Roll selection must pick the latest platform version that ships ALL of an environment's plugins — completeness is a selection criterion, not a post-hoc verdict #3479 stops the bad roll starting, this one stops a bad roll doing damage when one happens anyway.
Neither subsumes the other.
addressable and its grains still activate — that is what lets the sweep read.
Also in this change
Doc/Architecture/MeshAdmission— the design, the join-point inventory, the exit-vs-inerttrade-off, and the scope boundaries. Cross-linked from
ModuleSetConvergence, whose "rejectedalternatives" section this decides one of.
Category: Fix).No public type or member is removed, so no cross-repo counterpart is required.
🤖 Generated with Claude Code