Egil.SystemTextJson.Migration 2.0.47

There is a newer version of this package available.
See the version list below for details.
dotnet add package Egil.SystemTextJson.Migration --version 2.0.47
                    
NuGet\Install-Package Egil.SystemTextJson.Migration -Version 2.0.47
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Egil.SystemTextJson.Migration" Version="2.0.47" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Egil.SystemTextJson.Migration" Version="2.0.47" />
                    
Directory.Packages.props
<PackageReference Include="Egil.SystemTextJson.Migration" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Egil.SystemTextJson.Migration --version 2.0.47
                    
#r "nuget: Egil.SystemTextJson.Migration, 2.0.47"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Egil.SystemTextJson.Migration@2.0.47
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Egil.SystemTextJson.Migration&version=2.0.47
                    
Install as a Cake Addin
#tool nuget:?package=Egil.SystemTextJson.Migration&version=2.0.47
                    
Install as a Cake Tool

Egil.SystemTextJson.Migration

Version-tolerant JSON migration for System.Text.Json.

When data models evolve, old JSON payloads still exist — in databases, caches, queues, and on disk. This library migrates those payloads to the current type automatically during deserialization, so application code never deals with obsolete shapes.

Key characteristics:

  • Little to no overhead for normal-sized payloads — the medium source-generated happy-path profile benchmarks close to plain System.Text.Json throughput with zero extra library allocations.
  • O(1) discriminator check — only the first JSON property is inspected to determine the payload version.
  • Source-generation friendly — works with source-generated JsonSerializerContext and registers through the resolver chain, so types outside migration keep STJ's generated fast-path serializers. Trimmed/NativeAOT publishing is not supported yet; see the AOT recipe.
  • C# unions (.NET 11) — a union of [JsonMigratable] cases is classified by migration discriminator, so old and current payloads route to the case that migrates them. See the polymorphism & unions recipe.
  • Two migration styles — static (target-owned) via IMigrateFrom<TSource, TTarget>, or external (separate class) via IMigrate<TSource, TTarget> with optional dependency injection.
  • Nested migration — migratable child types inside migratable parents are migrated recursively.
  • Migration tracking — types can implement IJsonMigrationTracked to know whether they were migrated.
  • Configurable failure handling — choose between throwing, falling back to the target type, or returning null when a migrator cannot convert a payload.

Upgrading from 1.x? No API changed; the behavior changes and the checks to run are in Upgrading to 2.0.

📖 Looking for more? See the Recipes for 39 scenario-driven guides covering nested objects, collections, DI, source generation, failure handling, ASP.NET Core, Orleans, telemetry, and more.

Examples

The examples below use these shared types as a running scenario — a User type whose schema has changed between versions:

// The old shape. Marked [JsonMigratable] so the library writes
// a type discriminator during serialization and recognizes it
// during deserialization.
[JsonMigratable(TypeDiscriminator = "user-v1")]
public record UserV1(string Name, int Age);

// The current shape.
[JsonMigratable(TypeDiscriminator = "user-v2")]
public record UserV2(string FirstName, string LastName, int Age);

Choosing a migration contract

Use the contract that matches where the migration logic lives:

Scenario Interface Registration
The current [JsonMigratable] target type owns the migration logic IMigrateFrom<TSource, TTarget> No RegisterMigrator* call. The target type's contracts are discovered automatically when its converter is created.
A separate external class owns the migration logic IMigrate<TSource, TTarget> Register the migrator with RegisterMigrator* or RegisterMigratorsFrom*.

Do not implement IMigrate<TSource, TTarget> directly on a [JsonMigratable] type. That interface is reserved for separate external migrator classes.

Static migration

When the migration logic naturally belongs on the target type, implement IMigrateFrom directly. These target-owned migrations are discovered automatically; no RegisterMigrator* or RegisterMigratorsFrom* call is needed:

[JsonMigratable(TypeDiscriminator = "user-v2")]
public record UserV2(string FirstName, string LastName, int Age)
    : IMigrateFrom<UserV1, UserV2>
{
    public static bool TryMigrateFrom(UserV1 source, out UserV2 result)
    {
        var names = source.Name.Split(' ');
        result = new UserV2(names[0], names.ElementAtOrDefault(1) ?? "", source.Age);
        return true;
    }
}

Enable migration support on the serializer options and deserialize as usual:

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport();

// A UserV1 payload is automatically migrated to UserV2:
var json = """{"$type":"user-v1","name":"Jane Doe","age":30}""";
UserV2 user = JsonSerializer.Deserialize<UserV2>(json, options)!;
// user is UserV2 { FirstName = "Jane", LastName = "Doe", Age = 30 }

Brownfield adoption

Existing projects can adopt migration support one type at a time:

  • No shape change needed: add [JsonMigratable] to the current type and enable AddJsonMigrationSupport(). Existing discriminator-less object payloads that already match the current type continue to deserialize as that type; new writes include $type, so future reads use the normal happy path.
  • Existing payloads need migration: if stored JSON was written before [JsonMigratable] existed and represents an older object shape, set UndiscriminatedSourceType on the current type and provide a static or external migrator from that source type. The library then treats discriminator-less object payloads as that source shape, while discriminator-bearing payloads still use normal version matching.

When stored JSON represents an older source shape, configure the target with UndiscriminatedSourceType:

[JsonMigratable(
    TypeDiscriminator = "customer-name-v1",
    UndiscriminatedSourceType = typeof(CustomerNameV0))]
public record class CustomerNameV1(string Name)
    : IMigrateFrom<CustomerNameV0, CustomerNameV1>
{
    public static bool TryMigrateFrom(CustomerNameV0 source, out CustomerNameV1 result)
    {
        result = new CustomerNameV1($"{source.FirstName} {source.LastName}");
        return true;
    }
}
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport();

// Existing stored JSON was written before migration support existed,
// so it has no $type discriminator. CustomerNameV1 opts in to treating
// discriminator-less objects as CustomerNameV0 and runs its migrator.
var json = """{"firstName":"Jane","lastName":"Doe"}""";

CustomerNameV1 customer = JsonSerializer.Deserialize<CustomerNameV1>(json, options)!;
// customer is CustomerNameV1 { Name = "Jane Doe" }

UndiscriminatedSourceType is intentionally one source type per target. If multiple historical object shapes exist without discriminators, choose the one that represents the stored brownfield payloads you need to migrate.

External migration

When migration logic should live in its own separate class — for separation of concerns, testability, dependency injection, or because you don't control the target type — implement IMigrate and register it:

public class UserMigrator : IMigrate<UserV1, UserV2>
{
    public bool TryMigrateFrom(UserV1 source, out UserV2 result)
    {
        var names = source.Name.Split(' ');
        result = new UserV2(names[0], names.ElementAtOrDefault(1) ?? "", source.Age);
        return true;
    }
}
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport(builder =>
{
    builder.RegisterMigrator<UserMigrator>();
});

Multi-step migration chains

A target type can accept payloads from multiple older versions. Each source version has its own migration path:

[JsonMigratable(TypeDiscriminator = "user-v0")]
public record UserV0(string FullName);

[JsonMigratable(TypeDiscriminator = "user-v1")]
public record UserV1(string Name, int Age);

[JsonMigratable(TypeDiscriminator = "user-v2")]
public record UserV2(string FirstName, string LastName, int Age)
    : IMigrateFrom<UserV0, UserV2>,
      IMigrateFrom<UserV1, UserV2>
{
    public static bool TryMigrateFrom(UserV0 source, out UserV2 result)
    {
        var names = source.FullName.Split(' ');
        result = new UserV2(names[0], names.ElementAtOrDefault(1) ?? "", 0);
        return true;
    }

    public static bool TryMigrateFrom(UserV1 source, out UserV2 result)
    {
        var names = source.Name.Split(' ');
        result = new UserV2(names[0], names.ElementAtOrDefault(1) ?? "", source.Age);
        return true;
    }
}

Migrating from non-object JSON payloads

When the stored JSON is not an object — for example, a plain array or a primitive — the library can migrate it to a structured target type. The source type does not need [JsonMigratable]:

// The target type accepts a List<string> as its source.
// The source type (List<string>) is NOT marked with [JsonMigratable]
// — it's a plain .NET collection whose JSON representation is an array.
[JsonMigratable(TypeDiscriminator = "settings-v2")]
public record SettingsV2(List<string> Tags, string Label)
    : IMigrateFrom<List<string>, SettingsV2>
{
    public static bool TryMigrateFrom(List<string> source, out SettingsV2 result)
    {
        result = new SettingsV2(source, "migrated");
        return true;
    }
}
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport();

// Stored JSON is a plain array — no $type, no object wrapper.
var json = """["csharp","dotnet","azure"]""";
SettingsV2 settings = JsonSerializer.Deserialize<SettingsV2>(json, options)!;
// settings.Tags = ["csharp", "dotnet", "azure"], settings.Label = "migrated"

After migration, the target type serializes as an object with $type, so future reads take the zero-allocation happy path. See the recipe for more examples including primitives and mixed migrators.

Dependency injection for migrators

Pass an IServiceProvider so external migrators can receive constructor-injected dependencies. The migrator is resolved from the service provider on each call, supporting scoped lifetimes:

var services = new ServiceCollection();
services.AddScoped<UserMigrator>();

using var serviceProvider = services.BuildServiceProvider();

// When building serializer options, pass the service provider:
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport(serviceProvider, builder =>
{
    builder.RegisterMigrator<UserMigrator>();
});

If no service provider is configured, the library falls back to creating the migrator via its parameterless constructor.

Assembly scanning

Register external IMigrate<,> migrator classes in one or more assemblies instead of listing each one. Assembly scanning is not required for target-owned IMigrateFrom<,> migrations:

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport(builder =>
{
    builder.RegisterMigratorsFromAssemblies(typeof(UserMigrator).Assembly);
});

Source-generated JsonSerializerContext

To avoid System.Text.Json's reflection-based metadata, register both old and current types in a source-generated context (this does not make trimmed or NativeAOT publishing supported; see the AOT recipe):

[JsonSerializable(typeof(UserV1))]
[JsonSerializable(typeof(UserV2))]
public partial class AppJsonContext : JsonSerializerContext;
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport();
options.TypeInfoResolverChain.Add(AppJsonContext.Default);

Migration tracking

Implement IJsonMigrationTracked on your type to detect at runtime whether a particular instance was migrated during deserialization. This is useful for deciding whether to write the value back in its updated form:

[JsonMigratable(TypeDiscriminator = "user-v2")]
public record UserV2(string FirstName, string LastName, int Age)
    : IJsonMigrationTracked, IMigrateFrom<UserV1, UserV2>
{
    [JsonIgnore]
    public bool MigratedDuringDeserialization { get; set; }

    public static bool TryMigrateFrom(UserV1 source, out UserV2 result)
    {
        var names = source.Name.Split(' ');
        result = new UserV2(names[0], names.ElementAtOrDefault(1) ?? "", source.Age);
        return true;
    }
}
// After deserialization:
var json = """{"$type":"user-v1","name":"Jane Doe","age":30}""";
UserV2 user = JsonSerializer.Deserialize<UserV2>(json, options)!;
if (user.MigratedDuringDeserialization)
{
    // Persist the updated representation so future reads
    // hit the happy path.
    // await SaveAsync(user);
}

Custom type discriminator

By default the library uses "$type" as the discriminator property name and the type's full name as its value. Both can be customized:

// Per-type via the attribute:
[JsonMigratable(
    TypeDiscriminator = "user-v2",
    TypeDiscriminatorPropertyName = "version")]
public record UserV2(string FirstName, string LastName, int Age);
// Or set a global default property name via the builder:
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport(builder =>
{
    builder.SetTypeDiscriminatorPropertyName("_schema");
});

You can also derive the discriminator value from an existing attribute on your types, keeping the library out of your domain model:

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport(builder =>
{
    builder.GetTypeDiscriminatorFrom<SchemaVersionAttribute>(
        attr => attr.Version);
});

Failure handling

Control what happens when a migrator's TryMigrateFrom returns false:

Policy Behavior
ThrowJsonException Throw a JsonException (default).
FallBackToTargetType Deserialize the payload directly as the target type.
ReturnNull Return null (only valid for nullable target types).

Set a global policy on the builder, or override per-type on the attribute:

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport(builder =>
{
    builder.SetMigrationFailureHandling(
        JsonMigrationFailureHandling.FallBackToTargetType);
});
// Per-type override:
[JsonMigratable(MigrationFailureHandling = JsonMigrationFailureHandling.ReturnNull)]
public record OptionalData(string Value);

Observability

The library emits an OpenTelemetry-compatible counter (stjm.migrations) via System.Diagnostics.Metrics. Each migration attempt records the source type, target type, and status (success / failure).

Subscribe to the meter in a console or test app:

// Subscribe to the migration meter using MeterListener:
using var meterListener = new MeterListener();
var migrationCount = 0L;
meterListener.InstrumentPublished = (instrument, listener) =>
{
    if (instrument.Meter.Name == JsonMigrationTelemetry.MeterName)
    {
        listener.EnableMeasurementEvents(instrument);
    }
};
meterListener.SetMeasurementEventCallback<long>(
    (instrument, measurement, tags, state) =>
    {
        if (instrument.Name == JsonMigrationTelemetry.MigrationCounterName)
        {
            Interlocked.Add(ref migrationCount, measurement);
        }
    });
meterListener.Start();

In ASP.NET Core, register the meter with OpenTelemetry:

builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics =>
    {
        metrics.AddMeter(JsonMigrationTelemetry.MeterName);
    });

Performance

Every benchmark compares the library against hand-written migration code on top of plain System.Text.Json. The small profile is a minimal { "name": "...", "age": ... } object that highlights worst-case fixed overhead; the medium profile is a best-guess average object with about 12 object members; and the large profile has about 96 object members spread across nested objects, arrays, and dictionary entries. See benchmark payload examples for representative JSON from each profile.

The generated table below is refreshed by ./scripts/update-perf-docs.ps1 from the latest source-generated BenchmarkDotNet report, produced with the net11.0 build of the benchmarks (the union dispatch scenario only exists there; every other scenario also runs on net10.0). It keeps BenchmarkDotNet's Ratio, RatioSD, and Alloc Ratio columns so README numbers stay tied to the raw benchmark output.

Scenario Method Payload size Mean Ratio RatioSD Allocated Alloc Ratio
No migration (happy path) Plain STJ Small 251.02 ns 1.00 0.01 160 B 1.00
JsonMigratable Small 383.56 ns 1.53 0.01 160 B 1.00
Plain STJ Medium 1,865.72 ns 1.00 0.00 1656 B 1.00
JsonMigratable Medium 2,025.74 ns 1.09 0.01 1656 B 1.00
Plain STJ Large 14,357.30 ns 1.00 0.01 24624 B 1.00
JsonMigratable Large 14,519.17 ns 1.01 0.01 24624 B 1.00
Static migration Manual STJ migration Small 315.56 ns 1.00 0.00 312 B 1.00
JsonMigratable Small 554.32 ns 1.76 0.00 312 B 1.00
Manual STJ migration Medium 2,003.50 ns 1.00 0.05 1808 B 1.00
JsonMigratable Medium 2,228.92 ns 1.11 0.04 1808 B 1.00
Manual STJ migration Large 14,584.96 ns 1.00 0.01 24776 B 1.00
JsonMigratable Large 14,826.24 ns 1.02 0.01 24776 B 1.00
External migration Manual STJ migration Small 319.04 ns 1.00 0.00 312 B 1.00
JsonMigratable Small 562.98 ns 1.76 0.05 312 B 1.00
Manual STJ migration Medium 1,947.25 ns 1.00 0.01 1808 B 1.00
JsonMigratable Medium 2,181.95 ns 1.12 0.02 1808 B 1.00
Manual STJ migration Large 14,055.56 ns 1.00 0.01 24776 B 1.00
JsonMigratable Large 14,761.47 ns 1.05 0.01 24776 B 1.00
Undiscriminated source migration Manual STJ migration Small 318.95 ns 1.00 0.00 312 B 1.00
JsonMigratable Small 452.95 ns 1.42 0.00 312 B 1.00
Manual STJ migration Medium 1,960.61 ns 1.00 0.01 1808 B 1.00
JsonMigratable Medium 2,281.10 ns 1.16 0.01 1808 B 1.00
Manual STJ migration Large 14,450.53 ns 1.00 0.00 24776 B 1.00
JsonMigratable Large 14,664.90 ns 1.01 0.01 24776 B 1.00
Legacy payload Plain STJ + tracking Small 324.82 ns 1.00 0.00 192 B 1.00
JsonMigratable Small 431.97 ns 1.33 0.00 192 B 1.00
Plain STJ + tracking Medium 1,922.71 ns 1.00 0.01 1688 B 1.00
JsonMigratable Medium 2,091.69 ns 1.09 0.01 1688 B 1.00
Plain STJ + tracking Large 14,393.11 ns 1.00 0.01 24656 B 1.00
JsonMigratable Large 14,673.94 ns 1.02 0.01 24656 B 1.00
Union dispatch (.NET 11) Plain STJ structural classifier Small 621.03 ns 1.00 0.00 632 B 1.00
JsonMigratable classifier Small 572.33 ns 0.92 0.00 160 B 0.25
JsonMigratable classifier + migration Small 763.34 ns 1.23 0.01 312 B 0.49
Plain STJ structural classifier Medium 3,178.05 ns 1.00 0.00 1656 B 1.00
JsonMigratable classifier Medium 2,681.66 ns 0.84 0.00 1656 B 1.00
JsonMigratable classifier + migration Medium 2,967.83 ns 0.93 0.01 1808 B 1.09
Plain STJ structural classifier Large 22,504.99 ns 1.00 0.01 25544 B 1.00
JsonMigratable classifier Large 18,793.45 ns 0.84 0.01 24624 B 0.96
JsonMigratable classifier + migration Large 19,124.73 ns 0.85 0.01 24776 B 0.97
Serialization Plain STJ Small 84.97 ns 1.00 0.01 56 B 1.00
JsonMigratable Small 168.78 ns 1.99 0.01 88 B 1.57
Plain STJ Medium 504.53 ns 1.00 0.00 416 B 1.00
JsonMigratable Medium 692.47 ns 1.37 0.00 752 B 1.81
Plain STJ Large 4,800.09 ns 1.00 0.00 10384 B 1.00
JsonMigratable Large 5,110.45 ns 1.06 0.00 10728 B 1.03

Full benchmark reports: source-gen · reflection

Run benchmarks locally with dotnet run --project perf/Egil.SystemTextJson.Migration.PerfTests -c Release --framework net11.0. Refresh these docs from the latest BenchmarkDotNet output with ./scripts/update-perf-docs.ps1.

Design notes

  • First-property discriminator check. The converter inspects only the first JSON property for the type discriminator, keeping detection O(1) and allocation-free. The library serializes $type with Order = int.MinValue so round-tripped payloads always have it first. If external JSON has the discriminator in a non-first position, the payload is treated as a legacy payload.

  • Static migrators take precedence. When both a static IMigrateFrom and an external IMigrate exist for the same source type, the static contract wins.

  • Short discriminators recommended. Values like "user-v2" are smaller and faster to compare than the default full type name.

  • Non-object payload migration. When the JSON payload is not an object (e.g., an array or primitive), discriminator-based matching is not possible. Collections are matched by contract kind (StartArrayJsonTypeInfoKind.Enumerable); scalars are matched by the token family their built-in converter reads: a JSON number matches the TypeCode numerics (and enums), Half, Int128, UInt128, and on .NET 11 BFloat16/Decimal32/Decimal64/Decimal128; a JSON string matches string, char, DateTime, DateTimeOffset, DateOnly, TimeOnly, TimeSpan, Guid, Uri, Version, byte[], Memory<byte> and ReadOnlyMemory<byte>; true/false match bool. Nullable<T> sources are matched by their underlying type; scalar types that need a custom converter are not matched. Dictionary source types (Dictionary<string, T>) are also supported — when no discriminator match is found on a non-empty JSON object, the library checks for JsonTypeInfoKind.Dictionary migrators before falling back to legacy handling; an empty object {} takes the legacy path. A configured UndiscriminatedSourceType takes precedence over both and receives every object without a recognized discriminator. This adds zero overhead to the existing object-based happy path.

Mutation testing

dotnet tool restore
dotnet stryker --config-file stryker-config.json -t mtp

Reports are written under StrykerOutput/.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed.  net11.0 is compatible. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.
  • net11.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.0.76 0 9/24/2026
2.0.47 33 9/23/2026
1.7.4 1,522 6/11/2026
1.6.4 687 5/12/2026
1.5.3 318 5/6/2026
1.4.3 112 5/5/2026
1.3.7 120 5/5/2026
1.2.6 193 4/14/2026
1.1.3 126 4/13/2026
1.0.2 126 4/11/2026
0.4.2 115 4/11/2026
0.3.4 110 4/11/2026
0.2.3 114 4/11/2026

New Features:
- support .NET 11 with union classification and full numeric source matching
 Multi-targets net10.0 and net11.0 (the .NET 11 asset is built with the RC1 SDK
 under a go-live licence).
 On .NET 11, AddJsonMigrationSupport() registers a JsonTypeClassifierFactory so
 a C# union whose cases are [JsonMigratable] types is classified by migration
 discriminator: current and old payloads route to the case that migrates them,
 nested unions forward their discriminators, and any situation that cannot be
 resolved safely throws a JsonException listing the known discriminators.
 Source-generated contexts opt in with
 [JsonUnion(TypeClassifier = typeof(JsonMigratableUnionTypeClassifier))].
 Non-object payload matching now recognises every numeric type System.Text.Json
 can read (Half, Int128, UInt128 and, on .NET 11, BFloat16 and Decimal32/64/128),
 honours JsonNumberHandling for quoted numbers and named floating-point literals,
 matches byte[] and other string-shaped sources, disambiguates collection sources
 by element discriminator and contract kind, and never shape-matches a source
 read through a converter override.
 [JsonMigratable] on a union, collection or dictionary now fails with
 JsonMigratableTargetKindNotSupportedException pointing at the recipe instead of
 an opaque STJ error.
 Documentation covers .NET 11 unions, the unchanged [JsonPolymorphic] limitation,
 an NDJSON read-migrate-write-back recipe, and states that trimmed/NativeAOT
 publishing is not supported yet.
 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Bug Fixes:
- match a quoted number to 1.x's numeric sources before 2.0's
 The quoted-number tier considered every numeric source at once, so a
 target migrating from int and Half threw on "42" as ambiguous although
 a plain 42 goes to int. The tier is now split, legacy numeric types
 first and then the ones 2.0 added, for top-level payloads and for the
 first element of a collection alike.
- name every source of a union case's converter override
 Picking the converter branch whenever options.Converters held a
 matching converter misattributed a resolver-supplied override when a
 converter had been added after registration too. Which source served
 the contract is not recorded on it, so the diagnostic now names all
 three with the fix for each.
- name the actual source of a union case's converter override
 A union case served by a converter other than migration's was always
 told to register its converters after AddJsonMigrationSupport(), also
 when the override came from a resolver ahead of the migration entry or
 from an attribute, where no converter registration is involved. The
 diagnostic now distinguishes a converter in options.Converters from a
 resolver or attribute override and gives the fix for each.
- keep the building registry for a union configured inside a converter build
 The union classifier re-rooted the options' scope to the resolver that
 served the union's first case. Inside a converter build that scope is
 the building registry, and the case being built has no migration
 converter to ask, so it fell back to whichever registry served the
 first case; a nested case then got that registry's discriminator and
 migrators. Since each migratable case now reads its registry from its
 own converter, the scope is kept as is, and the first case's resolver
 stands in only when the options carry no registration at all.
- stop inferring a shared registration from the shape of a registered chain
 A resolver at the front of a registered chain that no longer shows its
 migration resolver looks the same whether it wraps that resolver or was
 left behind when the resolver was removed, so independently created
 options reusing it inherited a registration they never had and their
 own registration call ran no configuration.
 Registration carries over to a copy only by identity, through the chain
 object STJ's copy constructor shares with it. A copy that changed its
 own chain behind a wrapper registers like any other options, with the
 configuration it passes.
- never call a user's resolver during registration
 Discovery probed application-defined resolvers with a marker type, so
 the first registration on options whose resolver wraps a
 DefaultJsonTypeInfoResolver resolved through it and froze its
 Modifiers, the side effect registration promises not to have.
 The probe has no case left to serve: a copy finds its registration
 through the chain object it shares with the original or the wrapper at
 the front of that chain, a cached registration is kept while an
 application-defined resolver is in the chain, and union routing reads
 each case's registry from the converter the case resolves to. It is
 removed; discovery walks STJ's chains and decorators and calls nothing.
- infer a shared registration only from the entry hiding its resolver
 Matching any application-defined resolver shared with a registered
 chain made independently created options that reuse a downstream
 resolver inherit that chain's registration: their own registration
 call returned without inserting a resolver or running its
 configuration.
 Registration puts the migration resolver at the front of the chain, so
 the inference now requires the registered chain to no longer show its
 resolver and the shared instance to be the application-defined entry
 at its front, the one that took the resolver's place. A downstream
 resolver shared with a registered chain says nothing.
- forward nested union routes from the registry serving the nested case
 The outer union forwarded a nested union's case discriminators and
 sources from its own registry, while a type-selective resolver can
 serve the nested case through another migration resolver whose
 registry holds the external migrators for it. Old nested payloads then
 went unclassified although the inner union could migrate them.
 A nested migratable case now forwards its routes from the registry that
 built its converter, as a direct case does.
- find a copy's registration through shared chain entries and route union cases per registry
 A copy that extended its own chain gets a chain object of its own, so
 the chain the original registered on no longer identified it; behind a
 type-selective wrapper neither the walk nor the probe could recover the
 registration and a fresh registry went in front. The copy's chain is
 built from the entries of the chain it was copied from, so an
 application-defined resolver it shares with a registered chain now
 identifies that registration.
 Union routing used one registry for every case, the first served case's,
 while a type-selective wrapper can send cases to different migration
 resolvers. Each case now routes with the registry that built its
 converter; a case inside its own migration build keeps the scope's.
- route unions with the registry that serves their cases
 The union classifier routed with whatever registration was cached for
 the options. A registration the options no longer use can stay cached
 behind an application-defined resolver that answers no probe, and a
 wrapper can send the cases to a resolver registered on other options;
 in both cases the routes came from the wrong registry.
 The classifier now reads the resolver from the migration converter a
 case resolves to and routes with its registry, using the cached lookup
 only for the exclusions of the options being resolved. A case that
 resolves to its plain contract without a converter override is
 reported as missing migration support rather than as a converter
 ordering problem.
- treat a silent resolver probe as unknown, not as removal
 A resolver that forwards only its own application's types never sees
 the probe's marker, so its silence said nothing, yet the guard read it
 as the registration having been removed and put a fresh registry in
 front of it, losing the explicitly registered migrators.
 A positive probe remains proof. A silent application-defined resolver
 now keeps the cached registration, as the walk cannot tell it from a
 wrapper. Copies of registered options, which carry no cached
 registration, find the original's through the chain object STJ's copy
 constructor shares with them until they change their own chain.
- probe application-defined resolvers for the migration entry they wrap
 A copy of options whose migration entry sits behind an application-
 defined wrapper carries no cached registration, and the chain walk
 cannot see through the wrapper, so registering again on the copy put a
 fresh registry in front of it and the union classifier reported missing
 migration support.
 Registration discovery now asks each application-defined resolver in
 the chain for the contract of a marker type that only the migration
 resolver answers, and takes the resolver stamped on the answer as the
 one behind it. That replaces the assumption that any such resolver was
 wrapping the entry: replacing the chain with one's own resolver and
 registering again now registers again, as with STJ's resolvers.
- leave a wrapped migration entry in place and route unions through it
 Restoring a hidden registration by inserting its resolver at the front
 of the chain took the migratable types away from the application-defined
 wrapper that had been consulted first; the wrapper's own contracts for
 those types no longer applied. The guard now leaves the chain as the
 user shaped it: an application-defined resolver in the chain is assumed
 to be wrapping the registered entry, and a chain made of STJ's own
 resolvers is the only shape that counts as having removed it.
 The union classifier used the strict structural check and reported
 missing migration support behind such a wrapper; it now applies the same
 rule and routes with the registration cached for the options.
- keep the first registration when a wrapper hides its resolver
 The registration guard cannot see through an application-defined
 wrapper, so calling AddJsonMigrationSupport() again after wrapping the
 chain entry inserted a fresh resolver with an empty registry, and the
 migrators the first call registered were no longer consulted.
 The cached registration is now kept when the chain holds a resolver the
 structural walk cannot see through, and its resolver is put back in
 front where a later call finds it. Replacing the chain with STJ's own
 resolvers still counts as removal and registers again.
- honour exclusion scopes from any active migration resolver
 Registering migration again after an application-defined wrapper hid
 the first resolver adds a second one, and both stay active. Each
 resolver then rejected the exclusion scope the other had registered for
 a clone, so neither stepped aside for the type being built and the
 converter build recursed until the stack overflowed.
 A scope's exclusions and root options describe the options instance,
 not the resolver that registered it, so a resolver now honours whatever
 scope it finds; when it builds a converter under a scope another
 resolver registered it re-roots the clone's scope to itself, so the
 discriminator metadata comes from the registry that builds the
 converter. The registration guard keeps its structural check for
 replaced or cleared resolvers.
- apply entry decorators to migratable plain contracts and refuse stale union routing
 When the migration entry stands in for the default resolver and is
 decorated with a modifier, the plain contract of a migratable type was
 produced by the exclusion clone's wrapper, outside the decorator, so the
 modifier applied to every type except the migratable ones. The migration
 resolver now returns the reflection contract itself for the type being
 built, so it travels back through the decorator like any other contract.
 The .NET 11 union classifier read the cached registration without
 checking that its resolver was still reachable, so after the resolver
 chain was cleared or replaced it built routing against a registry the
 options no longer used. It now uses a reachability-checked lookup that
 never removes entries and reports missing migration support instead.
- keep the exclusion scope while resolving through an opaque resolver wrapper
 Structural validation of a cached scope cannot see the migration
 resolver behind an application-defined wrapper, so while a migratable
 type's converter was being built through such a wrapper the resolver
 dropped the exclusion clone's own scope, no longer stepped aside for the
 type, and started building the same converter again without end.
 The executing resolver now authenticates a scope by identity: a scope
 whose resolver is the one being called is valid however the chain is
 wrapped. Structural validation, with removal of a stale entry, is done
 only by the registration guard in AddJsonMigrationSupport(), the one
 place that must detect a resolver that was replaced or cleared, and it
 never runs during a resolution. With an opaque wrapper and no downstream
 resolver the failure is STJ's own missing-metadata error rather than a
 stack overflow.
- match the scalar sources 1.x knew before the ones 2.0 added
 A target with both a string and a Guid source received a JSON string
 from the string source in 1.x, because Guid was not a scalar source
 then. 2.0 classified both as string-shaped and reported the payload as
 ambiguous, so a stored payload stopped migrating. Scalar matching now
 runs in tiers: the types 1.x matched by TypeCode first (plain sources
 ahead of ones behind a converter override), then the types 2.0 added,
 then quoted numbers, then overridden sources of the newer types. The
 same order applies to the first element when collection sources
 compete. A source 1.x selected is therefore selected again, and a 2.0
 source only where 1.x had no match; two sources in one tier are still
 ambiguous.
- discover migration support through the chain, not by resolver identity
 The idempotency guard in AddJsonMigrationSupport() and the union
 classifier's registry lookup both scanned TypeInfoResolverChain for the
 migration resolver by type. A decorated entry (WithAddedModifier or an
 application wrapper) hid it, so a second AddJsonMigrationSupport() call
 inserted a fresh registry ahead of the decorated one and dropped the
 configured external migrators, and a copy of such options (which carries
 no registered scope) made the .NET 11 classifier report that migration
 support was missing.
 Discovery now also asks the chain for a private marker type that only
 the migration resolver answers; decorators forward the request, and the
 answer carries the resolver. Calling the resolver directly rather than
 options.GetTypeInfo leaves mutable options mutable. The Stryker mutate
 list names the renamed resolver files.
- match overridden sources by their CLR shape as a last resort, as 1.x did
 The enum-only fallback added earlier left other sources with a converter
 override behind: an int read through a custom JsonConverter<int> was
 matched by TypeCode in 1.x, and 2.0 rejected its stored numeric payloads.
 Every source with a converter override now keeps the token family of its
 CLR type as a last-resort match, considered only after all other sources,
 so 1.x-readable payloads stay readable while a plain source of the same
 shape still wins.
- decide migration precedence from the options in use, not chain identity
 The migration resolver located itself in TypeInfoResolverChain by
 reference when building a type's converter, so any decorator around
 that entry (WithAddedModifier, or an application wrapper) made the
 lookup fail with ArgumentOutOfRangeException on the first migratable
 type. The exclusion clone now wraps whatever resolver it inherited and a
 per-options scope tells the migration resolver when to step aside for
 the type being built; the union classifier finds the registry through
 the same scope. Decorated entries work, and a decorator's modifiers
 keep applying to everything the chain returns.
 A converter registered before AddJsonMigrationSupport() also kept its
 precedence in copies of the options that no longer contained it,
 leaving the type with a plain contract and no $type. Precedence is now
 checked against the converters of the options being resolved.
- keep numeric payloads reaching an enum source that has a string converter
 1.x routed a JSON number to an enum source by TypeCode even when
 JsonStringEnumConverter was registered, and that converter reads integers
 by default. The 2.0 rule that excludes sources with a converter override
 from shape matching therefore rejected stored numeric payloads whose only
 source is such an enum. The number is now offered to an overridden enum
 source when no other source matched, so those payloads migrate as they
 did in 1.x while a plain numeric source still wins over the enum.
- make AddJsonMigrationSupport() idempotent
 Calling AddJsonMigrationSupport() twice on the same options put two
 migration resolvers in the resolver chain. The reflection fallback only
 stands in while the migration resolver is the sole chain entry, so a
 second call made options without any other resolver fail to serialize
 plain types. A second call now keeps the first registration, which is
 what the previous converter-factory registration did in effect because
 System.Text.Json only consulted the first matching converter.
Performance:
- keep STJ fast-path serialization by registering through the resolver chain
 AddJsonMigrationSupport() no longer adds a converter factory to
 options.Converters. System.Text.Json only uses a JsonSerializerContext's
 generated fast-path serializers when that list is empty, so one entry
 switched every type in the options to the metadata path, including
 types that never take part in migration. Migration is now a resolver
 inserted at the front of TypeInfoResolverChain. Types outside migration
 keep their generated serializer; a [JsonMigratable] type itself, and
 any type whose properties reach one, still serializes through the
 metadata path because its contract carries the injected discriminator.
 Precedence is unchanged for converters: one registered in
 options.Converters before AddJsonMigrationSupport() still wins for a
 [JsonMigratable] type, one registered after still does not. Options
 without any resolver keep reflection-based serialization.
 Two setups behave differently. A custom IJsonTypeInfoResolver that
 serves a [JsonMigratable] type with its own converter used to win
 silently because it never consulted options.Converters; it now has to
 be inserted ahead of migration with TypeInfoResolverChain.Insert(0, ...)
 after AddJsonMigrationSupport(), otherwise migration owns the type and
 rejects the non-object contract. Clearing TypeInfoResolverChain or
 assigning TypeInfoResolver after AddJsonMigrationSupport() now removes
 migration support, since that is where it lives.