Module Adoption Policy

Maintainer directive, 2026-09-07: if no plugin version is shipped, we use the old one. We make it load despite a newer dependency on the platform or on another module. As soon as a new module version ships, we start using it. We must become far more robust during deployments.

This page is the authority on what an installation runs. Every other architecture page that describes a floor, a hold, a skip or a decline on the module lane defers to it; where such a page still describes the older mechanism, it says so in a banner that names the implementing issue.

The three rules

Who may adopt unattended is a PER-PACKAGE choice since 2026-09-14 — updatePolicy on the install record (Auto / Notify / None), seeded from PluginCatalog:DefaultUpdatePolicy, and independent of the platform's image policy on Admin/UpdatePolicy. The rules below say WHAT an installation runs once a version is adopted; Plugin Update on Green Build says whether the adoption happens by itself.

Maintainer clarification, 2026-09-09: compatibility follows the used API, not a platform pin. Keep a module usable across platform and dependency releases when the contracts it uses remain compatible. A different version, build commit or MVID is not evidence of incompatibility. A removed type/member, changed required signature or another actual linking/loading failure is. Conversely, identical version strings do not make incompatible bytes safe.

This is a runtime contract. Reproducible compiler inputs and content-addressed cache keys record what produced an artifact; they must not become an exact-version requirement for running a compiled module. A NodeType bake with another build identity is not reused blindly: compile its source against the current platform. That cache miss is not a declaration that the feature is incompatible. Explicit strict-prebuilt policy remains an operator choice.

The compatibility regression suite must include positive and negative controls: unchanged used APIs across different assembly versions, additive APIs/body changes, removal of a referenced API even with an unchanged version, incompatible member signatures, and preservation of a working generation when an upgrade fails. Exercise real metadata/loader paths; tests that compare version strings alone cannot prove these claims. ModulePlatformLinkTest covers the type measurement and rejected-upgrade continuity. Member compatibility also needs actual loading/execution: the current type-level probe cannot establish it. Do not describe a successful type probe as proof that every method call is compatible.

# Rule What it replaces
R1 — continuity An installation always runs some version of every module it has installed: the newest one that loads. If nothing newer ships for the platform it runs, the version it has keeps running. Nothing removes a working module because a newer one exists but cannot load. A landed generation that does not load leaves the module absent (only image-shipped modules had a baseline to fall back to); a shelved landing overwrote the only reference to the loadable generation.
R2 — declared floor, then measured A package version declares minMeshVersion. An installation uses a version only when its running platform is at or above that floor, and normally installs the newest such version (policy package-min-mesh-version). The floor is decided by ONE comparator (PlatformFloor, below); a floor that cannot be ordered against the running version proceeds as advisory. Whether a version that passes the floor LOADS is then measured — the type-level link probe (ModulePlatformLink, core #3552/#3538) and the actual load. R2 as first written — "measured, never declared" — made the floor advisory everywhere after 2026-09-07. On 2026-09-27 that let packages built for a newer platform sync onto older ones — see R2: the declared floor below.
R3 — eager adoption The moment a new module version ships — a newer version on the registry, or the same version rebuilt for this platform's identity — the installation adopts it, if it loads. If it does not load, R1 applies and the row says so. Adoption ran at boot and on catalog opens; a fallback generation was never re-examined when a loadable build for the same version appeared.

The rules compose into one sentence: run the newest version whose floor this platform meets and that loads, keep what you have until then, and never let an unorderable string decide.

R2: the declared floor

The rule (policy package-min-mesh-version, which carries the maintainer's words and the date): source must ask for which min version; any version above the minimum is used; normally we attempt to install latest. It replaces R2's first wording, "measured, never declared", for package and version selection. R1 and R3 are unchanged, and the link probe stays as a second measurement; it is no longer the only gate.

Why the floor holds again — 2026-09-27

Store 1.16 used IPaymentProvider billing-portal members (core deef2cfa15) and Hosting used DeploymentContent.AnnouncementKeySecret (core be5d157273). Both synced onto instances running 3.0.0-ci.9412/9414, images that predate those commits, and failed to compile: CS0117/CS1061, 10 Store and 4 Hosting NodeTypes with no usable assembly. Both packages declared the platform they needed. Nothing read it:

The tests that existed asserted the opposite. ModuleFloorAdvisoryTest pinned "advisory everywhere", and no test drove the content lane with a floor at all. A newer package landing on an older platform was the tested behaviour.

One comparator: PlatformFloor

MeshWeaver.Plugin.Packaging.PlatformFloor.Evaluate(floor, running) is the only floor decision. Every consumer calls it: the module update decision, the content reconcile, the install orchestrator, the installer, the per-module GitSync decline and the prebuilt-adoption policy. OneFloorComparatorGuard fails when a src/ file compares a declared floor with SemVer anywhere else. The running version has one reader, PlatformBuildInfo.RunningPlatformVersion: the image's MESHWEAVER_PLATFORM_VERSION with build metadata stripped. ModulePlatformFloor.RunningVersion and PrebuiltAdoptionPolicy.RunningPlatformVersion both delegate to it. (The module lane used to read MeshWeaver.Graph's assembly stamp, which carries no run number.)

Only a comparable floor strictly above the running version holds. That rule is what keeps the 2026-09-07 trap closed. SemVer ranks ci < rc < clean, so an rc or clean floor can never be met by a ci build.

Floor vs running Verdict
no floor none
both carry a run number (3.0.0-ci.N vs 3.0.0-ci.M) held iff N > M. The version line is ignored, so a mislabelled line loses.
either side is -ci.0 (a local source build) advisory: proceeds
floor or running version unreadable (latest, unknown, blank) advisory: proceeds
otherwise, by numeric core a higher core is held, a lower core is satisfied. An equal core with a clean floor (3.0.0 vs 3.0.0-ci.N) is satisfied. An equal core with a labelled floor (3.0.0-rc8 vs 3.0.0-ci.N) is unordered: advisory, proceeds.

PlatformFloorTest.TheSeptember7Trap_NeverHolds pins the second half of that table, and TheSeptember27Shape_Holds pins the first. ModulePlatformFloor.DeclineReason keeps its SemVer comparison for two jobs that decide nothing at runtime: the wording of the status-row advisory, and the pack-time lint's parity oracle.

Phase 1: the consumer holds (done)

Lane On an unmet floor
Compiled module (ModuleUpdateDecision, via PluginBundleClient.AdoptModule) SkipPlatformBelowFloor. The bundle is not even downloaded, and the landed generation keeps running. The floor applies to a Land answer only, so it never hides a more specific skip.
Source content: an update (PackageUpdateReconciler, CatalogLayoutAreas.InstallOrUpdate) Held before any file is fetched. The installed version keeps running. The install record carries heldUpdate ("held: 1.16.0 needs platform ≥ 3.0.0-ci.9494, running 3.0.0-ci.9412 — updates when the platform rolls"), and the update applies on the first reconcile after the platform rolls. A record already at the candidate's hash is not held: re-landing what is already there replaces nothing.
Source content: a fresh install (InstallOrUpdate, PackageInstaller.Install) Refused with PackagePlatformFloorException, which names both versions. Nothing is fetched or written.
Source content: any other caller of the installer over an EXISTING record (a maintenance RefreshModules) Refused the same way unless it re-installs the content already recorded (AllowedOverExistingRecord, 2026-10-04). Before, an existing record waved any version through.
GitSync (ModuleSyncDecision) That one module is declined through the same PlatformFloor decision. Its siblings still sync.
Prebuilt adoption (PrebuiltAdoptionPolicy) Declined through the same decision, and the content compiles instead. This path used to hold a private SemVer copy, which kept the 2026-09-07 trap live.

The self-update roll gate (ReleaseAvailability) is unchanged, and no roll waits on a floor.

A hold ends by itself (2026-10-04). "Updates when the platform rolls" is kept: every roll is a boot, the boot reconcile re-decides each held candidate, and the safety-net timer and every module-published broadcast do the same. The first pass on which the floor is met CLEARS the record's heldUpdate/heldSince and decides the update again. Tonight's AI hold did not apply after the roll for a SECOND reason the sentence never named: its partition is sync-owned, so the decision then degraded to the reminder, and no lane ever landed a sync-owned package's module — see One Partition, One Bookkeeping, gate 1e, for how that now converges. The target such a hold converges to is defined once in One Promotion Gate → The target set.

Blocking tickets

A hold is correct only while the working version keeps serving and the hold is visible. Nobody acts on a silent hold. When an update is held, the instance files one blocking ticket through the dispatch channel it already has: IPackageHoldDispatch. The portal registers PackageHoldHandover, which posts a signed package-update-held event with severity: blocking into the control inbox on the self-update announcement's own route (Hosting:ControlInbox:Url + Hosting:ControlInbox:Secret). The event carries the package, the held version, the floor, the running version, the version that keeps running, and what unblocks it.

Phase 2 (not done)

What "loads" means

Two lanes, two measurements, one fallback:

"Loads" includes "installs". A generation that is refused by the link probe, that faults in Assembly.LoadFrom, or whose provider attribute throws while its contributions are materialised has not loaded in the sense of R1 — it contributes nothing, and a module that contributes nothing is worse than one that is absent when it has displaced a copy that would have worked. The order of the fallback is fixed and every step is measured by the same probe and load as the one before it: the newest generation → the previous landed generation → the image-shipped copy → absent, reported. The one shape the order cannot reach is a generation whose assembly loaded and whose install then threw: the default load context holds one assembly per simple name, so neither the previous generation nor the image copy can be loaded beside it. That shape stays absent and reported, and the report names the image copy it could not substitute, so nobody reads the absence as "the image had nothing" (the mechanism is on The Module Platform Link Gate).

The rule was measured against on 2026-09-08 (the control instance, image 3.0.0-ci.8079): the module set pinned a two-week-old store generation of MeshWeaver.Blazor.Views, an assembly the image also ships. The boot's link probe refused it correctly — it referenced MeshWeaver.Graph.AnchoredComment, a type the platform had since removed — but the boot union had already substituted the store entry in place of the baseline entry, so the image's copy was never tried, the module "contributed nothing", and every skinned control on the portal rendered through its fallback HTML. The probe was not the gap; the fallback order was, and this section is what closes it.

🚨 This is the CONSUMER half, and it masks the producer's defect rather than removing it. A portal that falls back to the image copy renders correctly while its publication still carries two builds of one assembly name, so "does it render" is not evidence a bundle is clean. The producer half — a module bundle never carrying a MeshWeaver.* copy the platform already ships, measured off the image rather than declared in a list — is The Platform-Shipped Witness, and it probes the same two locations MeshBuilder.ResolveModulePath does, on purpose. One thing this fallback still cannot reach: the sealed-set conflict below HOLDS the roll for the whole fleet whatever one process does at boot. The other — a riding copy that does load, shadowing the image copy so the fallback never runs — is what the next section closes.

Two copies of one module: the FRAMEWORK IDENTITY decides (#4161)

The fallback above runs when the store generation does not load. The harder case is the one where it does: the image ships a copy of a module, the store holds another copy of the same name, both link, and the wrong one wins. Until #4161 the boot union (ModuleActivationBoot.ComputeEffectiveModuleEntries, pass 1) made every enabled store entry whose landed DLL existed an override of the same-named Modules:Assemblies baseline entry. The DLL's existence was the only gate.

Measured on memex-local, 2026-09-08/09 (Plugins#1483): the portal was built from source that day (3.0.0-ci.0) and its image shipped its own MeshWeaver.Blazor.Views, written 2026-09-07 08:30:50Z, mvid 8c833e20. A store pack of the same module, built 2026-08-27 14:00:29Z, mvid a87ef06f, displaced it:

[ModuleLoad] MeshWeaver.Blazor.Views ← /tmp/meshweaver-pinned-modules/…@96af4c59/…
             (source=store, mvid=a87ef06f, written=2026-08-27 14:00:29Z)

The link probe passed, the module loaded, and its view registrations no longer matched the control types the platform emits — so the Subscribe panel's outermost control rendered as StackControl { … }: a whole-tree ToString(). Not a missing-view error, not a log line, just a page that reads as broken. "Loadable" and "correct for this platform" are different properties, and the link probe only answers the first — it asks whether the types a module references exist, and a view pack that registers views against types the platform no longer emits references nothing that is missing.

The rule. When a store entry's recorded framework identity (ModuleActivationEntry.FrameworkMvid) differs from the identity the booting platform reports (PrebuiltAssemblySeeder.LiveFrameworkMvid) and the image ships a copy of that same module, the image's copy runs and the store copy is reported declined: built for another platform on the boot skip channel. The image's copy is correct for this platform by construction — it was compiled with it.

Three bounds, each of which is the rule and not a caution:

Case Verdict Why
Identity unrecorded on either side Unchanged — the store copy wins R2: an unrecorded identity is absence of evidence, not evidence of difference, exactly as an unrecorded version is to the landing service. Reading it as a difference would decline every module landed before identities were recorded at all. Deliberately not PrebuiltAssemblySeeder.DeclineReason, which declines an absent identity — declining is the safe answer there and the damaging one here.
Store-only module (the image ships none) Unchanged — the store copy wins There is nothing to prefer it to. A declined module is an absent module, which is strictly worse than one whose identity does not match; this is the same trade "an unusable one must not override" already makes.
Identity matches The store copy wins if it is a newer release than the image's copy — see the next section The ordinary upgrade path (#2548) and the whole point of installing a module. The discriminator decides on a difference, never on being a store copy.

This is not the declared floor. The floor is a string a module's author wrote. Since policy package-min-mesh-version it holds a version only when it is comparable with the running platform and above it (R2). The identity is what the producing toolchain measured about the bytes it emitted, compared against what this process measures about itself. It only chooses between two copies the deployment already holds, and never where there is just one.

It is self-healing, and it cannot tear a replica set apart. R3 lands a bundle whose served identity differs from the landed one at the same version (ModuleUpdateDecision), so the moment the registry serves this module built against this platform it lands and wins again — no operator step, no re-install. And every replica of a deployment runs one image, so every replica states one identity and reaches one verdict (#3395 holds).

The decline has to reach the loader, not just the log. A baseline entry resolves through MeshBuilder.ResolveModulePath, whose probes are landed root → image → app closure and whose landed probe looks in the fixed modules/<name>/<name>.dll. Generation landing writes modules/<name>@<gen>/, so that probe misses on its own — but an entry from before generation landing carries no Directory and its bytes sit in exactly that folder, so the resolver would hand back the copy pass 1 had just declined, silently, with the decline line already printed. The baseline emitted in place of a declined store copy therefore carries EffectiveModule.PreferImageCopy, and ResolveLoadPath resolves it with no landed root — image → app closure. Every other baseline entry keeps the probe order it always had.

The seam is explicit: the identity is a parameter of the pure computation, not something it reads from the process. The six-argument overload states none and declines nothing, which is what a host compiled against the previous platform binds — an optional parameter is a compile-time default and not a binary one, so a discriminator that silently read the process's own identity would change what those hosts do at their next boot with nothing in their diff. ModuleIdentityDiscriminatorTest (test/MeshWeaver.Compiler.Pipeline.Test) lands every copy through the real landing service and carries all three bounds as negative controls.

When the identity cannot decide: the image copy's VERSION does (#6044)

Under the compatibility key (policy platform-backwards-compatibility) every build of one platform epoch states the same framework identity, so the rule above stopped discriminating: a store copy built from older sources than the image stated the image's own identity and overrode it. Measured on memex.meshweaver.cloud, 2026-09-28: the image 3.0.0-ci.9554 was built from Plugins 8931656f, and the module store held MeshWeaver.AI 1.16.3 and MeshWeaver.Hosting.Instance 1.0.6 built from the older 6d21172e / 84f5e5f1. Both stated identity c003e001, both overrode the image's own copies, and a correct image rendered the pre-8931656f admin page. The image's locks were not yet settled, so its copies carried the same version numbers as the store copies over newer sources.

The record. A module DLL carries no package version of its own (its informational version is the platform's, #3732), so the image build writes it down: the closure lane (memex/MeshModulesPublish.targets, WriteMeshModuleSeedStamps) finds the package that declares each closure module (<package>/index.json → content.module) and writes modules/<Name>/module.seed.json beside the DLL — version and moduleVersion from that package's manifest.lock at the commit the image is built from. ImageModuleSeed reads it.

The rule. A store copy of an image-shipped module overrides the image's copy only when its recorded version is a strictly newer release than the stamp's (ImageModuleSeed.DeclineReason). Otherwise it is declined exactly as an identity mismatch is: named on the [ModuleActivation] SKIPPED line, the baseline emitted with PreferImageCopy, and reported by PendingModuleActivations as declined, never as "a restart activates it".

Why EQUAL loses. An equal version is the case that happened. Publication follows a settled lock, and settling bumps the version, so a store copy at the image's own version carries the same sources or older ones — never newer. The image's copy was also compiled against this very platform. Preferring it is right in every equal case there is.

Case Verdict
Store version newer than the image stamp Store copy wins — the upgrade path
Store version equal or older Image copy runs; store copy declined, named
No stamp (image from before it, module no package declares), stamp without a version, or store entry without one Unchanged — decided by the identity rule alone (R2)
Store-only module Unchanged — nothing to prefer it to

It is self-healing in the same way: the next published release of the package is newer than the image's stamp, lands, and wins. The pure rule is pinned by ImageCopyVersionDiscriminatorTest (test/MeshWeaver.Compiler.Pipeline.Test), whose control first proves the store copy wins without the stamp.

A declined bundle risks stale TYPES, not just a slower boot

The bullets above treat no adopted bundle as a cost paid in boot time, because the content then compiles in the mesh. Measured on 2026-09-10 (the control instance, 3.0.0-ci.8238, core 2287decb, identity s546f29f9…) that is not the whole story: a module whose bundle is declined can keep serving types built from source older than the source the instance has installed, while the package's own install record reads installed, current and up to date.

MeshWeaver.AI had gained ModelDefinition.ReasoningEffort (Plugins#1556, 09-09 12:41Z) and ThreadMessage.Timing (Plugins#1552, 11:59Z). The install record carried main's own file hashes — src/MeshWeaver.AI/ModelDefinition.cs = 89fd0a2264…, byte-identical to origin/main, which declares the property — so the sources were current. The types were not: get @…/schema/ModelDefinition listed no reasoningEffort, and a patch setting it wrote a new node version with the property silently dropped. The instance's own health said why:

bundle_adoption: Degraded — 25 adoption attempt(s), 0 assembly/assemblies adopted, 25 MISS(es) —
content the registry was meant to serve is compiled here instead: AI: FrameworkDeclined
(built against framework s72c27afab89c1007…, live framework is s546f29f90e235e61…)

The registry's bytes were therefore never in play. What served was the previously resolved build, kept alive by the same-MAJOR stale-but-serving rule recorded on The Execute-Time Interlock (#3844): a moved source fingerprint is deliberately not a refusal, so 1.5.0 → 1.5.1 kept the old build. That is why a Store RefreshModules re-land and two restarts each reloaded the identical generation and changed nothing. What finally let fresh types through was a new {package}@{version} shelf entry — AI/index.json 1.5 → 1.6 with no source change (Plugins#1586) — for which no previously adopted build existed. Two minutes after that merged the instance installed AI 1.6.0, and the property appeared on the served schema and survived a write.

Three consequences worth carrying:

🚨 Do not read stale served types as the registry serving bad bytes. The two are indistinguishable from the consumer: same [ModuleLoad] line, same generation directory, same mvid, and [ModuleLoad] never names the commit a bundle was built from. The discriminator is bundle_adoption on /health — FrameworkDeclined means the registry's copy was never adopted, so a fix aimed at the shelf would be aimed at bytes this instance never ran.

The registry shelf: arrival order is not version order (#3996)

R1–R3 describe the consumer. A registry instance is also where producers publish: CI uploads module bundles to it (POST /api/plugins/bundles/{plugin} → ModuleLandingService.ShelveModule), and since #3461 two lanes publish the same module — core CD's plugins-bake at the gate's Plugins commit, and the module repository's own publication. Core CD runs take 40–50 minutes, so the older build routinely arrives last. Until #3996 every accepted upload moved the registry's activation head. Measured on the plugin registry instance on 2026-09-11: MeshWeaver.Mail.MicrosoftGraph 1.7.0 landed at 02:49Z and 1.6.1 displaced it at 03:00Z; the activation entry read Version=1.6.1 PreviousVersion=1.7.0, so the next restart would have silently un-shipped a merged change. MeshWeaver.AI showed the same shape that night.

The rule is the consumer's "never roll back unattended", applied to the publish route: the head is the highest version the shelf holds, never the last upload to arrive. The order is NuGetVersionComparer's — the comparer SkipOlder uses — so a registry's head and its consumers' update decisions cannot disagree about which version is newer.

The upload, relative to the current head What happens
Strictly older, and the head's bytes are present Shelf-only. The bytes land, the head does not move, and the publish answers 200 with shelfOnly: true and the reason.
Equal version The head moves: a rebuild of unchanged source against a newer platform republishes under the same version (Plugins#931). Identical bytes resolve to the same content-addressed generation, so re-publishing them is a no-op.
Newer The head moves; the displaced generation becomes the fallback (#3649).
Pre-release vs stable SemVer order: 1.7.0 outranks 1.7.0-rc1, and 3.0.0-ci.3758 outranks 3.0.0-ci.900 (numerically). A pre-release published after its stable is shelf-only.
Either version unknown, or not a SemVer version at all The head moves, as before. An absent or unparseable version is no evidence of order (R2) — and NuGetVersionComparer reads an unparseable part as 0, so without this a nightly label would rank below every real version.
A newer head whose entry DLL is gone The head moves. A record without bytes is not a version this registry holds, and refusing the one upload that could heal it would turn the rule into a self-sealing outage. A re-publish of the head's own bytes (the content address ignores the version label) resolves to the head's own directory instead: the landing restores the files it lost, and the head keeps its label.
A newer head that does not link on the registry's own platform The head stays. The shelf warehouses modules for newer platforms, and boot runs the fallback when the head does not load there (R1). Letting an older loadable upload take the head would move the registry onto the older version the moment its own platform caught up — #3996 by another road.

What happens to a shelf-only upload's bytes. It competes for the head's fallback slot (PreviousDirectory), which the modules GC never reclaims. It takes that slot when the slot is empty or its bytes are gone, when it loads on this platform and the recorded fallback does not, or — at equal loadability — when it ranks higher. Otherwise it is not retained: the publish response says retainedAsFallback: false, and the GC reclaims the generation. There is one fallback slot, so a registry holds at most two generations of a module (head and fallback) — the #3649 design, not a new limit. If a shelf-only upload moves the fallback while the head does not load here, the restart flag is raised, because boot runs the fallback and a restart genuinely changes what loads.

Resolvable by version. The bundle index lists the head and a retained fallback as separate entries at their own versions, newest first, and GET /api/plugins/bundles/{plugin}/{version} serves the generation that version names (ModuleBundleSource.CollectVersion). The index readers (PluginBundleClient and mw module fetch) take the first entry for a package, so they still get the head. A consumer that asks for a retained older version by name gets those bytes, never the head's bytes under another label. Versions match by their exact text, as the index advertises them — not by the SemVer comparer, which would make any two non-SemVer labels equal. An older upload whose bytes are identical to the head's is the head's generation: it is not listed under its own label, and its bytes are served as the head's.

Deliberate rollback. Re-publishing an older version no longer rolls a registry back: the publish route has no operator, only build jobs, and a build job never intends a rollback. Roll forward instead (publish the fix under a higher version), or uninstall the module on that registry first (ModuleLandingService.RemoveModule disables the entry, so the next publish is a first landing). The Store's adopt path (LandModule) does not carry this rule; the unattended lane that could reach an older version is already refused by SkipOlder.

A record that already regressed — the 2026-09-11 state of the plugin registry instance, head 1.6.1 with 1.7.0 as its fallback — is not rewritten by an older or equal upload; the newer generation stays retained as the fallback. It heals on the next publish of 1.7.0 or higher, which the next core CD cycle delivers once its gate carries that version.

Covered: two replicas at once (#4026). Landings are serialised within one process, and across replicas the rule used to be last-writer-wins: two replicas landing the same module within the same few seconds each decided against the entry they read before the other wrote. A landing no longer decides that way. It writes an immutable record of its own facts — its lane included, so the shelf's "never regress" and the adopt path's "an operator asked for it" both survive — and the head and fallback are DERIVED from every record present by replaying this rule in arrival order, so every replica reaches the same answer whatever order the writes landed in. Module Activation Head Ownership carries the on-disk format, why it stays readable by images already deployed, and the retention rule.

What the platform roll gates on

The self-updater and the CD post-promote gate select the newest release on which no installed module is unloadable. Concretely, per installed package: a build published for the target identity exists (it will be adopted), or the landed generation links against the target's surface, or neither can be shown — which is reported as indeterminate, never as clearance and never as a hold. Declared floors do not enter the ROLL: they decide which package version an installation takes (R2), never whether the platform rolls. A missing content bake does not enter (it is reported as "would compile at boot: …"). The sealed-set consistency check (#3175/#3221) stays: two builds of one platform assembly in one identity is a torn publication, and torn publications are refused whole.

The safety net after a roll is boot-time, in this order: the link probe refuses what cannot load; the fallback keeps the previous generation; a module with no loadable generation makes the pod unhealthy and the rollout stalls, with the previous pods serving. That is what "robust during deployments" buys: the worst outcome of a wrong roll is a stalled rollout with a named module, never a portal that serves nothing and never an installation stuck on a month-old build because a string said so.

The implementation plan

Four changes, in this order; each is one pull request against core with its tests, and each rewrites the page(s) it makes true.

Step Issue Change Pages it rewrites
1 #3648 Floors become advisory at every runtime decision point (ModuleUpdateDecision, LandFromBundle, LandCore, boot, ReleaseAvailability, the status surfaces). Pack-time floor lint stays as authoring hygiene. This also resolves the 2026-09-07 self-update deadlock without a production write. Modules, ReleaseGates, SelfUpdateTargetSelection, ModulePlatformLinkGate, PluginPackaging, ReleaseGateDenominator
2 #3649 Keep the previous generation: ModuleActivationEntry.PreviousDirectory, boot fallback in MeshBuilder, GC keeps it, the module set records what loaded, status rows name it. ModuleSetConvergence, ModulePlatformLinkGate, Modules
3 #3650 Eager adoption: a fallback re-examines every new build for its version; a ModulePublished broadcast triggers the package's reconcile; a pending restart rolls the same image within the interval rules. PluginUpdateOnGreenBuild, Modules
4 #3651 The roll gate on measured loadability: the publication carries platform-surface.json per identity, ReleaseAvailability links landed generations against it, ContentBakeMissing is advisory, RollSelection stops at the first release with no unloadable module. RollSelection, ReleaseStrategy, CiContentBake, ReleaseAvailability pages, SelfUpdateSchemaWall

All four steps are implemented: 1 is #3661, which carries 2 and 3 merged into it, and 4 is stacked on it as the pull request for #3651. The pages each step names now describe the mechanism as it runs once that change set ships, and their banners say so. The one-time exit from the 2026-09-07 hold was a pipeline roll (helm-release), taken by the maintainer's decision on the same day, with the satellites baked for the target identity first so the roll adopted their bundles rather than compiling them.

What stays exactly as it is

Why the version string was the wrong instrument (2026-09-07), and what changed

A minMeshVersion is a claim written before the platform it names exists. It is authored, so it can be absent or wrong; it is coarse, so a 3.0.0 line cannot express "after commit X"; and it was compared by a total order (ci < rc < clean) that encodes a release policy, not compatibility. Every one of those properties produced an outage that year: rc7 floors deadlocking a registry against its own roll (2026-08-22), a 3.0.0-rc14 floor naming a platform that never existed, and the 2026-09-07 hold.

Two things are different now. Floors are written by the producing build (3.0.0-ci.N, one shape, policy version-shapes), not typed by hand against an rc line. They are compared by PlatformFloor, which decides only what it can order and waves everything else through as advisory. The comparator was the wrong instrument; the claim was never wrong in kind. Without it, 2026-09-27 showed that the link probe alone lets source for a newer platform land on an older one.