Platform Versioning (SemVer)
Policy
platform-semver-versioning. Every platform build CD publishes from main is a plain SemVer release<major>.<minor>.<run>. The patch is the monotonic CD run number. The minor changes only when someone bumps it on purpose, and the major changes only on a declared break. No version carries a channel word: noci, norc. A build that publishes nothing (pull request, local, a satellite lane compiling core) is<major>.<minor>.0-dev. Images also carry the floating tags<major>-latestand<major>.<minor>-latest, which the arm step moves forward and never backward. A deployment record follows a version pattern resolved against the registry. It never names a floating tag.
1. The scheme
| shape | example | what it is | run number (BuildOrdinal) |
|---|---|---|---|
<major>.<minor>.<run> |
3.1.10050 |
a main CD build, line ≥ 3.1 (Directory.Build.props with -p:PlatformBuildNumber, passed only by main-cd.yml) |
10050, the patch |
<major>.<minor>.0-edge.<run> |
3.1.0-edge.311 |
an unverified edge build (edge-images.yml, manual) |
311, the edge lane's own run |
<major>.<minor>.0-dev |
3.1.0-dev |
any build that publishes nothing: a pull-request run, a local build, a satellite lane compiling core | 0, never ordered against a publication |
X.Y.Z-ci.0 |
3.0.0-ci.0 |
the retired local stamp, read only | 0 |
X.Y.Z-ci.<run> |
3.0.0-ci.9999 |
a continuous build of the retired notation, read only | 9999 |
X.Y.Z-rcN.ci.<run> |
3.0.0-rc9.ci.7824 |
retired labelled line | 7824 |
X.Y.0 / a clean 3.0.x |
3.1.0, 4.0.0, 3.0.0 |
a floor or a deliberately cut release | none; compared by its numeric core |
<major>-latest, <major>.<minor>-latest |
3-latest, 3.1-latest |
floating pointers | never a candidate |
Why the patch is the run number. The rule "higher number ⇒ newer code" is what every self-updater,
retention plan and floor check depends on (Self-Update Target Selection).
The run number is the one counter the pipeline never gets wrong. If the patch is that counter, the old
notation and the new one share one lineage. The last 3.0.0-ci.<n> and the first 3.1.<n+1> are
then ordered by when they were published, and nothing can roll backwards across the cut-over. SemVer
gives the same order, because a minor bump is deliberate and always later than the builds before it.
The boundary is a declared constant, never inferred. A plain X.Y.Z is read as a build of the new
notation only when its line is at or above PlatformReleaseOrder.SemVerEraStart (3.1) and its patch
is non-zero. That keeps every tag the old notation published meaning exactly what it meant. A clean
3.0.0 stays a promotion. The withdrawn slip 3.1.0-ci.7841 is still ranked by its ci number, so it
can never outrank the new notation. Floors like 3.1.0 or 4.0.0 are still compared by their numeric
core, so a floor that says "needs the 3.1 line" holds a 3.0.0-ci.* portal and is met by every
3.1.<run>.
Stable does not start following main. A new-notation build carries no pre-release label, so to NuGet
it is as "clean" as a release. VersionSelect.PickTargets therefore keeps any tag with a run number out
of Stable, so Stable keeps following exactly what it follows today: deliberately cut releases
(3.0.0, 3.1.0, …), which release.yml promotes. See decision 1 in §6.
Why the line is 3.1, and why the patch continues the run number
The first build of the minter is 3.1.<run> with <run> the next main-cd run (≈ 10330 at the cut-over).
Two other choices were considered and rejected:
3.0.<run>. Every deployed reader already reads a clean3.0.xas a PROMOTION with no run number, so3.0.10331would land in the promotion band and lose to every3.0.0-ci.*set. Moving the boundary would need a second fleet-wide roll of the readers before the minter could flip.- Restarting the patch at 1 (
3.1.1,3.1.2, …). SemVer would still order3.1.1above3.0.0-ci.10330, but the self-updater, retention, floors and the never-backwards guards rank by RUN NUMBER (one lineage, #3542), and1 < 10330: every Continuous install would freeze on its last3.0.0-ciset. Continuing the run number keeps SemVer and the lineage in agreement, so no reader has to choose between them.
The cut-over: what keeps working
- Existing images and floors.
3.0.0-ci.<n>tags stay in the registries and keep their order:3.0.0-ci.10330 < 3.1.10331by run number and by SemVer. A floor stamped3.0.0-ci.10317is met by every3.1.<run>; a floor3.1.<run>holds a portal still on the old notation until it rolls. - Patterns.
3.*(every Systemorph record, Memex step 3) admits both notations. A record still on3.0.0-ci*admits NO new build and freezes silently — the PartnerRe estate's records (Systemorph/PartnerRe.Memex) are widened in their own repository. - Mixed fleets. A portal decides WHICH tag through its own
VersionSelectand hands it to the control lane. A portal whose image predates the step-1 readers (merged 2026-10-07 15:01Z) ranks3.1.<run>below every3.0.0-ci.*set, so it takes TWO hops: first the newest old-notation set (which carries the readers), then the new notation. Measured at the cut-over: control and memex had the readers; memex-cloud (3.0.0-ci.10148) and pearl did not, and were already not rolling for an unrelated reason. The control lane's own lag check (SelfUpdateRouting.RollInsteadOfRestart, Plugins step 2) reads both notations and opens a roll to control's newest seen tag on a restart-pending. - Binding identity.
AssemblyVersionmoves3.0.0.0→3.1.0.0with the line, as designed (PlatformBinding.MayBind): every module compiled against3.0.0.0binds on a 3.1 portal by roll-forward, and a module compiled on a 3.1 build meeting a lagging 3.0 portal is declined loudly as a binding conflict — the same answer its floor gives. MeshWeaver.Plugins and MeshWeaver.SocialMedia derive their AssemblyVersion from the platform's PlatformVersion, so they follow without a change of their own. (Pinning it per major was tried in review and rejected: it changes the expression those repositories verify, turning a version bump into a paired change.) - CD targets the newest VOUCHED commit, not main's tip. main-cd's workflow file is main's, but
the tree it builds is the gate's target, which can predate the minter for a tick or two after it
merges. Such a run mints the retired
<line>-ci.<run>with THIS run's number — still one lineage — and main-cd's shape assertion accepts exactly that, never a-devor another run's number. Measured on the cut-over: main-cd #10351 targetedc6310ff(the minter's tip118dd05had no completed required check yet), composed3.0.0-ci.10351, and the first version of the assertion refused it. - The release.
release.ymlpromotes a<major>.<minor>.<run>build by adding<major>.<minor>.0, moves no pointer for it, and still promotes a retired-notation set the old way.
2. What 3-latest means for consumers
- It is a starting point, never a record value. CD moves
3-latestand3.<minor>-latestat every arm (main-cd.ymlphase D, andcontrol-firstformemex-control), and only forward: a newer armed set holds them. A fresh install or a Helm overlay's seed image may name3-latest, so it boots on the newest armed build. - A record follows a PATTERN, not a tag.
updatePattern: "3.*"admits every build of major 3 in both notations. The self-updater lists the registry, drops pointers (VersionSelect.MovingPointer), per-RID images and git-sha tags, and ranks what remains by run number. A floating tag is never written into aHosting/Deploymentrecord, because records are never pinned to a tag. A pointer is mutable, so a record that named one would describe whatever the pointer happens to hold, not a decision. 3.0.0-latestkeeps moving only while the old notation is minted. The new notation moves no three-part pointer, because3.1.10050-latestwould point at exactly one build. Consumers that seed from3.0.0-latest(the Memex overlays,resolve-line-pointer.sh,preflight-provision.py) switch to3-latestbefore the minter flips. After the flip3.0.0-latestis frozen on the last old-notation set.
3. Inventory: every place the old notation is produced or parsed
Line numbers are as of this change. P = produces the notation, R = parses it. "Handled" means this change teaches the place both notations. "Owed" means a later step of the migration has to change it.
Core (Systemorph/MeshWeaver)
| place | role | status |
|---|---|---|
Directory.Build.props PlatformVersion = 3.1.0; Version = <major>.<minor>.$(PlatformBuildNumber) / <major>.<minor>.0-dev |
P: the minter. Every image tag, MESHWEAVER_PLATFORM_VERSION and package version comes from here |
handled (step 4) |
.github/workflows/main-cd.yml (4 × -getProperty:Version) |
P: passes -p:PlatformBuildNumber=$GITHUB_RUN_NUMBER and asserts <major>.<minor>.<run> before tagging |
handled (step 4) |
test/MeshWeaver.Documentation.Test/PlatformVersionSchemeGuard.cs |
R: the scheme guard: line, main build, release, -dev, binding identity |
handled (step 4) |
src/MeshWeaver.Plugin.Packaging/PlatformReleaseOrder.cs SourceBuildLabel |
R: -dev reads as run 0 (the retired -ci.0 did) |
handled (step 4) |
src/MeshWeaver.Plugin.Packaging/PlatformReleaseOrder.cs:68,92,122 |
R: BuildOrdinal, Compare, Newest, the ONE order every C# caller uses (VersionSelect, PlatformFloor, PlatformCompatibility.ProducerIsNewer, TargetSet, SealedPublicationIndex, ShippedPrebuiltBundles, PrebuiltBundleRetention) |
handled: SemVerEraStart, IsSemVerBuild |
memex/Memex.Portal.Shared/SelfUpdate/VersionSelect.cs:211 |
R: Stable must not admit a run-numbered clean tag | handled |
memex/Memex.Portal.Shared/SelfUpdate/VersionSelect.cs ResolveChannel advisory |
R/P: tells the operator which pattern to set | handled: names 3.* |
src/MeshWeaver.Hosting/SelfUpdate/UpdateChannelPattern.cs |
R: the glob, pattern-agnostic | unchanged; 3.* works as is |
src/MeshWeaver.Hosting/SelfUpdate/SelfUpdateOptions.cs DefaultPattern; src/MeshWeaver.Deployment.Contract/DeploymentPortalConfig.cs:603 |
R: render a record's updatePattern as SelfUpdate__DefaultPattern |
unchanged (pass-through); docs examples still say 3.0.0-ci* |
.github/scripts/platform-version.py (new) |
R: the one shell-facing reader: ordinal, max-ordinal, line-pointers |
new, self-tested |
.github/workflows/main-cd.yml:866 |
R: satellite-compat baseline run number from mw-plugin-test:main |
handled |
.github/workflows/main-cd.yml:2083 |
P: control-first line pointers for memex-control |
handled: 3-latest 3.1-latest |
.github/workflows/main-cd.yml:2442,2447 |
R: promote's never-backwards check before main/latest move |
handled |
.github/workflows/main-cd.yml:2752,2755,2762 |
R/P: arm phase D never-backwards check and the line pointers | handled |
.github/scripts/arm-promoted-set.py:133,151,281 |
R/P: run_number_of, line_pattern. The announcement pattern for control's roll lane becomes 3.1.* |
handled, self-tested |
.github/scripts/resolve-platform.py:217,240,254,264 |
R/P: set names, freezes, bake receipts, promotion records, notices, registry tags (vendored into satellites; the lanes fetch the canonical copy) | handled: match_set_name, compose_set_name, notice_set_number, self-tested |
.github/scripts/assert-bake-floor.py parse |
R: the seal-time floor gate (node-repo-publish-bake.yml) orders a bundle's floor against the platform version |
handled: SemVer-era patch is the ordinal; mixed-era pairs self-tested |
.github/workflows/node-repo-gate.yml:844-847 |
R: platform-set input shape |
handled, executed by test-gate-lane-forwards-the-callers-set.py |
.github/workflows/node-repo-publish-bake.yml:2106 |
R: released-version shape | already accepts X.Y.Z |
.github/workflows/edge-images.yml |
P: edge tag <major>.<minor>.0-edge.<run>, spelled from the line |
handled (step 4) |
.github/workflows/release.yml |
R/P: a v* tag promotes the build of its line in either notation; no pointer move for the new one |
handled (step 4): decision 2 in §6 |
.github/acr-retention/*, comments across main-cd.yml |
prose and fixtures | history; no change |
MeshWeaver.Plugins
Nothing in Plugins calls PlatformReleaseOrder. Every platform-version reader there is hand-written,
and most of them recognise only ci.N. That is the largest share of the owed work. The cure is to
delegate to core's order, not to add another regex.
| place | role | on 3.1.N |
|---|---|---|
Hosting/Deployment/Source/SelfUpdateRouting.cs:929 BuildOrder (used by Decide :883, Superseded :652, RollInsteadOfRestart :1068) |
R: own ci.N parse |
silent: unordered, so the never-backwards guard and supersession turn off |
Hosting/Deployment/Source/RollGates.cs:302,316 |
R: via BuildOrder |
a gated roll never opens |
Hosting/Deployment/Source/ImageLine.cs:52,72,86 (PointerFor, PatternFor, LineOf) |
R/P: line = text before the first - |
gives 3.1.10050-latest, 3.1.10050-ci* |
Hosting/Deployment/Source/InstanceComposition.cs:132 FleetPattern = "3.0.0-ci*", :163 seed tag |
P: new instances get the old pattern and 3.0.0-latest |
must become 3.* / 3-latest |
Hosting/InstanceAction/Source/ActionsExecutor.cs:397,405 |
R: unattended roll needs the pattern to admit | via data |
Hosting/ModuleInventory/Source/ModuleInventoryContent.cs:390-449 (Ordinal, SetsBehind, Platform); PlatformBuildInbox/Source/FleetTargetIntake.cs:268; ReleaseFollowThrough.cs:216 |
R: own -ci\.(\d+) |
silent: no fleet target, holds undetected |
Hosting/DeploymentStatus/Source/DeploymentStatusLayoutAreas.cs:111,126 |
R: display | degrades to "≠ tag" |
.github/workflows/portal-ai-image.yml:300-313 |
P: Plugins mints its own ${PlatformVersion}-ci.${BUILD} from the max ACR tag |
owed: minter step |
.github/workflows/portal-ai-image.yml:152, portal-next-image.yml:124, log-watcher-image.yml:141 |
R: MW_PLATFORM_REF set-name freeze regex |
a 3.1.N freeze is treated as a git ref |
.github/workflows/ci.yml:4494 |
R: floor-stamp gate ^3\.0\.0-ci\.[0-9]+$ |
red |
scripts/platform-requirement.py:78,147,266,343 (+ producers :116…:504) |
R/P: sets, floors, Requires-platform: |
red / silent |
scripts/mesh-floors.py:78,96,208,220,232,282 |
R/P: stamps and checks minMeshVersion = 3.0.0-ci.N |
red |
scripts/ceiling-adoption.py:93 |
R: the resolver's lag sentence | silent |
*/index.json minMeshVersion (~90 packages) |
data: floors in the old notation | fine: core reads both notations, one lineage |
Memex (Systemorph/Memex)
| place | role | on 3.1.N |
|---|---|---|
mesh/Deployments/{build,control,memex,memex-cloud,pearl}.json updatePattern: "3.0.0-ci*" |
data: the records | refuses every 3.1.N; must become 3.* before the minter flips |
deployments/aks/*/values.*.public.yaml SelfUpdate__DefaultPattern: "3.0.0-ci*" |
data: overlay mirror of the record | same, in the same change |
the same overlays' portal.image / migration seed …:3.0.0-latest |
data: seed pointer | 3-latest |
scripts/check-no-pins.py:56 FLEET_PATTERN |
R: records must equal it | change with the records |
scripts/resolve-line-pointer.sh:35,66 |
R/P: latest → 3.0.0-latest; picks the newest concrete tag by a 4th dot field |
3-latest; the sort must use the run number |
scripts/resolve-portal-image.py:25-33 build_ordinal |
R: requires [-.]ci\.N |
red |
scripts/image-contains.py:60, deployments/aks/ci-runners/ci-platform-refresh.py:834 |
R: [.-]ci\.N |
not recognised / silent |
.github/scripts/aks-ops-classify.py:301,334-360 |
R: unattended CI roll needs the pattern to admit | via data |
scripts/preflight-provision.py:473 |
P: default --tag 3.0.0-latest |
3-latest |
Directory.Build.props:32-35 |
P: Memex's own -ci. build version |
optional |
Memex.Portal.Shared/SelfUpdate/VersionSelect.cs (frozen copy) |
R: NuGet SemVer | already orders 3.1.N above 3.0.0-ci.N |
The agent-facing prose that teaches the old notation (AGENTS.md and skills in Plugins and Memex,
Memex docs/*) is updated in the step that changes the behaviour it describes.
4. Migration order
Readers go first, then the data, then the minter. Each step is safe on its own. The tests in §5 show it.
- Core readers (done, MeshWeaver#6208):
PlatformReleaseOrder,VersionSelect(Stable), the resolver,arm-promoted-set.py,platform-version.py,main-cd.yml's readers, the gate lane. It merges and rolls to every portal, the control instance included. Until a portal runs this reader it ranks3.1.Nbelow every3.0.0-ci.*, because a clean tag without a run number used to fall into the promotion band. - Plugins readers (done, MeshWeaver.Plugins#3091):
SelfUpdateRouting.BuildOrder,RollGates,ImageLine,ModuleInventoryContent,DeploymentStatusdelegate toPlatformReleaseOrder.InstanceCompositionseeds3.*/3-latest. The freeze regexes,platform-requirement.py,mesh-floors.py,ceiling-adoption.pyand theci.ymlfloor-stamp gate accept both notations. Rolled to the control instance (it hosts the operator and the roll lane). - Memex data (done, Memex#676): every record's
updatePattern→3.*, overlays'SelfUpdate__DefaultPattern→3.*, seeds →3-latest,check-no-pins.py,resolve-line-pointer.sh,resolve-portal-image.py,image-contains.py,ci-platform-refresh.py,preflight-provision.py. Widening a pattern to3.*before any3.1.Nexists changes nothing. The withdrawn3.1.0-ci.7841matches the glob but is ranked 7841. The record change goes through the governed record path. Nobody edits live records by hand. - Core minter:
PlatformVersion→3.1.0,Version→<major>.<minor>.<run>for a main CD build (-p:PlatformBuildNumber, passed only by main-cd) and<major>.<minor>.0-devfor every other build, with the scheme guard rewritten to the new shapes. From this merge on, CD mints3.1.<run>, arm moves3-latest/3.1-latest, and3.0.0-latestfreezes. - Plugins minter:
portal-ai-image.ymlmints the new notation. Floors are stamped as3.<minor>.<run>. - Retire the
3.0.0-ci*examples in docs, skills and AGENTS files. Leave3.0.0-latestfrozen until no overlay seeds from it.
Do not reorder steps 3 and 4. A record still on 3.0.0-ci* admits no new-notation build, so every
Continuous install freezes on its last old-notation set. Its only signal is "no newer release", and
that verdict is easy to misread.
5. What the tests prove
PlatformReleaseOrderTest.TheOldAndTheNewNotation_InterleaveByPublicationOrder:3.0.0-ci.9999 < 3.1.10000, while a later old-notation set3.0.0-ci.10002still outranks3.1.10001(the run decides, not the notation). The slip stays at 7841. A mixed set sorts in one total order, andTheTotalOrder_IsTransitive_AcrossBothNotationschecks it for every permutation.- Negative control: with the new-notation reading reverted (
SemVerBuildPatchreturningnull), the interleave, transitivity andBuildOrdinalcases fail, and so do threeVersionSelectcases. Without the reader,3.1.Nlands in the promotion band and is never chosen. VersionSelectTest.TheOldFleetPattern_NeverSelectsTheNewNotation: why step 3 comes before step 4.VersionSelectTest.WideningThePattern_BeforeTheCutOver_ChangesNothing: why step 3 is safe early.VersionSelectTest.Stable_NeverSelectsANewNotationBuild: decision 1 in §6, as implemented.AFloor_IsOrderedAcrossTheCutOver: a floor in either notation is ordered against a build in the other.PlatformVersionSchemeGuard(step 4), through real MSBuild: the line is<major>.<minor>.0; a main CD build composes<major>.<minor>.<run>;-p:PublicRelease=truecomposes the line; a CIRun build WITHOUT-p:PlatformBuildNumber(a pull-request run) and a local build compose<major>.<minor>.0-dev; an invalid build number never mints a release version; no composition containsci. Its control arm reintroduces a labelled line and a non-zero patch and requires both to be rejected.PlatformReleaseOrderTest.TheFirstSemVerBuild_OutranksEveryOldNotationSet(step 4): the real boundary pair3.0.0-ci.10330/3.1.10331forIsNewer,Newest, SemVer,PlatformFloorin both directions, andIsSourceBuild.-devreads as run 0 and is advisory against every floor.FailureFromOlderPlatformRedriveTest.AFailureFromTheLastOldNotationBuild_IsRetriedOnce_OnTheFirstSemVerBuild(step 4): a compile failure stamped on the last old-notation build (FailedPlatformVersion, #6349) is retried once on the first new build and never by an older replica; a-devlive build never retries.- The script self-tests (
platform-version.py,arm-promoted-set.py,resolve-platform.py,test-gate-lane-forwards-the-callers-set.py) check the same table. A negative control on the resolver (moving its boundary to 9.9) fails exactly the new-notation cases.
6. Decisions taken (maintainer can override)
These were open when the scheme was drafted. Each was settled the conservative way: nothing already published is renamed, the minor moves only by an explicit, governed action, and Stable keeps following what it follows today. Overriding one is a follow-up change to this page and the code it names. None of them affects the one-lineage order in §1.
- Stable keeps following deliberately cut releases. Stable excludes every run-numbered tag
(
VersionSelect.PickTargets, pinned byVersionSelectTest.Stable_NeverSelectsANewNotationBuild), so a Stable install, the seeded default, never starts following main. It reaches a new-notation line only through a release (decision 2). Not chosen: astablechannel pointer (Release Channels), or Stable as "Continuous with a minor-pinned pattern". Either would change what every Stable install follows on the day it lands. release.ymlkeeps promoting, and renames nothing. Av<major>.<minor>.0tag on a sealed commit adds the clean tag<major>.<minor>.0to that commit's already-published<major>.<minor>.<run>set, copies its release marker and opens the next-line pull request, exactly as it does forv3.0.0today. The<major>.<minor>.<run>tag stays where it is. In the new notation the release moves no-latestpointer: the set it promotes was armed by CD, which already moved3-latest/3.<minor>-latestto it or past it, so a release-side move could only move them backwards. GHCRlateststill follows releases. This is implemented with the minter (step 4). Not chosen: retiring the promotion step, or a git-tag-only release with a channel pointer.- The minor is bumped only by a merged pull request that edits
PlatformVersion. In practice that is the next-line pull requestrelease.ymlopens after a release (3.1.0→3.2.0), which goes through the same review and required checks as any other change. No workflow, script or self-updater editsPlatformVersionon its own. The run number keeps increasing across the bump, so the order is unaffected.
A known risk, not a decision: the counter is GITHUB_RUN_NUMBER of main-cd.yml. Renaming or
recreating the workflow resets it. That risk exists today. With the patch as the run number it
becomes visible in every version, so it is now a SemVer regression as well as a lineage one.