Advisory floors in a dependent — what changed here, and what the pin bump measured
MeshWeaver#3648 / #3651 (2026-09-07), Doc/Architecture/ModuleAdoptionPolicy rule R2: a
module's declared minMeshVersion is advisory — logged, shown on the status row, and it decides
nothing. Loadability is measured: the type-level link probe against the running platform, and
the actual load. Step 4 of the same policy made a missing content bake an advisory too — a cost
("would recompile at boot: …"), not a hold — except on a Modules:RequirePrebuilt instance.
The reason is concrete. NuGetVersionComparer ranks ci < rc < clean, so an rc or clean floor
could never be satisfied by any ci build. On 2026-09-07 that made memex-cloud's self-updater
decline all 11 candidate releases ("77 plugins required … every one declined") and held every
production portal on its morning build for the day — while the link probe would have loaded every
one of those modules.
What that did to THIS repo
Nothing visible, until the pin moved. Measured on fix/1513-teardown-twin-and-pin at core
73d94b55f, with the denominator, because a count without one cannot tell "these twelve broke" from
"the assembly did not load":
| suite | at core 73d94b55f (past #3661) |
after adapting |
|---|---|---|
MeshWeaver.PluginCatalog.Test (assembly, 726) |
714 / 726 — 12 failed | 727 / 727 |
SelfUpdateAvailabilityGateTest (class, 6) |
4 / 6 — 2 failed | 7 / 7, in 3 s instead of 63 |
MeshWeaver.Hosting.Monolith.Test (assembly, 806) |
— | 804 passed, 2 skipped, 0 failed |
CI shards that second assembly, so its run reported the two failures as 328 / 330 — a slice, not
the assembly. Quote the denominator you actually measured; they are not the same number.
Both projects still built clean at the new pin — -c Release -warnaserror, 0 Warning(s),
0 Error(s). That is the point: this is AGENTS.md's shape 7, a method's behaviour changing
behind an unchanged signature. No surface detector can see it by construction, the
Cross-repo pair (public surface) gate looks for removed types and members and nothing was
removed, and for a dependent that PINS core not even the release-event partial cover fires — the
platform-ref job resolves the same pin on a release event as on a pull request.
So the pin bump IS the integration test. For a repo on a pinned platform there is no earlier signal, and there is no
Dependent suitescheck anywhere that would have produced one. A red on the bump is that test firing, not a broken bump — and the answer is never to hold the pin, because the staleness arm ofValidate node reposmeasures both frozen inputs against coremainand turns every open pull request in the repo red once either passes 24 h or 120 commits.
The one judgement each red test needs
Every failure falls in exactly one of two buckets, and mixing them is the expensive mistake:
- It asserts the behaviour the policy replaced. Rewrite it to the new contract — and keep it asserting something. "Advisory" has two halves: the decision no longer refuses, and the claim is still produced. A test rewritten into a bare "it loads" has lost half its subject and would pass equally against an implementation that had stopped reading the floor at all. Every rewrite here asserts the advisory's presence and its absence on the satisfied side.
- It asserts something still valid and broke for another reason. Then it is a regression and the fix is not in the test.
The authority for bucket 1 is the policy page's list of decision points plus core's own reference
suites (ModuleFloorAdvisoryTest, ModuleFloorAdvisoryFunnelTest). A failure that does not map
onto one of those decision points is the signal for bucket 2. Guessing wrong cements a regression
that no gate can see.
A red that is neither, exactly: the fixture stops producing the state
SelfUpdateAvailabilityGateTest's two failures were 30-second timeouts, which is not the shape
of an assertion about a floor, and they deserved the suspicion they got. The cause was neither a
stale assertion nor a regression:
- the class's
SteerableGatebuilt its "held" verdict from a package with no sealed content bake — a blocker under the old rules; - #3651 made that an advisory, so the verdict came back
IsUpdatable = true; - the poller correctly rolled the candidate (
[SelfUpdate] rolling 9999.0.0-ci.1in the test's own log — the positive evidence, not an inference from the timeout); - nothing ever wrote a hold, so
Where(IsHeld).FirstAsync().Timeout(30 s)waited out its bound.
The subject — a held release is not rolled, and the hold is written where the Updates tab reads
it — was untouched, and every assertion stayed. What had to move was the fixture: the verdicts are
now built under new ReleaseGatePolicy(RequirePrebuilt: true), the supported strict mode in which a
missing bake is still a hold. What a hold is made of is ReleaseAvailabilityTest's subject;
what the poller does with one is this class's, and the policy change does not touch it.
🚨 A fixture that quietly stops producing the state under test reads as a hang, not as a cause.
That is why the class now carries a positive control on the seam itself
(TheSeamsTwoVerdicts_AreActuallyAHoldAndAPass): it goes red in milliseconds, naming the reason,
where the two real tests could only wait.
The floor's new answers, per decision point
Every one of these used to refuse, hold or skip; each now proceeds and says what was declared.
| decision point | old answer | new answer |
|---|---|---|
ModuleUpdateDecision.Decide |
SkipPlatformBelowFloor |
Land, floor worded into Reason |
PluginBundleClient.LandFromBundle |
landed 0, nothing on disk | lands, floor recorded on the entry |
ModuleLandingService.LandModule |
InvalidOperationException |
lands; only the link probe refuses |
ModuleLandingService.ShelveModule |
Held, no PendingRestart |
ordinary landing, PendingRestart true |
ModuleActivationBoot.ComputeEffectiveModuleEntries |
skipped, loudly | effective, announced on onAdvisory |
ModuleActivationStatus.NotYetLoaded / Unresolvable |
excluded as HELD | bucketed by its BYTES, Advisory set |
ReleaseAvailability.IsUpdatable |
ModuleFloorExceedsTarget blocker |
IsUpdatable, floor on Advisories |
ReleaseAvailability (no sealed bake) |
ContentBakeMissing blocker |
advisory + BootCompiles, unless RequirePrebuilt |
ModuleFloorExceedsTarget still exists as an enum member — binary API for hosts compiled against
the previous platform — and nothing produces it. A test asserting that kind is asserting a dead
branch.
The floor did not become worthless: it is recorded on the activation entry, carried onto status
rows as "declares platform ≥ X; running Y", and still gated at pack time by
check-module-floors.py / check-module-platform-floor.py, where a module declaring a floor above
the platform it was built against remains an authoring error.
Reading this page as a recipe
When a pin bump reddens a suite here:
- Re-measure with the denominator, both pins, same tree. "12 failed" is not a measurement; "12 of 726 failed, 726 of 726 at the old pin" is. A count with no denominator cannot distinguish "these twelve broke" from "the assembly did not load".
- Read the core change's own doc page and reference tests before touching an assertion. The policy page enumerates the decision points; if a failure does not map onto one, stop.
- Prefer the positive evidence in the failing test's own output over inferring a cause from the failure shape. A timeout says only "the condition never came"; the log line above it said which branch actually ran.
- Batch, then push once. A push to a pull request here buys a ~105-job run; a guaranteed-red run is waste, not information.
See also
Doc/Architecture/ModuleAdoptionPolicy(core) — the three rules and the four implementing steps.- Hosting/PlatformSourcePin — the seventh pin, and what a stale one delays.
- Hosting/PlatformBuilds — the six image pins that move as one set.
- Hosting/PlatformResolution — no pins since 2026-09-09: the newest sealed set, resolved per run.