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

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 — measured, never declared Whether a module loads is decided by measurement — the type-level link probe against the running platform (ModulePlatformLink, core #3552/#3538) and the actual load — never by a version string. A declared minMeshVersion, or a dependency on another module's version, is advisory: logged, shown on the status row, decides nothing. minMeshVersion compared with NuGetVersionComparer at eight decision points, each of which refused, held or skipped on a miss. Because that comparator ranks ci < rc < clean, an rc or 3.0.0 floor on an installed record could never be satisfied by any ci build — which is how every production portal was held on the morning build for all of 2026-09-07 while every candidate would have loaded.
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 thing that loads, keep what you have until then, and never let a string decide.

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 (memex.systemorph.com, 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. Two things this fallback cannot reach: a riding copy that does load shadows the image copy, so the fallback never runs; and the sealed-set conflict below still HOLDS the roll for the whole fleet whatever one process does at boot.

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 (memex.systemorph.com, 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 /healthFrameworkDeclined 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 memex.meshweaver.cloud 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 memex.meshweaver.cloud state, 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.

Not covered: two replicas at once. Landings are serialised within one process. Two replicas landing the same module within the same few seconds each decide against the entry they read before the other wrote, and the later write wins — #4026. The #3996 incident was sequential (eleven minutes apart); what remains is the concurrent case.

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. 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

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 is compared by a total order (ci < rc < clean) that encodes a release policy, not compatibility. Every one of those properties produced an outage this 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. The measured link probe has none of them: it reads the bytes, it answers per type, and it cannot be out of date because it is computed against the platform actually running. What the string was for — telling an operator which platform a module was built against — is served by the identity the bundle already records (frameworkMvid) and by the status row, not by a gate.

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