Dependency Major Upgrades

The standing directive is to run the latest version of every library, at minimum current within the minor line. A major boundary is the exception that needs a decision and a method, because it is the one place where the compiler stops being a witness.

This page is the method and the ledger. Dependency Licensing covers which licences may be adopted at all; this page covers how to cross a boundary once the licence is clear.

Why a major is different here

A clean dotnet build -c Release -warnaserror is the normal standard of proof in this repository. For a major bump it is close to worthless, for five independent reasons:

What a green build cannot see Why
In-mesh source Every .cs stored in a mesh node β€” NodeType Source/*.cs, Scripts, layout areas β€” compiles at RUNTIME in the portal. A NodeType's configuration lambda is C# inside a JSON string, invisible even to grep --include='*.cs'.
Authored CONTENT A parser bump changes how data already in the tree is read. Nothing compiles that data, so nothing fails when it starts parsing differently.
The satellite repositories MeshWeaver.Plugins has no Directory.Packages.props of its own. Its src/Directory.Packages.props IMPORTS core's, at the commit named by MW_PLATFORM_REF. A one-line edit here therefore chooses what another repository compiles against β€” and core CI is green by construction, because nothing here references what broke.
Behaviour behind an unchanged signature The shape no surface detector can see. The method below attacks it with a corpus and with the library's own compatibility baseline, not with a diff.
Rules an ANALYZER would have enforced A new major can ship analyzers β€” and your build may never load them (folder convention, ExcludeAssets, PrivateAssets). Then the build is green about nothing: the rules were never evaluated, and the green reads exactly like a pass. Rx 7 is a live instance; see step 6 and the ledger.

🚨 The Satellite package pins (removal declared) gate does not cover this. It fires on a PackageVersion being removed, not bumped β€” a bump keeps the Include and so never trips it. The satellite reads a bump only when somebody moves its pin, on another day, in another pull request.

The method

Six steps, in order. Each produces a positive artefact; none of them can pass by returning nothing.

1. Measure the premise against the live registry

Never adopt a target version asserted by an earlier session, an issue, or a memory. Ask nuget.org, and gate the read on HTTP status AND byte count β€” a failed request's empty body greps to zero and reads exactly like "no such version".

🚨 PRINTING the status and the byte count is not GATING on them. Plain curl -sS -o file -w … exits 0 on an HTTP 404, writes the error body into the output file, and prints a tidy HTTP=404 BYTES=14 that a hurried reader skims as evidence. That is this repository's own "a verification step that cannot fail is not a verification step" rule, violated in the one place it is being taught. Make the command refuse:

curl -sS --fail-with-body -o idx.json \
     -w "HTTP=%{http_code} BYTES=%{size_download}\n" \
     "https://api.nuget.org/v3-flatcontainer/<package-id-lowercased>/index.json" \
  || { echo "registry read FAILED β€” premise unmeasured, do not proceed"; exit 1; }
[ -s idx.json ] || { echo "registry read returned an EMPTY body β€” do not proceed"; exit 1; }

--fail-with-body turns a 4xx/5xx into a non-zero exit while still keeping the body to look at, and the -s test refuses a zero-byte success. Measured while writing this page: a guessed path for Rx's suppression file answered HTTP 404, 14 bytes, and a grep -c over that file scores 0 β€” which, unguarded, reads as "zero suppressions, all clear".

2. Read the licence at the NEW major, not at the old one

A licence can change at a major boundary β€” that is exactly how JsonPatch.Net 5.0.0 moved json-everything's published binaries to a maintenance fee, which is why the Aspire family is pinned. Compare the <license> element of both nuspecs; a change is a stop-and-report, never a call to make alone.

curl -sS --fail-with-body -o new.nuspec \
     -w "HTTP=%{http_code} BYTES=%{size_download}\n" \
     "https://api.nuget.org/v3-flatcontainer/<id>/<new>/<id>.nuspec" \
  || { echo "nuspec read FAILED β€” licence unread, do not proceed"; exit 1; }
[ -s new.nuspec ] || { echo "empty nuspec β€” do not proceed"; exit 1; }

🚨 The same trap as step 1, and worse here: an unguarded grep '<license' over a 404 body finds nothing, and "no licence element" is easy to misread as "no licence change".

🚨 The Dependency licences gate is a second opinion, not the first β€” and its count is a DENOMINATOR you have to read. check-licenses.py resolves licences from the restored dependency graph in the local NuGet cache, so the "N shipping packages checked" it prints is however much this worktree happened to restore. Run it after a full solution restore and it reports 257 here; run it in a worktree that restored three projects and it reports a fraction of that and still says "passed". Quote the number with the claim, and treat a small one as a partial sweep rather than a clean one. It is also no substitute for reading the two nuspecs yourself: the gate answers "is every restored package's licence acceptable", never "did THIS package's licence change at THIS boundary".

3. Diff the real public API, don't read the release notes alone

Release notes are a summary written by someone who was not thinking about this codebase β€” and they can be actively misleading. Rx 7.0.0, 7.0.0-rc.1 and 7.0.0-preview.20 ship byte-identical release bodies, an auto-generated cumulative changelog reaching back to 2021; most items people cite as "new in Rx 7" actually shipped in 6.1.0.

So download both .nupkgs, extract the assembly for the TFM we actually resolve, and enumerate the public surface of each with MetadataLoadContext. Sort, normalise away the assembly-qualified version stamps (they otherwise dominate the diff), and comm the two lists.

The removal set matters first: check it against what this repository and the satellites actually call.

🚨 But "additions cannot break a consumer" is FALSE, and believing it is how a major audit misses an entire break class. Additions cannot break a caller. They routinely break an implementer: a member added to an interface with no default implementation, or an abstract member added to an abstract class, breaks every outside type that implements it β€” CS0535 at the dependent's next pin move, long after this repository went green. This page's own YamlDotNet entry is an instance: 18.0.0 added HasParseMethod and Parse to ITypeInspector with no default implementation, and that is the single declared breaking change of that major. It missed us only because neither repository implements the interface.

So the addition set is not dismissible β€” partition it. Additions to static types, to sealed types, and new overloads are caller-only and safe to skim. Additions of abstract or default-less members to a type an outside assembly may implement are removals in disguise, and get the same treatment: enumerate them, then search both repositories for implementers. (Core carries the mirror-image obligation on its own surface β€” the Interface additions (implementers declared) gate β€” for exactly this reason.) One more caller-side trap that an addition can cause here: a new overload can make a dependent's <see cref> ambiguous, CS0419 under -warnaserror.

🚨 Filter on accessor visibility, or the tool will invent removals. Type.GetMembers with BindingFlags.NonPublic returns non-public properties too, and a naive dump lists them. Doing exactly that on Rx produced one phantom removal β€” CurrentThreadScheduler.Time, a property with no public or protected accessor, which no consumer could ever have called β€” out of 138 such non-public entries. Excluding them, the true removal count was zero. A removal you cannot call is not a removal.

4. Ask the library whether IT thinks it broke

Cheaper and stronger than inference, and routinely skipped. A package that sets EnablePackageValidation with a PackageValidationBaselineVersion carries a machine-generated list of every difference against that baseline, with a suppression entry for each one the maintainers accepted. Read the suppressions: if none of them is a behavioural or core-API break, that is the maintainers' own statement that the release is a packaging release.

🚨 Read them per-TFM, and never summarise the list in aggregate. Each entry carries a Left and a Right naming the two assets compared, and a suppression list mixes baseline-vs-new comparisons with cross-TFM ones inside the same package. Rx's 57 read as "all UI omissions" in aggregate, but six of them name core types (ThreadPoolScheduler, NotificationKind) β€” and those six turn out to compare netstandard2.0 against uap10.0.18362, TFMs this repo never resolves. The question is never "is the list clean", it is "does any entry name the asset pair I actually resolve" β€” which project.assets.json tells you exactly (compile and runtime per package, per target).

Also check the ADRs: a change that would alter semantics but is still Status: Proposed has not shipped, however much the release notes discuss it.

5. Run the change against the CORPUS, not only the suite

This is the step that finds behaviour changes behind unchanged signatures, and it is the one most often skipped. It applies whenever the library processes artefacts we store β€” a parser, a serializer, a formatter.

Build the same probe program twice β€” identical source, two project files differing only in the pinned version β€” configured exactly as production configures the library. Run both over every artefact in the tree the library actually processes, and emit one stable, hashed line per artefact. Then diff the two outputs. A zero-line diff over a few thousand real inputs is a far stronger statement than any suite.

🚨 Control the instrument before believing its zero. A probe that cannot report a difference reports zero for the wrong reason. Two checks, both cheap:

The same discipline applies to a sweep for a removed symbol: state the denominator. "Zero hits" means nothing without "…out of N files that contain the thing I was looking near".

6. Build and run the full suites, and never re-run a red

Build every touched project and its dependents -c Release -warnaserror, one project per invocation, and read a literal 0 Error(s) plus an elapsed time that makes sense β€” a two-second "Build succeeded" is an up-to-date no-op, not a build. Confirm the new assembly actually landed next to the test binary, because a props edit that did not take reads identically to one that did β€” and prefer hashing it against the downloaded .nupkg's asset to reading a version string, since the hash also tells you which TFM asset you resolved.

🚨 If the new major ships an ANALYZER, prove your build actually runs it before you read the green as evidence. A package can ship analyzers your compilation never loads β€” wrong folder convention, ExcludeAssets, a PrivateAssets default β€” and then a clean -warnaserror build says nothing whatever about the rules that analyzer enforces. dotnet build <proj> -getItem:Analyzer lists what csc is actually handed; demand a positive control, because an empty list means "the instrument is broken" just as readily as "no analyzer". If it turns out inert, say so and make the real argument separately from source β€” do not let an unrelated green stand in for it. Rx 7 is exactly this case (see the ledger), and the mistake is one-directional: it reads as reassurance.

Then run the full suites, not a filter. A major bump is exactly where a filtered run lies.

🚨 Prefer the test executable to dotnet test for the long suites. These are xUnit v3 executables; dotnet test drives them through the VSTest bridge, and on the longer suites here that bridge has been seen to die with SIGABRT after ~70 s, print no results at all, and still exit 0 β€” a textbook false pass. Running test/<Project>/bin/Release/net10.0/<Project> directly prints an explicit === TEST EXECUTION SUMMARY === with totals, which is the positive signal to demand.

And a red after a major bump is a real behavioural change until proven otherwise β€” that is the entire point of the exercise, so it is investigated, never re-run.

Investigated means EXONERATED BY MEASUREMENT, not argued away. Two discriminators do almost all the work, and both give a positive answer rather than an absence:

That last point is the trap worth naming: a test-infrastructure guard can be armed only on CI, so a green local run is not a control at all. MonolithMeshTestBase carries a memory watchdog that FailFasts the host when process RSS crosses 6 GiB, and it does so only when CI or GITHUB_ACTIONS is set β€” on a dev machine it logs the breach and keeps going, by design. The resulting CI signal is exit=134 SIGNAL SIGABRT plus a synthetic <Project>.HOST_CRASHED trx entry, which reads exactly like a native crash and invites a hunt for a disposal or scheduler bug that is not there. The FailFast message names the real cause (a cumulative Autofac Reflection.Emit factory leak from non-shared test classes), and the threshold is fixed, so a shard sitting near it crosses on run-to-run noise. Read the message before believing the signal shape.

Ledger

System.Reactive 6.1.0 β†’ 7.0.0 β€” a packaging release, and a SPLIT FAMILY it closed

Measured 2026-09-11.

Step Result
Registry https://api.nuget.org/v3-flatcontainer/system.reactive/index.json β†’ HTTP 200, 974 B. Newest stable 7.0.0; no 7.0.1 exists.
Licence MIT at both ends, unchanged. Assembly identity holds too: public key token 94bc3704cddfc263 unchanged, only AssemblyVersion moves 6.1.0.0 β†’ 7.0.0.0.
API removals ZERO public or protected members, once non-public entries are excluded (step 3) β€” on both asset pairs this repo actually resolves, read straight off project.assets.json: compile lib/net6.0 β†’ ref/net8.0 (0 removals) and runtime lib/net6.0 β†’ lib/net8.0 (0 removals, 16 additions). The 16 are ObservableEx.CombineLatest/Zip overloads for 9–16 sources, on a type nothing here references. The probe excluded 138 non-public-accessor properties on each side β€” the exact population that manufactures phantom removals.
API additions (the implementer half of step 3) Nothing implementer-obliging. All 16 additions are methods on ONE type, System.Reactive.Linq.ObservableEx, which is static (69 methods, no constructor) β€” so they are caller-only overloads. Zero public types were added, and no member was added to any interface or abstract class, so there is no CS0535 shape for a satellite to meet at its next pin move.
Surface we rely on System.Reactive.Linq.Observable is byte-identical at 656 members; every Subject/ReplaySubject/BehaviorSubject/AsyncSubject, every System.Reactive.Disposables type and every scheduler are member-identical.
Upstream's own verdict ApiCompatSuppressions.xml at tag rxnet-v7.0.0 (HTTP 200, 22,564 B) carries 57 entries: 51 CP0001 (40 naming Windows-UI/WinRT types, 11 AsyncInfoObservable/IEventPatternSource), 5 CP0008 and 1 CP0002. 🚨 The six non-CP0001 ones DO name core types (ThreadPoolScheduler, NotificationKind), so "all are UI omissions" would be wrong β€” but every one of them is a cross-TFM comparison against netstandard2.0 or uap10.0.18362, and none involves the net8.0 pair this repo resolves. Both types, and the CP0002 member, are present in ref/net8.0 and lib/net8.0 alike.
In-mesh sweep 31 of 638 in-mesh artefacts reference Rx β€” 30 of 93 .cs node sources, and 1 of 545 node .json (samples/Graph/Data/Northwind/AnalyticsCatalog.json, Rx inside escaped source). None compiles in CI. Since the removal set on the resolved assets is empty, there is nothing for any of them to have used.
Suites 15 suites, 6,944 tests, 0 errors, 0 failures (2 skipped), run as the test executables against the merged tree: Graph 1545 Β· Memex.Portal.Shared 1293 Β· Compiler.Pipeline 859 Β· Hosting 619 Β· Data 508 Β· Layout 496 Β· Messaging.Hub 409 Β· Documentation 397 Β· PluginTester 362 Β· Hosting.Orleans 259 Β· ContainerImages 112 Β· ContentCollections 28 Β· Testing.Xunit 23 Β· Cli 19 Β· Deployment.Contract 15. (MeshWeaver.Security.MeshTest is not a suite β€” OutputType=Library, zero [Fact]/[Theory].)
The one CI red, and how it was cleared Shard 2 aborted MeshWeaver.Compiler.Pipeline.Test with exit=134 SIGNAL SIGABRT and a synthetic HOST_CRASHED trx entry β€” the CI-only MonolithMeshTestBase memory watchdog FailFasting at 6,256 MiB observed / 6,144 MiB threshold, 1.8% over, naming the Autofac Reflection.Emit leak as the cause. Exonerated by two positive measurements, not by re-running: (a) that suite references Microsoft.Reactive.Testing, so it already resolved Rx 7.0.0 on the merge base β€” proven by hashing the System.Reactive.dll beside the control build, e8070137…, identical to the 7.0.0 lib/net8.0 asset β€” meaning the bump changes nothing it runs; (b) an A/B of peak RSS on one machine: merge base 1,843 MiB, this branch 1,616 MiB, i.e. lower.

The defect this bump actually fixed was not a version being old. Microsoft.Reactive.Testing was already pinned at 7.0.0 while System.Reactive read 6.1.0. The testing package depends on System.Reactive >= 7.0.0 (its nuspec: <dependency id="System.Reactive" version="7.0.0" …/>), and with CentralPackageTransitivePinningEnabled unset β€” it appears in Directory.Packages.props only in prose, never as a property β€” the higher version wins, but only in the projects that reference the testing package. Exactly 8 test projects do. Everything else resolved 6.1.0. After the change, all 52 Rx-resolving projects in the tree report System.Reactive/7.0.0 and none reports 6.1.0.

So the suite that gates the platform was exercising a different Rx major from the one the portals shipped, and nothing anywhere was red. That is the one skew a test suite must never have, and no control could see it: PackageFamiliesMoveTogetherGuard was keyed on a shared prefix, and System.Reactive and Microsoft.Reactive.Testing share none. The guard now carries a second shape, NamedFamilies, that lists a family's members explicitly β€” with its own negative control, because a guard that has never been seen to fail is not a guard.

Where the real change is, and why an API diff alone would miss it. Rx 7 removed the Windows UI surface β€” DispatcherScheduler, ControlScheduler, CoreDispatcherScheduler, ControlObservable, DispatcherObservable, WindowsObservable and friends β€” from the reference assemblies while keeping them in lib. That is source-breaking, not binary-breaking, and it is invisible to a diff taken against lib. Those types now live in the separate System.Reactive.Wpf, System.Reactive.Windows.Forms, System.Reactive.WindowsRuntime and System.Reactive.Uwp packages.

🚨 It is confined to the WINDOWS target frameworks, and that is why the diff above still reads zero. Those symbols only ever existed in the Windows-flavoured assets β€” measured by raw presence: DispatcherScheduler is in 7.0.0's lib/net472, lib/net8.0-windows10.0.19041 and lib/uap10.0.18362 and in none of its ref/ assemblies, and it is in no net6.0/net8.0 asset of either version. This repo resolves the non-Windows net8.0 pair and declares no *-windows* TFM, so the source break cannot reach it. Read the removal count without that qualification and you would conclude either that the diff was broken or that the break was ours. (The nine pre-6.x facades β€” System.Reactive.Core, .Linq, .Interfaces, .PlatformServices, .Providers, .Experimental, .Compatibility, .Runtime.Remoting, .Windows.Threading β€” are frozen at 6.1.0; no 7.0.0 of them exists. We reference none.)

🚨 The package now ships a Roslyn analyzer β€” and it is INERT here, which is not the same as passing. Rx 7 adds analyzers/dotnet/System.Reactive.Analyzers.dll (6.1.0 ships none), emitting RXNET0001–RXNET0004 at Warning, which -warnaserror would promote to errors. It is the single most likely thing to bite a warnings-as-errors build, so it was checked directly rather than inferred from the build being green β€” and the check says csc never loads it. In project.assets.json the analyzer appears only in the libraries file manifest, never in a targets entry, and dotnet build <proj> -getItem:Analyzer on a project that references Rx returns zero Rx analyzer entries while still returning the .NET analyzers, so the instrument discriminates. The package places the assembly directly under analyzers/dotnet/ with no language subfolder, which is not where NuGet selects a C# analyzer from.

So a clean -warnaserror build is NOT evidence that this codebase satisfies RXNET0001–0004. The evidence for that is separate and is a source sweep: the repository declares no Windows-flavoured target framework at all (zero *-windows* TFMs in any .csproj or .props), and references none of the Windows UI types the four rules fire on (0 hits), swept against a positive control of the 581 .cs files under src/ and test/ that do contain IObservable β€” so the sweep is not looking at an empty corpus. No pre-6.x Rx facade package is referenced either. Both facts are needed; neither substitutes for the other. If upstream ever fixes the analyzer's path, the rules become live for every consumer at once β€” and a satellite that grows a WPF or WinForms surface would meet them as build errors.

Behaviour: no change found. The four things that would be correctness changes here β€” scheduler defaults, ObserveOn/SubscribeOn semantics, error-propagation timing, and completion/disposal ordering β€” are unchanged, and that matters because this codebase's action block is an error boundary and a disposal fence, so a shift in when Rx signals disposal would be a correctness change rather than a performance note. Every item in the Rx 7.0 milestone is packaging, analyzers, tooling, docs, or the annotation below. Two things often attributed to Rx 7 in fact shipped in 6.1.0 and were already in force here: the RefCount fixes and the opt-in ResetExceptionDispatchState. And ADR 0004 (Rx.NET/Documentation/adr/0004-onerror-to-throw.md at tag rxnet-v7.0.0, HTTP 200, 14,436 B), which would change when OnError becomes a thrown exception, reads Status: Proposed β€” that is, not implemented.

🚨 All of that is still upstream's word about itself, so pair it with OUR OWN observation of the property that matters. MeshWeaver.Messaging.Hub.Test carries 40 [Fact]/[Theory] tests across 31 files whose whole subject is disposal, teardown, quiescing, shutdown admission and turn/backlog ordering β€” RoutedDisposeRequest_Announces_Once_AndBeforeTheTeardownStarts, GateOpenMustNotLetARunningTurnOvertakeTheBacklog, InitializationStopsAtTeardownStart, ShutdownWindowAdmission, the DisposalStall*/DisposalRace* family, and the rest. Every one passes on Rx 7 (that suite: 409/409). That is the closest thing to a direct measurement of "completion and disposal ordering did not shift" that this repository can make, and it is the check to repeat on any future Rx boundary β€” a suite total alone would not have told you the ordering tests were even in it.

The single compile-surface change is a nullability annotation: OfType<TResult>(this IObservable<object>) became IObservable<object?>. It widens what is accepted, so it cannot break a caller that compiled before.

Target frameworks. Rx 7 drops the net6.0 asset and adds ref/ assemblies (6.1.0 had none), so the compile surface and the runtime surface are now genuinely different artefacts β€” which is exactly why the UI removal above is invisible to a lib-only diff. netstandard2.0 is still shipped, so a net6/net7 consumer still resolves something; it is simply unsupported. We are on net10.0 and resolve net8.0.

One trap worth knowing, in the sibling product. Ix.NET's System.Linq.Async v7 removed its LINQ implementation from the reference assemblies in favour of .NET 10's own System.Linq.AsyncEnumerable. A project still on System.Linq.Async v6 that moves to .NET 10 gets ambiguity errors from two LINQ implementations. We pin System.Interactive 7.0.1, so this does not reach us β€” but it is the shape to check if an Ix reference is ever added.

What this bump changes for MeshWeaver.Plugins. MeshWeaver.Blazor, MeshWeaver.Blazor.Views, MeshWeaver.InstanceSync and MeshWeaver.LogWatcher reference System.Reactive versionless off this repo's list, and three test projects reference Microsoft.Reactive.Testing the same way. They move to Rx 7 when the satellite's MW_PLATFORM_REF next moves. Blazor Server is not a Windows-UI framework in the analyzer's sense, so RXNET0001–0004 do not fire there either β€” verified by the same sweep.

YamlDotNet 16.3.0 β†’ 18.1.0 β€” two majors, zero adaptation

Measured 2026-09-11.

Step Result
Registry …/v3-flatcontainer/yamldotnet/index.json β†’ HTTP 200, 5,860 B. Newest stable 18.1.0; 270 versions listed.
Licence MIT at both ends β€” <license type="expression">MIT</license> in the 16.3.0 and 18.1.0 nuspecs alike. No change.
API removals 6 public members across both majors: Accepts(Type) on the five built-in date/time converters (refactored onto the new ScalarConverterBase<T>, so still present by inheritance), and YamlDotNet.Core.Tokens.Scalar.IsKey. IYamlTypeConverter is byte-identical between the two.
Our usage Six symbols total β€” DeserializerBuilder, SerializerBuilder, IDeserializer, ISerializer, DefaultValuesHandling, YamlException β€” all present in both. Zero custom ITypeInspector, IYamlTypeConverter, INodeDeserializer or TypeInspectorSkeleton implementations in either repository.
Corpus 1,817 front matters across core and MeshWeaver.Plugins parsed and re-serialised by both versions: 0 differing lines. 1,799 parsed OK with identical value hashes and identical re-emit hashes; the same 18 files raised the same SemanticErrorException on both.
Suites MeshWeaver.Documentation.Test 390 passed / 0 failed; MeshWeaver.Hosting.Test full suite green.

The two declared breaking changes both miss us, and it is worth recording why β€” each would have been invisible in a build:

One adjacent change was checked and does not reach us: 17.1.0 gave MergingParser a default cap of 100k events. Neither repository constructs a MergingParser.

What this bump changes for MeshWeaver.Plugins. src/MeshWeaver.AI (skill and agent front matter) and src/MeshWeaver.Publish (slide front matter) carry versionless YamlDotNet references resolved from core's list, so both move to 18.1.0 when the satellite's MW_PLATFORM_REF next moves. Both were included in the 1,817-file corpus run above, and neither implements an extension point that 18.0.0 broke.

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