Module Versioning
π¨ Rule change, 2026-09-07 (maintainer) β see Module Adoption Policy. Delivery is no longer keyed on SemVer alone: a rebuilt same-version bundle for this platform's identity is adopted, and a fallback generation re-examines every new build. Implemented in PR #3661 (2026-09-08); the sections below describe the mechanism as it runs now.
A package's version is not decoration. It is the ONLY thing that decides whether anything you built reaches a portal that already has an older copy. Get it wrong and the pipeline stays green, the bytes reach the shelf, and no installation ever asks for them.
This page is the authoring reference for that number. For the node revision counter β a different number entirely β see MeshNode Versioning. For what a module IS and how a deployment activates one, see Modules.
π¨ The PLATFORM's version is a third number, and it is not authored here. PlatformVersion has
exactly two shapes β X.Y.Z-ci.<n> for every continuous build and clean X.Y.Z for the release, no
rc, no preview, no labelled line, ever β stated authoritatively in
Release Process & Versioning Β§1. Read it before writing a
package's content.minMeshVersion floor: a floor is a platform version, and SemVer Β§11.4 ranks
pre-release identifiers as text, which is how 42 packages came to declare floors no shipping platform
could ever satisfy (#3554). π¨ A floor is an authoring claim checked at pack time
(check-module-platform-floor.py refuses one above the platform the bundle is built against) β at
runtime it is advisory and loadability is measured, never declared
(issue #3648). Getting it wrong therefore
fails your build; it is not a knob for keeping a module off a deployment.
The three numbers, and who owns each
| number | lives in | owned by |
|---|---|---|
| MAJOR.MINOR β the series | the package root's index.json β content.version |
you, by hand |
| PATCH | manifest.lock β version |
the build (gen-manifests.py), never you |
moduleVersion β a content hash |
manifest.lock |
the build. Unordered; nothing can pin against it |
You author a series. The build derives the patch against the last published release and settles it
on main in tag-modules, so a branch never races the trunk for a number.
- Bump the MINOR for a feature. Bump the MAJOR for a break. That is the whole authoring rule.
- π¨ Never hand-edit the PATCH. It is derived. A hand-set patch is a claim about a tree you did not measure.
- π¨ Never commit a node's
versionfield. That isMeshNode.Version, the owner's persistence clock β not authorable content. A committed counter collides with the durable row on a fresh mesh,MonotonicWriteGuardrefuses the write, and the symptom surfaces four causal steps away as "never reached compilationStatus Ok". Stripversion,lastModifiedandlastModifiedByfrom anything you copy out of a live mesh. - π¨ A published version describes exactly one tree, forever.
tag-modules.pyrefuses to move an existing tag onto different content. Bump; never re-cut.
Why an unchanged version means "delivered to nobody"
Module delivery is keyed on SemVer and nothing else. Two independent gates close on an unchanged version, and neither looks at the bytes:
ModuleUpdateDecisionβlandedComparison == 0βSkipUpToDate. The verdict is taken before any download, and the fetch carries noIf-None-Match, so there is no byte-level fallback.CatalogLayoutAreasβ the manual Update button returnsInstallResult(0, 0)onModuleVersionequality, before the module half is even composed. The human override does not override.
CI still rebuilds the assembly and POSTs it to bundles/<Package>?version=<unchanged>. The shelf is
correct. Nobody requests it. A fresh install gets the new code; every existing portal keeps the old
one forever β and the repo, CI and the registry all look green. This is
MERGED is not delivered one layer down.
π¨ The blind spot: src/ and mixed packages
A mixed package declares content.module and ships an assembly built from src/.
gen-manifests.py enumerates packages with plugin_dirs(), which skips the set the repo declares
in scripts/gen-manifests.config.json (see "One checker, every repo" below):
{"skip": ["src", "test", "scripts", "tools", "e2e", "app", "clients", "meshweaver", "β¦"],
"hashModuleSources": true}
That skip is deliberate and correct for enumeration β src/ is not a package, and skipping it is
what keeps this gate independent of step order and of validate-repos.py. Do not "fix" this by
deleting "src" from the skip list. That changes what moduleVersion means for every package.
The narrower, correct switch is hashModuleSources.
The defect was narrower: for a mixed package, the module's own source under src/ was never hashed
into moduleVersion β so a change confined to src/ moved no version, by construction, and was
therefore built, shelved and delivered to nobody.
Measured on 2026-08-29: 12 of 29 mixed packages were in exactly that state. The worst was the AI
engine itself β AI/manifest.lock on main was byte-identical to the tag AI/v1.0.0
(version 1.0.0, moduleVersion be7ff235b95fb8e4) across 205 changed src/ files. Since
MeshWeaver.AI left the portal image, the module bundle is its only delivery channel, and
Agent/ + Skill/ content ships as embedded resources inside that assembly β so an edit to a
built-in agent reached nobody, silently.
The fix belongs in the derivation, not in a checklist. For a package declaring
content.module, the hash must cover what actually ships in that bundle, so a src/-only change
moves the hash, moves the derived patch, and is delivered. Nothing to remember.
What "what actually ships" means β the closure is narrower than the reference graph
Do not hash the whole dependency network. A bundle's contents are decided by DepsClosure.Derive,
which walks the module's own deps.json from its direct references and stops at MeshWeaver.*
nodes β those are never bundled, because a bundled copy would shadow the one in /app. Diamonds
ride deliberately: /app wins in the default load context while the platform carries a copy, and
the module's copy takes over when the platform sheds it, which is what lets platform slim-downs
land with no re-land coordination.
So for a mixed package the hash should cover:
| in the hash? | why | |
|---|---|---|
| the module project's own sources | yes | they are the assembly |
the module's .csproj |
yes | it pins package versions, and a NuGet bump changes the shipped bundle |
non-MeshWeaver.* transitive deps |
yes, by resolved version | they are bundled alongside the module |
module-owned MeshWeaver.* project references |
yes β and today they are NOT | they RIDE in the bundle (see below), so changing one changes this module's bytes |
image-shipped MeshWeaver.* project references |
no | never bundled β /app supplies them, so changing one does not change this module's bytes |
π¨ MeshWeaver.* is not one row β the container lane split it in two
The version derivation implemented for the fix above hashes the module's own project alone,
on the reasoning that a MeshWeaver.* reference is never bundled. That reasoning is only half
true, and the half it misses is live.
DepsClosure.Derive does stop at MeshWeaver.*, and that is what the sdk pack path uses
(--deps-closure). The container path β now the default for nearly every entry β does not use
it. It reads the module's closure manifest and, for every MeshWeaver.* sibling in it, asks
module-owned-platform.sh one question: is this project's source in this repo's src/ and does
the platform host not already ship it? If yes, the sibling is copied into the bundle and
passed as --with <Name>.dll, and the job log says so:
π¨ That second half used to read "and absent from src/platform-shipped.txt" β a hand-maintained
list, which drifted in both directions and put 27 duplicate copies into 14 of 37 bundles. It is now
MEASURED off the pinned image; see The Platform-Shipped Witness.
closure: MeshWeaver.Blazor.dll RIDES β module-owned (its source is in this repo's src/,
so it is nowhere in the image's /app)
It must ride: nothing else would supply it at run time. But the version derivation does not know
that, so a bundle can change its bytes without moving its version β the exact defect the
src/ fix above was written to remove, one hop out.
Measured on MeshWeaver.Plugins, 2026-09-01 (35 matrix entries, 119 module-owned
MeshWeaver.* projects): MeshWeaver.Blazor is module-owned and rides in 7 published
bundles β Analysis, AppleMaps, EntityViews, GoogleMaps, GraphViews, OpenStreetMap,
Radzen. Not one of those seven manifest.lock files hashes a single src/MeshWeaver.Blazor/
path. An edit there changes what all seven bundles ship and moves none of their versions, so every
existing portal keeps the old copy β SkipUpToDate, before any bytes are read.
The MeshWeaver.AI example in the row above is currently safe only by accident of a transition:
MeshWeaver.AI is listed in that repo's src/platform-shipped.txt while MeshWeaver.Blazor.Portal
still references the engine. That line is marked to leave with MeshWeaver#2599 β and the moment
it does, every AI-provider bundle joins the seven above. Do not read "a change to
MeshWeaver.AI does not require bumping MeshWeaver.AI.OpenAI" as a standing rule; it is a
statement about one entry in one exclusion list on one day.
The correct scope is the RIDING closure, not the whole reference graph. Hash the module's own
project plus exactly the MeshWeaver.* siblings module-owned-platform.sh says ride in its
bundle. That is neither "own project only" (which under-covers, as measured) nor "the whole
closure" (which bumps every dependent on any change and destroys the signal in the number) β it is
the set whose bytes are actually inside the artifact being versioned.
The ProjectReference walker for this already exists β see scripts/check-surface-manifest.py
(assembly names reachable from start through ProjectReference), scripts/project-closure.py,
and the floor rule in scripts/check-module-floors.py. Reuse one of those rather than writing a
fourth; the module-owned/image-shipped split is .github/scripts/module-owned-platform.sh, the
same script the pack step and the bundle inspection call, so the three cannot disagree.
Until that lands, registry-version β manifest-version is NOT a sound publication baseline.
It is proposed periodically as a way to narrow the module lane on push (Plugins#889 option 3);
version equality does not imply byte equality while any sibling rides, so narrowing on it would
silently under-publish exactly the bundles above. A sound baseline has to be a commit β the
analogue of the bake's source-commit.txt β diffed with project-closure.py, which walks
transitive in-repo ProjectReferences and therefore sees riders for free.
If you are reading this while that derivation is still landing: bump
content.version's MINOR by hand for anysrc/-only change to a mixed package, and say in the PR that you did so and why.
π¨ Version strictness β what a platform roll ADOPTS (Modules:VersionStrictness)
Maintainer directive, 2026-09-08: "typically it should accept newer platform versions, especially within the same family β we can have a setting for version strictness; but for dev we should be very tolerant and only use min versions." And earlier: "for every new platform release we should be able to just roll it and the old modules should work β then it is fully decoupled."
Until this setting, a portal adopted a prebuilt bundle only when it was sealed under the portal's
exact framework identity (<root>/<identity>/<source>/). Every platform roll therefore adopted
nothing until every satellite had re-sealed for the new identity, and on 2026-09-08 β with CD
unable to produce a sealed set at all β that meant no roll. The identity gate is still the safest
statement there is, and it is still the default for the image's own bundles; but a bundle's real
requirement is the set of platform TYPES its bytes link against, and that is measurable from the
assembly's own metadata (ModulePlatformLink, the module lane's gate β see
Module Platform Link Gate). So adoption now has a strictness, decided
by PrebuiltAdoptionPolicy and read once per sweep:
Modules:VersionStrictness |
adopts a bundle sealed for another identity when⦠| default where |
|---|---|---|
Exact |
never β this identity's seal only (the rule before 2026-09-08) | β |
Family |
its _releases marker places it on the same major line (3.x on 3.y), its declared floor (minMeshVersion) is satisfied, and every assembly's platform type references resolve against the running process |
every deployment |
Minimum |
its floor is satisfied and its links resolve β the platform line is not consulted | a Development host (the Monolith, the Aspire dev profiles) |
What the policy does per bundle:
- This identity's seal adopts as before β no link check; the bytes were compiled against exactly this platform.
- Otherwise the identity is placed on a line through the
_releases/<version>markers (SealedPublicationIndex.ReleasesOf).Familydeclines an identity no marker names β it cannot establish the line and does not guess;Minimumdoes not need the line. - A candidate the line and floor admit is measured:
ModulePlatformLink.Checkover each assembly's type references against the running platform's surface. Linkable β adopted, stamped with the live dependency ids (PrebuiltAdoptionPolicy.LiveStampOf, so the build-currency clause does not immediately call it stale); unlinkable β compiled from source on a mesh that may compile, or refused loudly β naming the type and the missing member β on aModules:RequirePrebuiltmesh. - The sweep takes, for each source this identity has not sealed, the newest sealed publication
among the admitted identities (
ShippedPrebuiltBundles.FallbackPublishedBundlesOf). A source this identity HAS sealed is never shadowed.
What the link check does not see, so nobody assumes it does: a member that moved on a type that
still exists. That surfaces at activation (MissingMethodException), where the stale-build self-heal
recompiles the type from source β the same fallback a declined bundle takes, one step later.
Development. Minimum is what "very tolerant" means: yesterday's bakes load on an unreleased
platform unless a type they need is genuinely gone. A developer who wants the production rule sets
Modules:VersionStrictness=Family (or Exact) in the Monolith's configuration β a configured value
always wins over the environment default.
π¨ What this setting does NOT decide: whether the replicas of one installation converge on one sealed set. #3417 (fix for #3395) settled the mechanism β two compiles in one process resolve one module set β and deliberately left the POLICY open: whether a replica whose pinned set no longer matches should (a) restart itself, (b) flip readiness so the rollout replaces it, or (c) decline to write NodeType compile records while it is behind (#3417 β "Still open"; Modules β "Replicas of ONE deployment can run DIFFERENT module sets"). This setting inherits that gap unchanged: every replica reads the same shared storage under the same rule, so two replicas of one image choose the same publication unless a seal lands between their boots β the in-place-republish window Sealed Publication Reads describes, which
Family/Minimumneither widen in kind nor close (they only admit more candidate directories). A replica's adoption is written onto the SHARED NodeType record with that replica's coordinates, exactly as a local compile is today. Whether replicas must converge β and how β is the maintainer's decision, and it is stated here so that it is not settled as a side effect of the strictness default.
Full rebuild when the platform updates
A module is built against a platform pin. When the platform releases, the pin moves and EVERY
module must be rebuilt and republished β not only the ones whose source changed β or every portal
reads FrameworkDeclined (built against <old>, live <new>) and adopts nothing.
So the two lanes differ deliberately:
| trigger | bake lane | module lane |
|---|---|---|
pull_request / merge_group |
the affected closure | the affected closure |
push to main |
the affected closure, baselined on the PUBLICATION (source-commit.txt), never github.event.before |
everything β this lane records no publication marker, and the registry version is not one (see the riding-closure note above) |
repository_dispatch / schedule (release-follow) |
everything | everything |
workflow_dispatch |
everything | everything |
The push row is the only one where the two lanes differ, and the difference is a missing
marker, not a policy: bake-scope.sh reads a source-commit.txt sealed beside the bundles, so it
knows the commit its own last publication covered. The module lane's scope still answers FULL on a
push β but since 2026-09-02 the module build ledger sits below the scope
(ModuleBuildArchitecture β "Content-addressed outputs"):
every selected module is keyed by its whole compiled+tested closure plus both image digests and the
platform ref, and a key the ledger already holds as Published is reused, not rebuilt. So a push
compiles every module whose key has no usable Published record β the Plugins#889 baseline, derived
from content rather than from a version or a commit, which is what makes it immune to the riding
blind spot above. Callers opt in with ledger: required.
One checker, every repo
gen-manifests.py is the platform's, at .github/scripts/gen-manifests.py. node-repo-validate.yml
fetches it at the caller's pinned platform-ref and runs it against the caller's tree, exactly as the
compile-check lane runs .github/scripts/compile-check.py (AGENTS.md β "never hand-roll a node repo's
CI"; Module Build Architecture β "scripts are centralized β
the lane fetches the platform's copy at the pin; repos keep only allow-files").
It was vendored per repo until 2026-09-07, and by then the six copies were five different vintages β
MeshWeaver.Plugins 1157 lines, Crm 646, Education 646, Reinsurance 644, SocialMedia 643, Manufacturing
305. Each fix landed in whichever copy hit the bug and the other five kept it: #434 (a release has two
witnesses), #942/#1023 (--resolve), and #1426 β the trunk baseline resolved against the remote's
live tip, which reds every intermediate commit of a merge-queue group on main, because the tree
under test is compared against locks committed after it. Manufacturing's copy never grew the trunk
witness at all, so it also had no --resolve and no self-test.
What stays in the caller is one allow-file, scripts/gen-manifests.config.json:
| key | |
|---|---|
skip |
required β the top-level directories that are NOT packages. Per-repo by construction (a scratch directory in one repo is a shipping package in another), and it must equal validate-repos.py's SKIP, which each repo's check-skip-sets.py asserts. There is no default: a guessed skip list either demands a manifest.lock for scripts/ or silently stops versioning a real module. |
hashModuleSources |
optional, default false β the #878 fix (hash a mixed package's src/ project, and the siblings riding its bundle, into its moduleVersion). It needs the caller's scripts/project-closure.py to expose graph_of / module_owned / riding_siblings, and asking for it without one is an error, never a quiet fall-back to the smaller hash. Default false because turning it on moves every mixed package's version β a release event, not a script upgrade. |
Both settings are declared, never inferred from whether a file happens to exist: a capability that degrades silently on a missing input is the skip-trapdoor shape AGENTS.md forbids, and here it would change what a published version means without saying so.
Adoption is one commit per repo β add the config, drop the vendored copy, point validate-repos.py
and check-skip-sets.py at the platform copy, and pass centralized-gen-manifests: true to the lane.
Before merging one, prove it moves nothing:
MW_REPO_ROOT=$PWD python3 <platform>/.github/scripts/gen-manifests.py --check
MW_REPO_ROOT=$PWD python3 <platform>/.github/scripts/gen-manifests.py --check-versions
Both must be green on the tree as committed β the canonical has to reproduce every lock the
vendored copy wrote, or the swap republishes modules that did not change. Measured that way on
2026-09-07 across all six repos: identical verdicts everywhere, and Manufacturing gained the trunk
witness (verified against their tags β verified against the published tags and the trunk).
Before you open the PR
python3 scripts/platform-script.py gen-manifests.py # after ANY change to a package folder
# OR to a src/ project a package bundles β both move the lock
python3 scripts/platform-script.py gen-manifests.py --check # what CI runs on your branch
platform-script.py resolves the platform's copy at the sha this repo's lanes are pinned to, so a
local verdict is CI's verdict. A repo that has not adopted yet still runs python3 scripts/gen-manifests.py.
--check is a PR gate and is entirely local: it asserts your lock describes your tree.
--check-versions is main-only β a branch is never asked to win a race against the trunk.
The question to ask before merging is not "is it green" but "will anyone receive it":
- Did I change a package's node content? β the hash moves and the patch is derived, but you still
run
gen-manifests.pyand commit its output. "Derived, not hand-edited" does not mean "generated for you": CI regenerates-and-commits only onmain(finalize-versions), and on a branch it merely runs--check, which redsValidate node reposnaming every stale module. - Did I change only
src/of a mixed package? β the same applies β editingsrc/alone moves the lock of every package that bundles that project, often a dozen at once. Run the generator, then confirmmanifest.lockactually moved: if it did not, the change reaches nobody. - Is this a feature or a break? β bump the MINOR or the MAJOR in
index.jsonby hand. - Am I tempted to edit the patch, or to re-cut an existing tag? β no.
Related
Modules Β· MeshNode Versioning Β· Plugin Packaging Β· Deploying Plugin Changes Β· Plugin Registry Β· Plugin Update on Green Build