Skip to content

v9.0.0

Choose a tag to compare

@github-actions github-actions released this 11 Oct 18:47
· 450 commits to main since this release
c4f0272

9.0.0 — 2026-10-11

Wire-format corrections, and the machinery that proves them. It started from three gaps measured
by wrapping a DateRange in a downstream value object and putting it on an ASP.NET Core API —
JSON with nothing registered, dictionary keys, OpenAPI — and a value-semantics review of the result
found more of the same kind, each one answering wrongly without throwing. Nothing was removed or
resignatured
— package validation passes against the 8.0.0 baseline.

This is a major for the reason 7.0.0 and 8.0.0 were: several existing calls now answer
differently, and a caller cannot discover that from a compile error. A floating 8.* reference
must not pick any of these up silently.

What can break you, in order of likelihood:

  1. JsonSerializer.Serialize(range) with default options writes the literal, not
    {"Length":181}. The old form could not be read back by anything, this package included, but a
    consumer that only ever wrote it — into a response, a document, a log — sees its shape change.
  2. A culture provider no longer reaches the literal. Parse(s, culture) reads the invariant
    literal, and formatting without a format string writes it. Code that parsed culture-formatted
    text through Parse(s, de-DE) deliberately must parse the bounds itself.
  3. Temporal literals are strict ISO 8601. [01.02.2027,05.03.2027) used to parse — month
    first, as January 2 to May 2 — and a DateTimeOffset without an offset took the parsing
    machine's. Dotted, slashed and named-month dates, date-only text for a date-time and an
    offset-less DateTimeOffset are now a FormatException; HH:mm and PostgreSQL's own output
    still parse.
  4. Lenient decimal forms are refused. "1,000", "(5)" and currency symbols in a
    DecimalRange or DecimalSet literal are a FormatException, as they are in PostgreSQL.
  5. Interpolating an integer or decimal range is invariant under every culture. Only code that
    depended on [1,5,2) — which parsed back as a different range — is affected.
  6. The EF Core provider writes temporal values as text at the database's resolution. A SQL
    constant now carries the microsecond Npgsql would bind, so a query that compared a constant
    against its own row and missed now matches; and a temporal set whose elements are less than a
    microsecond apart is stored with one of them, as the database could only ever hold one.
  7. EF Core queries composing on a server-computed value set Union throw where they answered
    wrongly
    — equality in a projection or on a projected union, a conditional or ?? around one,
    GroupBy, Distinct, OrderBy, set operations, Count through ??, and ExecuteUpdate to a
    union. Each returned rows that disagreed with memory, or stored a non-canonical array.
  8. The literal grammar follows PostgreSQL's. A quoted-empty or whitespace-only range bound is
    refused instead of read as unbounded; a backslash escapes in unquoted array elements and range
    bounds ({a\,b} is one element); {{a,b}}, {a}b} and {a"b} are refused; only ASCII
    whitespace is skipped. An empty format string writes the literal, as null does.
  9. A DateTime literal with an offset keeps its wall clock. 2026-01-01T09:30+05:30 is 09:30
    (Local) on every machine; it was converted into the parsing machine's zone.
  10. JSON precedence and coverage changed in three places. A union converter registered on the
    options now reaches values serialized through object or as a variant; a property declared as
    a range or set interface writes the literal (it wrote {}); and a NodaTime date in a non-ISO
    calendar — bare, or as a wrapper-set element — is refused, where it was written thirteen days
    off.

Everything else is additive: a TypeConverter on every type, ranges as dictionary keys, ranges
and sets inside EF Core JSON columns, Native AOT, Deconstruct on every bounded variant, and an
OpenAPI transformer to copy. The
migration guide has the call-site edits.

Changed

  • ⚠️ Every range, variant, multirange and value set serializes as its literal with nothing
    registered.
    The converters were opt-in (AddRangeConverters()), and System.Text.Json's fallback
    for a type with no converter is a property dump: DateRange wrote {"Length":181}, a RangeSet
    [{"Length":5}], a StringSet {"Values":["a","b"],"Count":2,"IsEmpty":false}, and an
    object-typed range every public property of its variant. None of them read back — the abstract
    union threw NotSupportedException, the rest JsonException — and nothing threw on the way
    out, so an ASP.NET Core host that forgot the registration wrote wrong responses and refused every
    request body with a 400. Each type now carries [JsonConverter], so "[2026-01-01,2026-06-30]",
    "{[1,5],[7,10]}" and ["a","b"] are the wire form by default.

    • System.Text.Json does not inherit [JsonConverter], so each sealed variant carries its own —
      a value reached through object is a variant, and an attribute on the union alone would have
      left it on the property dump.
    • The attribute is the lowest-precedence route: a converter registered on the options or on a
      property still wins. AddRangeConverters() stays valid and is now redundant.
    • Source-generated JsonSerializerContexts honour it too. Unions, variants and the non-generic
      sets name a closed converter type, which the generator instantiates directly; RangeSet and the
      wrapper arities name RangeJsonConverterFactory, since an attribute cannot name an open generic
      converter. A context serializing a value set must also list the element type — the set
      converter resolves the element's contract through the options.
    • Payload change for anyone who wrote the old form: a response or document that carried
      {"Length":…} now carries the literal.
  • ⚠️ Temporal bounds and elements parse as strict ISO 8601. DateRange, DateTimeRange,
    DateTimeOffsetRange, TimeRange, DateSet, DateTimeSet, DateTimeOffsetSet, TimeSet and
    the temporal wrapper arities read element text with the lenient BCL Parse, which reads the
    invariant culture month first: [01.02.2027,05.03.2027) was January 2 to May 2, 9:30 AM was
    09:30, and a DateTimeOffset without an offset took whichever machine parsed it — all silently.
    They now accept exactly what this library writes and what PostgreSQL prints in its ISO
    DateStyle: yyyy-MM-dd; HH:mm or HH:mm:ss with any number of fraction digits, truncated to
    100 ns; T or a space between them; for a DateTime an optional Z (Utc) or ±HH,
    ±HH:mm or ±HH:mm:ss (Local) with the wall clock kept as written — the offset is no longer
    applied
    : it was converted into the parsing machine's zone, so 2026-01-01T09:30+05:30 was
    05:00 in Berlin and 04:00 on a UTC server, and a tsrange stores the wall clock; PostgreSQL's own
    timestamp input drops the offset the same way, and a local value now round-trips exactly, even
    across a DST gap — for a
    DateTimeOffset a required Z, ±HH, ±HH:mm or ±HH:mm:ss (a historical local-mean-time
    offset, kept as the instant at offset zero); and infinity / -infinity as MaxValue /
    MinValue, what Npgsql materializes for them. Everything else is a FormatException through
    Parse, TryParse, JSON, the TypeConverter and configuration binding. The wrapper arities —
    BCL and NodaTime — read the text as their primitive first and hand the element its round-trip
    form, so a lenient generated element Parse never sees non-ISO text, and an element that
    ignores the format specifier fails on read instead of truncating silently. The NodaTime types
    were already strict; they now also read infinity for Instant, LocalDate and
    LocalDateTime, the types Npgsql maps it for. A closed temporal set reads its JSON elements with
    the same grammar
    — System.Text.Json's own converter read an offset-less DateTimeOffset, or a
    bare date, in the parsing machine's offset — unless a converter is registered for the element
    type, which still wins; the written form is System.Text.Json's, unchanged. TemporalLiteralGrammarTests pins the
    grammar; TemporalLiteralStrictnessContractTests (conventions) refuses a non-ISO element through
    every entry point of all 26 temporal range and set types.

  • The range interfaces are declared not to be an extension point. IRange<T>, the shape
    interfaces and IRangeFactory<TRange, T> are the shared vocabulary of the shipped range types;
    implementing them outside the library is unsupported, and they may gain members in a minor
    version. Nothing changes for code that consumes them. The XML docs and docs/architecture.md
    state the policy.

Fixed

  • ⚠️ Interpolating a DecimalRange under a comma-decimal culture produced a different range.
    Int32Range, Int64Range and DecimalRange have no FormatValue override, and the default
    formatted a null provider — what string interpolation, string.Format and logging renderers
    pass — in the current culture. Under de-DE, $"{DecimalRange [1.5,2)}" was [1,5,2), which
    the parser reads back as [1,52), silently; [1.5,2.25) came back as [1,5225). Under
    sv-SE a negative bound took U+2212 as its minus sign and no longer parsed. A null provider
    now means the invariant culture in both default FormatValues, as every override already did.
    ToString(), JSON, the TypeConverter and the EF bridge were never affected — they pass the
    invariant culture explicitly. LiteralCultureContractTests (conventions) formats every shape,
    multirange and set family under de-DE, sv-SE, fa-IR and th-TH.

  • ⚠️ Decimal bounds and elements parse with PostgreSQL's numeric grammar. DecimalRange and
    DecimalSet parsed with NumberStyles.Any, which also accepts group separators, currency
    symbols and parenthesised negatives: ["1,000",2000] read as [1000,2000], "(5)" as −5 —
    literals PostgreSQL rejects. It is also what let 5,2 read as 52 above, and what made an
    explicit comma-decimal provider misread silently: DecimalRange.Parse("[1.5,2.5)", de-DE) was
    [15,25). They now parse with NumberStyles.Float — sign, digits, point, exponent — and those
    inputs are a FormatException. Integer types already used NumberStyles.Integer. If you
    relied on the lenient forms, they now fail.
    The wrapper arities are covered too: DecimalSet<T>
    and the integer arities handed the raw element text to the element's own Parse, and a generated
    one (decimal.Parse(s, provider), NumberStyles.Number) accepts group separators, so
    DecimalSet<Price>.Parse("{\"1,5\"}") was {15} — through TryParse, configuration binding and
    a JSON string too — while DecimalSet refused it. They now parse with the family's grammar first
    and hand the element its invariant text, so its validation still runs and scale is kept; the
    decimal JSON writer and the EF Core bridge read element text the same way.
    WrapperGrammarContractTests (conventions) holds every wrapper family to its primitive sibling.

  • ⚠️ An empty format string wrote a culture-formatted literal. .NET reads "" as the general
    format, exactly like null, but the literal checked only for null: ToString("", de-DE) on
    DecimalRange [1.5,2) wrote [1,5,2), a DateRange with ("", th-TH) Buddhist-era short
    dates. An empty format now writes the invariant literal everywhere null does.
    LiteralProviderContractTests and LiteralCultureContractTests sweep "" alongside null.

  • ⚠️ A quoted empty range bound read as an unbounded one. Int32Range.Parse("[\"\",5)") was
    (,4] and ("","") was (,), silently, where PostgreSQL answers invalid input syntax; a
    whitespace-only bound ([ ,5)) was trimmed to nothing the same way. Bounds now follow PostgreSQL's
    range_in: only an absent bound is infinite, quotes may wrap any part of a bound, and a backslash
    escapes the next character inside quotes or out. Applies to every range type and multirange.

  • ⚠️ Array literals departed from PostgreSQL's array_in. StringSet.Parse(@"{a\,b}") was two
    elements, a\ and b, where PostgreSQL reads the one element a,b; {{a,b}} was the set
    {"{a","b}"} where PostgreSQL reads a two-dimensional array; {a}b} was {"a}b"} where
    PostgreSQL errors. Unquoted elements now honour backslash escapes, an unescaped {, } or "
    inside one is a FormatException, and whitespace inside an element is kept. Both literal parsers
    skip only the ASCII whitespace PostgreSQL skips. LiteralGrammarParityTests (integration) asks a
    live server for the same answer.

  • The NodaTime types refused HH:mm. TimeRange.Parse("[09:00,17:00)") parsed but
    LocalTimeSet.Parse("{09:00}") threw. HH:mm is now accepted wherever the BCL types accept it —
    a LocalTime, the time part of a LocalDateTime, an Instant with its offset or Z.

  • ⚠️ A converter registered on the options for a range union reached union-typed declarations
    only.
    System.Text.Json resolves a converter by the exact type, and a range reached through
    object or declared as a variant is handed over as its variant, whose own [JsonConverter]
    wrote the literal — so with a custom Int32Range converter, Serialize(range) wrote the custom
    form and Serialize<object>(range) wrote "[1,2]". The variant converter now hands values and
    dictionary keys, on both legs, to whatever System.Text.Json would choose for the union from
    Converters, unless that is this package's own; a read goes through the union and narrows.
    Payload change for anyone who registered a union converter and serialized ranges through
    object.

  • ⚠️ A property declared as a range or set interface was written as a property dump.
    IRange<int> wrote {} and IValueSet<string> {"Values":["a"]}, with no error, and neither
    read back. The interfaces now carry InterfaceDeclaredJsonConverter, which writes through the
    runtime type's contract (the literal, or a converter registered for that type) and refuses a read
    with NotSupportedException naming the remedy: declare the concrete type.

  • ⚠️ NodaTime: AddNodaTimeRangeConverters() shadowed ConfigureForNodaTime, and wrote bare
    non-ISO values in their own calendar's fields.
    Registered first, its element converters won for
    all five NodaTime types, contrary to the documented "either order"; and a Julian
    LocalDate(2024, 1, 1) — ISO 2024-01-14 — was written "2024-01-01" and read back thirteen days
    off. Any other converter registered for the type now wins wherever it sits, and a bare
    LocalDate, LocalDateTime or YearMonth in a non-ISO calendar is refused on write with
    ArgumentException, as NodaTime's own converter refuses it. Convert with
    WithCalendar(CalendarSystem.Iso) first.

  • ⚠️ NodaTime wrapper sets stored an element in another calendar as a different date.
    NodaTime's ISO patterns format a value in its own calendar's fields, so a LocalDateSet<T> over a
    Julian LocalDate(2024, 1, 1) — ISO 2024-01-14 — wrote {2024-01-01} and ["2024-01-01"], and
    the EF Core bridge bound, inlined and stored ISO 2024-01-01: thirteen days off, with no error and
    an unequal round trip. LocalDateTimeSet<T> and YearMonthSet<T> likewise; against live
    PostgreSQL a Julian-January parameter matched a row holding ISO January. The closed sets convert
    such a value to ISO; a wrapper arity can only build its element by parsing, so it now refuses
    it with ArgumentException — in From, collection expressions and reads, and as the probe of
    Contains/Add/Remove — and the EF Core bridge refuses one as a bare operand. If you wrapped
    non-ISO values, convert them with WithCalendar(CalendarSystem.Iso) first.

  • ⚠️ A culture provider no longer changes the literal. Parse(s, provider) handed the provider
    to the element parser, and ToString(null, provider) to the element formatter. th-TH uses the
    Buddhist calendar, so DateRange.Parse("[2026-01-01,2026-06-30]", th-TH) returned
    [1483-01-01,1483-06-30] and ToString(null, th-TH) wrote 2569; ar-SA read dates as
    Hijri; de-DE read the decimal 1.5 as fifteen. Nothing threw. The literal is PostgreSQL's
    text and has no culture, so:

    • Parse/TryParse (string and span, every range, RangeSet and value set) read the invariant
      literal and ignore the provider, as Guid.Parse does.
    • Without a format string, formatting writes the invariant literal whatever the provider. A
      provider still applies alongside an explicit format (ToString("yyyy-MM-dd", th-TH)), which
      asks for display text.
    • The library's own paths — JSON, the TypeConverter, EF Core — always passed the invariant
      culture and are unaffected, as is ASP.NET Core model binding, which passes it too.
      LiteralProviderContractTests (conventions) parses and formats every shape, multirange and set
      family under de-DE, sv-SE, fa-IR, th-TH and ar-SA. If you parsed culture-formatted
      text through Parse(s, culture) deliberately, parse the elements yourself and build the range
      with the factories.
  • Ranges, multiranges and value sets inside an EF Core JSON column (ToJson()) could not be
    saved — the plugin's type mappings had no JsonValueReaderWriter, and SaveChanges threw a
    bare NullReferenceException from inside EF. Each mapping now writes the store type's text,
    which also makes queries into the document translate:
    CAST(doc ->> 'Period' AS daterange) @> @day.

    • The document holds the store's representation, not the API JSON, and it is a persisted
      format: a value set is "{a,b}" (an array in API JSON), the YearMonth types — wrapper
      arity included — their month-aligned dates, since that is what the cast reads.
    • Temporal values are stored as a parameter would bind them: truncated to the microsecond
      toward 2000-01-01 as Npgsql truncates, the extremes Npgsql maps as -infinity / infinity.
      Writing every digit and letting the server round them half-to-even made the document hold a
      different value from the column, so Doc.Period == period could miss the row it was saved
      from and Contains disagreed at the bounds. Materializing from the document now gives what
      materializing from a native column gives.
    • ExecuteUpdate of a value set inside the document is refused: EF Core writes it with
      to_jsonb, storing a JSON array the document cannot hold, after which the row no longer loads.
      The refusal reads the translated UPDATE, so a join or EF.Property cannot reach past it.
      Ranges, multiranges, a whole complex property holding a set, and the whole JSON property can be
      set.
    • SaveChanges (complex and owned, nested, null and empty kept apart) and the operators inside
      the document were checked against a live server, ExecuteUpdate to null fails in Npgsql for
      any type, and EF Core refuses ExecuteUpdate on owned JSON types — docs/efcore.md has the
      matrix.

    JsonColumnTests (integration) round-trips and queries every type inside one jsonb column;
    TemporalStoreParityTests holds the temporal rule for 18 types and seven probes (remainders
    above, below and at 500 ns, before 2000, the extremes, sub-microsecond ranges);
    ExecuteUpdateJsonTests the update paths. MappingParityTests requires a reader/writer on every
    mapping.

  • ⚠️ Temporal SQL constants carried more precision than the database, and set arrays duplicates.
    The EF Core provider rendered a temporal range, multirange or set constant with seven fraction
    digits (nine for NodaTime), which the server rounds half-to-even, while a parameter is truncated
    toward 2000-01-01 — so a constant could disagree with a parameter for the same value, and EF 10
    inlines evaluatable expressions as constants without EF.Constant. DateTime.MinValue rendered
    as a finite date where a parameter carries -infinity. Constants now carry the value Npgsql
    would bind — a set's element constant too, which for InstantSet and LocalTimeSet was Npgsql's
    own nine-digit literal, so set.Contains(EF.Constant(x)) missed the element x was stored as.
    And a temporal set whose elements truncate to one microsecond was bound with both,
    so the stored array's cardinality disagreed with the set's Count; it is now deduplicated on
    every path. A range's element operand (range.Contains(x), a constructor argument) has the
    range's own mapping now too: a constant is the stored value rather than Npgsql's literal, and a
    parameter is normalized like the bounds — DateTimeOffsetRange.Contains(dto) threw "only offset
    0 (UTC) is supported" for any non-UTC offset, and DateTimeRange.Contains likewise for a
    UTC-kinded DateTime.

  • A range built inline in a query failed to translate. r.Period == DateRange.CreateFinite(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)) — or Int32Range.Parse("[1,5]", null), or any
    factory call over values that depend on nothing in the query — threw "does not have a type mapping
    assigned" under ==, !=, Equals and inside a conditional. EF Core evaluates such a call on the
    client and inlines the result as a constant typed by its sealed variant, for which no mapping
    exists. The query interceptor now retypes it to its range type; the literal goes through the
    store text like any other constant. Contains, Overlaps, captured variables and EF.Constant
    were never affected. InlineRangeConstantExecutionTests (integration) runs one per range type.

  • ⚠️ A server-computed value set Union is refused wherever it would answer wrongly (EF Core).
    Union translates to array_cat, which concatenates; 8.0.0's LINQ-level refusal caught only
    ==/Equals in predicates and Count over a bare union, and its claim that a projection is
    client-evaluated was false. Against live PostgreSQL, with {a,c} ∪ {a,b} and target {a,b,c}: a
    projected comparison answered false, a ternary or ?? around the union matched 0 rows,
    GroupBy made 2 groups where memory makes 1, Distinct().Count() said 2, and ExecuteUpdate
    stored {a,c,a,b}. The refusal now runs on the final SQL tree, through a decorator around the
    provider's IRelationalParameterBasedSqlProcessorFactory, and follows a union through
    array_remove, CASE, COALESCE and subquery columns. It may still feed @>, <@, &&,
    Remove, IsEmpty, or the outermost projection, which materializes canonical.
    UnionCanonicalityTests (integration) executes every shape and the legal compositions.

  • A NodaTime range's element operand in another calendar was compared by its own fields (EF
    Core).
    LocalDateRange and LocalDateTimeRange passed no ISO normalization for an operand, so
    Contains(EF.Constant(julianDate)) rendered the Julian year, month and day as if they were ISO
    and missed its row, and a LocalDate parameter threw inside Npgsql. The operand is now converted
    to ISO, as the bounds are and as the NodaTime sets already did.

Added

  • A TypeConverter on every range, variant, multirange and value set. None had one, so
    TypeDescriptor could not convert them from a string, and Microsoft.Extensions.Configuration
    binding of "Period": "[2026-01-01,2026-06-30]" into a DateRange property produced null
    with no error
    . Each type now converts to and from its literal (RangeTypeConverter,
    RangeVariantTypeConverter, RangeSetTypeConverter, ValueSetTypeConverter, and
    LiteralTypeConverter for the generic types).

    • Always invariant: the culture argument is ignored, since the literal is a wire format.
    • An empty string converts to null (absent), never to the empty range, which is empty.
    • Unlike [JsonConverter], [TypeConverter] is inherited, so without their own a variant
      would have silently used the union's and returned whatever shape the literal had. Each variant
      carries a narrowing converter that refuses another shape.
    • ASP.NET Core model binding is unaffected — minimal APIs and MVC bind through the static
      TryParse these types already have.
  • Native AOT and trimming. RangeSet and the validated-wrapper sets failed under Native AOT
    with "missing native code": their JSON converters and TypeConverters were built with
    MakeGenericType over instantiations the AOT compiler never generated, and whether one existed
    depended on unrelated code elsewhere in the program. Each generic type now registers its own
    closed converters, which are compiled with it, so even a type a program only deserializes works.
    RangeJsonConverterFactory and LiteralTypeConverter no longer use MakeGenericType or walk
    interfaces, ValueSetJsonConverter resolves elements through JsonTypeInfo<T>, and both
    libraries build with IsAotCompatible — no trim or AOT warnings in a consumer's publish. The
    consumer smoke test gained a NativeAOT consumer that publishes and runs a native binary; seeding
    out RangeSet's own registration fails exactly the range set cases there. The core and NodaTime
    READMEs now say so, and so do their NuGet tags (native-aot, trimming); PackagingConventionTests
    holds both claims to the project's IsAotCompatible in both directions. The EF Core packages make
    no such claim. A source-generated context must list a value set's element type as well as the
    set; leaving it out is a NotSupportedException naming the element, even for the families that
    bring their own element converter (SetSourceGenerationTests pins both halves, and the NativeAOT
    consumer runs the documented context for a primitive set and a range through object).

  • Ranges and multiranges as dictionary keys. Dictionary<DateRange, int> threw
    NotSupportedException in both directions, registered or not. RangeJsonConverter,
    RangeVariantJsonConverter and RangeSetJsonConverter now override
    ReadAsPropertyName/WriteAsPropertyName, writing the same literal as the value form
    ({"[2026-01-01,2026-06-30]":181}). Keys canonicalize on read like values, and a malformed or
    wrong-shape key is the same JsonException. DictionaryKeyPolicy is not applied — a naming
    policy would rewrite the literal into text that does not parse. Value sets are not keys: their
    value form is a JSON array, not a string. Because keys canonicalize, two spellings of one key —
    "[1,5]" and "[1,6)" — collide: by default the later value silently wins, as for any duplicate
    key, while .NET 10's AllowDuplicateProperties = false refuses the collision, since it checks the
    parsed key. docs/serialization.md recommends the latter wherever a dropped entry matters.

  • Deconstruct on every bounded variant. The positional variants had one from the compiler, but
    every Finite is written by hand — its constructor is internal, so the factories stay the only
    way to build one — and so are the unbounded variants of LocalDateRange, LocalDateTimeRange
    and YearMonthRange, whose constructors normalize the calendar. var (start, end) = finite
    compiled on no range type. Each now deconstructs into exactly what its constructor takes, as a
    positional record would:

    • (start, end) on a discrete type. Both bounds are inclusive — the canonical form — so they pass
      straight back to CreateFinite, whose default there is closed.
    • (start, end, startInclusive, endInclusive) on a continuous type, deliberately with no
      two-value form: passed back to CreateFinite, (1.5,2] would come out as its half-open
      default [1.5,2).

    VariantDeconstructContractTests (conventions) holds every bounded variant of every discovered
    range type to its constructor's parameters, and checks the values written against the properties
    under both inclusiveness settings.

  • Docs: an OpenAPI schema transformer. ASP.NET Core's generator describes a range, multirange
    or value set body as {} — it cannot see through a custom converter, and .NET 10 has no public
    hook for a converter to declare its schema. docs/serialization.md#openapi carries a
    IOpenApiSchemaTransformer to copy into an API project: ranges and multiranges as type: string
    with a literal pattern, value sets as arrays of unique elements. It ships in no package; the
    repository compiles and tests it against Microsoft.AspNetCore.OpenApi 10 and holds the doc to
    the tested copy verbatim. Query and route parameters were already type: string.

  • Tests: TypeConverterContractTests (conventions) converts every shape through the union's and
    the variant's converter under de-DE, and every set family. A new
    CodoMetis.ValueRanges.AspNetCore.Tests project runs configuration binding and a real ASP.NET
    Core host (MVC and minimal APIs) with no ValueRanges registration. JsonDefaultContractTests (conventions) serializes every shape of every discovered
    range type — through the union, through object, inside a RangeSet, and as a dictionary key by
    union, variant and multirange — and every value set family in both arities, with nothing
    registered, against the registered output. The consumer smoke test asserts the unregistered wire
    form against the packed package.

Published to nuget.org through Trusted Publishing by this run. The CycloneDX SBOM for each package is attached below.