The Module Platform Link Gate

Rule change, 2026-09-07 (maintainer) — Module Adoption Policy, implemented by #3648, #3649, #3650 and #3651. This page describes the mechanism as it runs after those changes: a declared floor is advisory, a refused generation falls back to the previous one, a new build is adopted eagerly, and a platform roll is held only by a module that provably cannot load on the target.

A module is adopted on what this process can LOAD, never on a version string.

The 2026-09-09 maintainer clarification is explicit: different platform/dependency versions are acceptable while the referenced API is compatible; identical versions are insufficient when the API is not. ModulePlatformLinkTest compiles separate contract assemblies to exercise both directions of version skew, additive API changes, and removal of a referenced type without a version change. It also verifies that a refused upgrade leaves the installed generation and its bytes intact. This probe checks types, not member signatures; actual load/install/execution checks must cover missing methods or changed constructors. A successful type probe is not a claim that those member checks ran.

Until MeshWeaver#3538 the module lane had exactly one platform gate: the module's declared minMeshVersion FLOOR, compared as SemVer against the running platform's version. That gate is a claim — a string a module author writes by hand. The module's real requirement is not a version at all. It is the set of types its bytes are linked against, which the compiler records exactly, without anyone having to say anything, in the assembly's own metadata.

Those two are not the same thing, and the gap between them took a production portal down for a day.

What it cost, measured

memex-cloud ran core 3.0.0-rc9.ci.7693 (main of 2026-09-03). It adopted the pre-installed DefaultViews view pack's MeshWeaver.Graph.Views, whose bytes were compiled on 09-06 against a MeshWeaver.Mesh.Contract that carries CodeOutputCurrency — a type added to core on 09-04 (2dc5868b2). The pack declared minMeshVersion: 3.0.0-rc8; the running version satisfied it; the bytes landed and loaded.

They could not possibly work. Three things followed, and none of them named the install:

Why the declared floor can never answer this

Three properties make minMeshVersion structurally unable to decide loadability:

  1. It is authored, so it can be wrong or absent. An absent floor is "no constraint" — which is right, since inventing one would be a claim the author never made — so a module that says nothing is admitted on no evidence at all.
  2. It is coarse. A floor names a release; a break is a single type or member. A module built one commit after a type was added has the same declared floor as one built a month before it.
  3. It is a claim about the FUTURE. minMeshVersion: 3.0.0-rc8 asserts that every platform from rc8 onward has what the module needs. That assertion is made before the platform it will run on exists, and this is precisely the direction the module lane deliberately permits: a module built against a newer platform is allowed to load on an older one, because that permission is what makes an ex-post Store install possible at all.

The recorded frameworkMvid cannot substitute for it either. That is an opaque identity, not an ordering: equality proves the same build, and anything else proves nothing. Gating modules on MVID equality would forbid every legitimate cross-build install — the whole point of the module lane. See Module Versioning for what the pack lane records and why.

What the gate does instead: measure

ModulePlatformLink (src/MeshWeaver.Mesh.Contract/ModulePlatformLink.cs) reads the module's type references straight out of its metadata — no Assembly.Load, no module initializer, no type loading, no side effect — and resolves each one against the copy this process would actually bind to.

The verdict is a three-state, and the third state is the point:

State Meaning What callers do
Linkable every referenced type in every resolved platform assembly exists load it
Unlinkable a referenced type is absent, or a whole referenced platform assembly is refuse, naming the type
Indeterminate the check could not be MADE (unreadable bytes; a platform copy that would not parse; no published surface) at landing and at boot: refuse. At the roll gate: report — see At the roll below

ModuleLinkVerdict.MayLoad is true for Linkable and nothing else. It is written as an explicit predicate rather than left to each call site precisely so that nobody can spell the check as != Unlinkable and quietly admit Indeterminate. "I could not determine whether this loads" and "this loads" are different facts, and a gate that reports the first as the second is a gate that cannot fail.

The denominator is part of the verdict

A clean answer over zero checked references is indistinguishable from a check that never ran, so the verdict carries what was measured: how many type references were resolved, which platform assemblies were read, and — named, never silently folded into "fine" — which referenced assemblies were not checked.

An assembly is in the denominator when the platform carries it. Two categories are deliberately outside it:

The one exception: an unresolved assembly whose simple name is the platform's own (MeshWeaver.) is refused. That is the whole-assembly shape of the same defect and a certain load failure.

What it does NOT see

Member-level skew. A method or constructor signature that moved on a type that still exists — MeshWeaver#2234's original MissingMethodException — is invisible here, because this checks TYPE references. That shape is caught at install by IncompatibleModule, and the two are complementary halves rather than one check. Stating this explicitly matters: a gate whose blind spot is undocumented gets read as covering more than it does.

Where it runs, and what it protects

At landing — the floor, measured

ModuleLandingService.LandCore consults it beside the declared floor, from memory, before a single byte touches the disk. A refusal therefore costs no generation directory, and the generation already running keeps running.

The two landing paths differ exactly as they already did for the declared floor:

🚨 The surface here carries the ACTIVE generation of every landed module, and it is rebuilt per landing. A wave lands its modules ONE AT A TIME, and a module may legitimately reference a SIBLING module that landed thirty seconds ago and that this process has not loaded (restart-as-activation). A surface that knew only /app — or one captured at the wave's first landing — would not carry that sibling, and the platform-prefix rule below would refuse a module that is perfectly fine. On a real deployment that reads as a feature silently missing after an upgrade, which is the same class of confidently-wrong verdict this gate exists to replace. The ACTIVE generation specifically, through the one resolution rule (ModuleDirectoryFor) — a superseded generation is still on the volume until the GC reclaims it and can legitimately lack a type its successor has, so listing directories would make the verdict depend on the filesystem's ordering.

At boot — the generation is parked, not the portal

MeshBuilder.InstallAssemblies probes each module before Assembly.LoadFrom. A refusal is recorded as an IncompatibleModule — the same record a module whose registration threw produces — so it is reported three ways that already existed: written to stderr at boot (the only channel that exists before the logging pipeline is up), registered in DI for any host to surface, and classified RequiredModuleState.Incompatible so a module declared under Modules:Required is named on /health rather than silently absent.

The blast radius is the point. One module that cannot link costs that module's contribution and nothing else — never every other module, never the portal, never every render on it.

The previous generation runs when the newest cannot (MeshWeaver#3649)

Refusing a generation is not the same as losing the module. Until #3649 it was, for every module the image did not also ship: the refused generation was the activation entry's only pointer, the generation that had loaded last time was unreferenced, and the next GC pass reclaimed it. A shelved landing built for a newer platform therefore took a working Store-only module away for good. Rule R1 of the Module Adoption Policy is the opposite: an installation runs the newest generation of every module that loads, and keeps the one it has until a newer one does.

The fallback is present, not incompatible: Modules:Required classifies it Present, the readiness probe stays Healthy, and the package card says which version runs. It is also not pending: a restart re-measures the same bytes and falls back again, so PendingModuleActivations subtracts a fallback whose refused generation is still the one the set activates — and counts it pending again the moment the set moves on to a generation other than the refused one, which a restart genuinely tries. What makes that restart happen is MeshWeaver#3650 (rule R3).

🚨 The surface is the application closure PLUS every directory the boot is loading from. A store-landed module lives in its own generation directory and may legitimately reference another module landed beside it; measuring against /app alone would report that sibling as an absent platform assembly and quarantine a module that is perfectly fine. The runtime's own resolution surface is what has to be measured.

At the roll — the surface travels as a document

A platform roll is held only by a module that provably cannot load on the target, and by nothing declared (MeshWeaver#3651; the maintainer's rule of 2026-09-07, Module Adoption Policy). On that day every production portal sat on its morning build: the declared floors declined every candidate (lifted by MeshWeaver#3648), and the satellites' missing bakes for the new identity would have held it again the moment the floors were lifted — while a boot compile of those courses succeeds on every pull request, and every candidate would have loaded. So the roll gate asks the same question this page's probe asks at boot — would these bytes load there? — about a platform that is not running anywhere the gate can reach.

The answer needs the target's type surface at gate time, and the one process that can write it is the bake, because the bake runs inside the target image. Every bake therefore writes platform-surface.json beside framework-mvid.txt (BakeOutput.WritePlatformSurface, from ModulePlatformSurface.ToJson), publish-bake-bundles.sh uploads it beside _complete for every identity, and PublishedBundleCatalogue reads it back (ModulePlatformSurface.FromJson). The shape is deliberately minimal — the identity the document is keyed to, and per assembly the full type names it exports, exactly the set TypesOf answers on a running process:

{
  "identity": "s5b8b0e2c…",
  "assemblies": {
    "MeshWeaver.Blazor": ["MeshWeaver.Blazor.BlazorView`2", "…"],
    "MeshWeaver.Mesh.Contract": ["MeshWeaver.Mesh.MeshNode", "MeshWeaver.Mesh.ModulePlatformSurface", "…"],
    "…": []
  }
}

An assembly whose surface the producer could not read is omitted, never written empty: an empty list reads as "this assembly has no types" and would report every reference to it as missing. A document that is not this shape is refused by the reader (JsonException) rather than read as an empty surface — an empty surface refuses every module that binds a MeshWeaver.* assembly, which would be a confidently wrong hold, not a missing measurement. mw-plugin-test platform-surface [<app-dir> --shared-frameworks <dir>] writes the same document by hand for a bake that predates it.

At the gate (ReleaseAvailability, fed by ModuleLinkObservation.Measure — the one IO step, on the file-system pool), per installed package that ships a compiled module:

Situation Answer
The target's sealed module set declares a build of the module it will be adopted at the roll (MeshWeaver#3650); nothing to measure — its consistency is the sealed-set rule's business (MeshWeaver#3175)
No such build, and nothing landed on this instance nothing keeps running across the roll; nothing to hold on
No such build, and a landed generation — the bytes that keep running ModulePlatformLink.Check(landed entry DLL, target surface): UnlinkableModuleUnloadable, the hold, naming the module and the missing types; Linkable ⇒ clear; Indeterminatereported

🚨 Indeterminate is REPORTED at the roll, not refused — and that is the opposite of what this page says for boot and landing, on purpose. At boot and at landing the thing being refused is one module's load, and the fallback keeps the previous generation serving; the blast radius is one module. At the roll the thing that would be refused is the whole platform's update, for every module, on a publication that simply predates the surface — which is exactly the shape of the 2026-09-07 hold, reintroduced with a better excuse. So a release with no platform-surface.json, a document that does not parse, or landed bytes that cannot be read is written onto the verdict's advisories ("Views: whether its landed module … loads on 3.0.0-ci.8100 could not be determined — no sealed source publishes platform-surface.json …"), logged, recorded on Admin/UpdatePolicy and shown on the Updates tab — and decides nothing. The safety net after such a roll is this page's boot-time probe (which DOES refuse), the keep-the-previous-generation fallback (MeshWeaver#3649), and the readiness stall.

The same verdict carries the missing content bakes as a cost, not a hold (UpdatabilityVerdict.BootCompiles, "would recompile at boot on …: education, crm"): the compile is the code path every pull request of that content already proved green. Modules:RequirePrebuilt — the opt-in strict mode in which the seeder refuses a boot compile and parks the type — keeps it the hold it used to be everywhere. RollSelection therefore walks newest-first and stops at the first release with no unloadable module; "no complete release" names the module.

How it composes with the generation pin

ModuleGenerationPin (MeshWeaver#2509) copies the generation a replica is about to load into process-local storage, so a sibling replica's GC cannot reclaim bytes this process still lazily loads. The two are sequential and independent: the link probe decides whether a generation may be loaded, and the pin decides from where. The probe runs against the resolved load path before InstallAssemblies touches it, so a refused generation is never pinned and never copied — and a pinned one is measured exactly as the shared one would have been. Neither changes the other's answer.

How a quarantined module reads on a surface

ModuleActivationReport now carries a fourth state beside pending, unresolvable and deferred: Quarantined. It is kept apart for the same reason as the others — the remedies differ, and every one of these states was previously mis-rendered as "restart required":

State What it means Remedy
Pending landed on the volume, not loaded here a restart
Unresolvable activated, but its bytes are gone re-install the package
Deferred landed, but in no proposed module set the landing wave must complete
Quarantined refused: its bytes need a platform this deployment is not running a platform update — which is itself the restart that loads it
Fallbacks (MeshWeaver#3649) present and running its PREVIOUS generation; the newest one landed but does not load here none — a build that loads here, or a platform update, takes over by itself
Fallbacks, @image (MeshWeaver#3735) present and running the IMAGE-SHIPPED copy; no landed generation loads here — the row reads runs the image-shipped baseline; vX (gen) landed but does not load here: … none — the same; the health check names it, and a restart measures the same bytes and falls back again

A quarantined module must never be reported as pending. Its assembly genuinely is not loaded, so the pending derivation finds it — and a restart re-runs the same measurement on the same bytes and refuses again. "Restart required" would be a prompt no restart can clear, which is the same false promise the held-entry and missing-bytes rules already exist to prevent.

On the package card the person who installed it sees one localized line (ui.moduleBuiltForNewerPlatform) instead of a feature that is silently absent. Platform-owned chrome, so it follows the viewer's language — see Localization.

What proves it

test/Memex.Portal.Shared.Test/ModulePlatformLinkTest.cs. Every test compiles real assemblies with Roslyn and drives the real gate; nothing is mocked and no rule is re-derived locally.

The repro compiles a module against a stand-in MeshWeaver.Mesh.Contract carrying a type the platform running the test does not have — memex-cloud's shape verbatim: same assembly simple name, missing type — and lands it through the real ModuleLandingService.

Test What it pins
AModuleBuiltAgainstANewerPlatform_IsRefusedAtLanding_NamingTheMissingType the refusal, the named type, and that nothing landed
AModuleBuiltAgainstThisPlatform_LandsAsBefore the gate is a gate, not a wall
AnUnloadableModule_IsShelvedRatherThanRefused_OnThePublishPath the shelf still stocks what it cannot run
UnreadableBytes_AreRefused_NotWavedThroughAsUnknown Indeterminate fails closed
ALinkableModule_ReportsANonZeroDenominator a clean verdict actually checked something
AWholePlatformAssemblyThisDeploymentLacks_IsRefused the coarse-grained shape, and that a private dependency is named unchecked rather than refused
AModuleReferencingASiblingModuleLandedMomentsEarlier_IsNotRefused the FALSE-refusal direction: a sibling module is not an absent platform assembly
AnUnloadableModule_IsParked_AndTheOthersStillInstall the blast radius: one module, not the portal
AParkedModule_ReadsAsQuarantined_NeverAsRestartRequired the false-promise rule
ARequiredModuleRunningItsPreviousGeneration_IsPresent_NeverIncompatible a fallback (MeshWeaver#3649) is Present — the probe stays Healthy

The fallback itself is proven end-to-end in test/MeshWeaver.Compiler.Pipeline.Test/ConfiguredModuleActivationTest.cs (the #3649 section): a loadable generation is landed, a generation built for a newer platform is shelved over it, and a boot composed as the portal composes it runs the previous one and reports it; both generations unloadable stays incompatible; the GC keeps the fallback and reclaims it after an uninstall; the adoption records what loaded; the projection onto the mesh's set carries the pointer; the activation report names the row and never calls it "restart required". The #3735 section of the same file proves the image-shipped step with the image copy resolved through the REAL baseline resolver: the only landed generation is refused and the image copy runs, reported, recorded as @image on the adoption, Present for Modules:Required, and named on the activation report; a previous generation is tried BEFORE the image copy; an image copy that does not load either leaves the module incompatible with both reasons on the record (the negative control on the branch); an image entry listed but not shipped claims nothing; and a generation that loaded and then threw at install is not replaced by the image copy.

The roll gate's half (MeshWeaver#3651) is ModulePlatformSurfaceJsonTest and ReleaseLinkGateTest in the same project — real modules, a real published root on disk, the running process's own surface document with one type removed as "the older target":

Test What it pins
ToJson_ThenFromJson_CarriesTheIdentityAndEveryAssemblysTypes the document round-trips the identity and exactly the set TypesOf answers
ARealModule_LinksIdenticallyAgainstTheLiveSurfaceAndTheDocument the same module, the same verdict and the same denominator on both
ADocumentThatIsNotASurface_IsRefused_NeverReadAsAnEmptySurface fail closed on shape — an empty surface would refuse everything
AModuleUnlinkableAgainstTheTargetSurface_HoldsTheRoll_NamingTheModule THE hold: ModuleUnloadable, module and type named, never Indeterminate
ALandedModuleThatLinksAgainstTheTarget_Clears the gate is a gate, not a wall
AModuleWithABuildPublishedForTheTarget_IsNotHeldOnItsLandedGeneration the published build is what will be adopted; the landed one does not decide
AReleaseWithNoPublishedSurface_IsIndeterminate_ReportedNeitherClearanceNorHold the roll-time direction of the third state
AMissingContentBake_IsACostTheVerdictNames_AndAHoldOnlyUnderRequirePrebuilt both arms of the bake rule on one fixture
TheWalkStopsAtTheFirstReleaseWithNoUnloadableModule newest-first, past the unloadable one, naming it; the cost said on the outcome
WhenEveryCandidateCannotLoadTheModule_NothingIsSelected_AndTheModuleIsNamed "no complete release" stays put and names the module
Reconnecting…
The server was updated. Reloading the page to pick up the latest version.