CodoMetis.ValueRanges

This file is the canonical agent guide. CLAUDE.md only imports it (@AGENTS.md), so Claude Code and every other agent read the same rules. Edit this file, never CLAUDE.md.

In-memory range and multirange types for .NET 10, mirroring PostgreSQL's six built-in range domains (int4range, int8range, numrange, daterange, tsrange, tstzrange) plus two v5 additions: TimeRange over TimeOnly (maps to the custom timerange type — needs HasPostgresRange + EnableUnmappedTypes on the EF side) and, in the NodaTime satellite, YearMonthRange over YearMonth (stored as a month-aligned daterange). Since 10.0.0, Float64Range and Float32Range cover finite double and float (the custom float8range and float4range, set up like timerange), with Float64Set/Float32Set and their arities on double precision[]/real[]. Each range type is a discriminated union of five sealed variants with exhaustive pattern matching.

Since v6 there is a second type family: value sets (Sets/) — immutable, canonical (deduplicated, sorted, no nulls) sets of scalar values stored as native PostgreSQL arrays (StringSet/text[], GuidSet/uuid[], …), plus a validated-wrapper arity for every family (StringSet<T> … DateTimeOffsetSet<T>, and five more in the NodaTime satellite) constrained only on BCL interfaces. EF maps them as scalars with plugin-owned mappings and translators (@>, &&, <@, cardinality, array_remove) — never through EF's primitive-collection machinery. Since 10.4.0 Union, Add, Intersect and Except run as one canonicalizing subquery (ARRAY((SELECT DISTINCT ON (x) x FROM unnest(…) WITH ORDINALITY … ORDER BY x, ordinality))) for every family whose order PostgreSQL shares (ISetTypeDefinition.CanonicalizesInSql, #478), built by the SQL guard. Only text is left out: ORDER BY follows the database collation rather than ordinal order, so on StringSet/StringSet<T> Union stays array_cat and On text they run as SQL only where order and repeats don't count (#485): left unsorted under @>/<@/&&, IsEmpty and the outermost projection, Count as count(DISTINCT …) and ==/!= as mutual containment, until #479 decides the order (docs/efcore.md#combine-sets-with-union).

Stack

.NET 10 · C# 14 (extension methods) · MSTest 4.x · EF Core + Npgsql (PostgreSQL bridge)

Structure

Shipping projects live under src/, test projects under test/. Never hard-code a positional path (../../) to reach the repo root — walk up to the CodoMetis.ValueRanges.slnx marker file, so moving a project cannot silently retarget a test.

  • src/CodoMetis.ValueRanges/ — Core library: range types, interfaces, set ops
  • src/CodoMetis.ValueRanges.NodaTime/ — NodaTime satellite: LocalDateRange, LocalDateTimeRange, InstantRange (namespace stays CodoMetis.ValueRanges; uses core internals via InternalsVisibleTo)
  • src/CodoMetis.ValueRanges.EFCore.PostgreSQL/ — EF Core provider for LINQ-to-SQL translation
  • src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/ — EF satellite: registers NodaTime RangeTypeDefinitions, UseValueRangesNodaTime()
  • test/CodoMetis.ValueRanges.Tests/ — Unit tests (one file per operation)
  • test/CodoMetis.ValueRanges.NodaTime.Tests/ — NodaTime satellite unit tests
  • test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/ — EF Core SQL translation tests (no database)
  • test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/ — NodaTime EF translation tests (no database)
  • test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ — Live PostgreSQL via Testcontainers (needs Docker; Inconclusive without, but hard failure under CI=true). The shared container runs VALUERANGES_POSTGRES_IMAGE, default postgres:16-alpine (the 10.x floor); CI runs both integration projects on 16 and 18, and a server below 16 fails every test (PostgresServer.cs). Covers BCL and NodaTime types. Authority on PostgreSQL semantics — run when changing translations or range algebra. CapturedCollectionProbeTests sends every discovered range and set type as a captured collection (Contains, Any, Count); it links RangeProbes and SetProbes from the conventions project rather than copying them. ComplexIndexesCrossTests holds this package's columns under EFCore.ComplexIndexes.PostgreSQL's temporal and exclusion constraints and GiST indexes on PostgreSQL 16 and 18 (containers of its own) through a test-only reference; neither shipping package references the other
  • test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/ — The same live layer for configuration that is per process: its runtimeconfig sets Npgsql.DisableDateTimeInfinityConversions, which Npgsql and the plugin each read once, so it cannot be flipped inside the other project's run. Holds the temporal store rule at the extremes with the conversion off. Own container; CI's integration job runs every test/*IntegrationTests/
  • test/Directory.Build.targets — Floats the EF test projects (every project whose name contains EFCore) to a newer EF Core and Npgsql EF patch for one run, -p:EfFloat=10.0.14 -p:NpgsqlEfFloat=10.0.3, while src/ keeps compiling against the floor it ships: a consumer's situation, which floor-only CI missed for #302. Without the properties it does nothing. CI's newest-patch integration legs pass them, and the newest published versions live in one place, the integration-legs matrix in dotnet.yml (#297)
  • test/CodoMetis.ValueRanges.AspNetCore.Tests/ — Host-level behaviour with no ValueRanges registration: configuration binding (TypeConverter), MVC and minimal-API binding and JSON bodies via TestServer, and the documented OpenAPI schema transformer (ValueRangesSchemaTransformer.cs), which docs/serialization.md must carry verbatim — edit the test copy, then the doc. HostCultureProbeTests binds every discovered type (core and NodaTime) through both hosts under four request cultures; it links RangeProbes, SetProbes, WrapperElements and Reflect from the conventions project rather than copying them
  • test/CodoMetis.ValueRanges.Conventions.Tests/ — Repo-level conventions: changelog consistency and form, packaging metadata (each package's nuspec, generated by NuGet's own pack task, carries its version's changelog section as release notes), release wiring, the SECURITY.md supported-versions table, value set contract compliance, EF mapping parity, translator dispatch (TranslatorDispatchSweepTests: every public query member over every type, each operand mapped as its declared type), the default JSON contract. Everything is discovered (projects by globbing src/, types by reflection), so adding a package or a type needs no edit here
  • test/oracles/ — The bug-finding system (docs/oracles.md): claims.json quotes every promise the consumer-facing docs make and names the test that holds it (empty heldBy is the worklist); oracles.json registers the sweep classes; seeds/*.patch plant one defect each in src/ that the named oracles were shown to catch; surface-waivers.json names the public members no claim covers, with the reason; candidates.json holds disagreements found but not fixed, keyed to issues; postgres-surface.json holds every PostgreSQL range, multirange and array operator and function (one entry per row of postgres-surface.catalog.txt, the raw pg_operator/pg_proc listing) with the member that mirrors it, the translation a test asserts, or a waiver or issue; translation-support.json is the other direction: every public query member (QueryMembers, discovered from the public API, one key per overload) classified as running as SQL, in memory only (with the reason) or planned (with the issue), which TranslationSupportInventoryTests holds complete and TranslationSupportProbeTests (integration) holds true against EF Core over every closed type; CloneSweep.cs is the clone-divergence sweep over the hand-copied type families (sets, arities, ranges, EF registrations), and clone-waivers.json explains every divergence it finds between siblings that is right as it is. Oracle*Tests and CloneDivergenceTests in the conventions project hold all of it consistent in CI without building; dotnet run tools/oracles.cs seeds re-proves locally that every seed is caught, … status prints the worklist, … pg-surface regenerates the listing from a container, … clones prints the sibling divergences with their waiver status
  • tools/oracles.cs — The local prover and status report for test/oracles/; file-based app, never run in CI
  • test/consumer-smoke-test.sh — The only check of delivery rather than code: packs the four packages, restores them into throwaway projects created outside the repository (inside it, Directory.Build.props would apply), compiles and runs real code against them, and asserts on printed output and translated SQL — never on the exit code alone. Before the consumers it reads each .nupkg's nuspec: the release notes open with the version's changelog section in plain text and end with the changelog at the version's tag. Five consumers: core only; a Native AOT publish of core + NodaTime, run as a native binary (extra linker paths via SMOKE_AOT_LINKER_ARGS, e.g. for Homebrew's dotnet; SMOKE_AOT_SDK pins that consumer to another installed SDK through a global.json beside it and targets its framework, which is how the .NET 11 SDK's ILCompiler is run over the net10.0 packages by hand (#299) until #297 gives the 10.x CI a leg for it); the NodaTime EF satellite, which pulls in all four through the nuspec chain; samples/Bookings run as a web app against its own PostgreSQL container (VALUERANGES_POSTGRES_IMAGE; skipped without Docker, a failure under CI=true), asserting every response, SQL line and stored row docs/walkthrough.md shows; and the EF package beside EFCore.ComplexIndexes.PostgreSQL from nuget.org, asserting its exclusion and temporal constraints over this package's columns in the create script. NUGET_PACKAGES is redirected to a private folder because NuGet resolves id+version from the global cache before any source, so a locally built package is otherwise shadowed by whatever build of that version was restored before; the generated nuget.config uses package source mapping so this repo's ids come from the local feed only and everything else from nuget.org. Runs on every PR in both modes (own feed; the pre-built dist the release workflow passes). Everything is discovered (projects by globbing src/, types by reflection), so adding a package or a type needs no edit here
  • samples/Bookings/ — The minimal-API booking service docs/walkthrough.md walks through. Not in the solution: samples/Directory.Build.props stops the repo's props, the smoke test runs it against the packed packages, and WalkthroughSampleTests (integration) links its source to hold the page's claims. WalkthroughSampleConventionTests fails when a C# block on the page is not a contiguous run of the file its <!-- sample: … --> marker names, when an output block on the page is not asserted by the smoke test, or when Bookings.csproj names another package version than the newest release on nuget.org (PackageValidationBaselineVersion) or another framework than that release's. GitHub's dependency submission restores every project from nuget.org, so an unreleased version fails it; move the sample with the baseline after each release, and regenerate its migration with that release's EF Core tools when the major changes (an EF Core 11 snapshot does not compile against EF Core 10). The smoke test moves the copy to Version and this line's framework
  • docs/ — Agent docs (read relevant doc before starting work)

Commands

dotnet build                          # Build everything
dotnet test                           # Run all tests
dotnet test --filter "ClassName=RangeContainsTests"   # Single test class
dotnet test --filter "FullyQualifiedName~Contains_FiniteRange"  # Single method
dotnet pack -c Release                # Pack NuGet packages (builds first)

The build treats warnings as errors (Directory.Build.props), including CS1591 on the public surface of the shipping projects — the shipped .xml is what IntelliSense shows. Deliberate internal-API usage (EF1001) is acknowledged with a file-level pragma next to the reason, never repo-wide.

Pack writes each package's release notes (nuget.org's Release Notes tab, Visual Studio's Updates tab) from the ## [$(Version)] section of its own src/<package>/CHANGELOG.md, through the target in Directory.Build.targets. They are plain text, because nuget.org shows release notes as typed and links only bare URLs: a link becomes text (url), and backticks, bold and ### are dropped. Pack fails, naming the file, when the section is missing, empty, doubled or over nuget.org's 35,000 characters, so a version bump needs its section in all four package changelogs before dotnet pack runs. Never set PackageReleaseNotes anywhere else.

Workflow

  1. Read docs/architecture.md before modifying range types or interfaces
  2. Explore the codebase — range operations are per-shape (Finite, UnboundedStart, etc.)
  3. Run dotnet test after each change; tests are method-level parallel
  4. Prove every fix by reverting it. Strip only the fix, confirm the new test fails for the expected reason, restore. A test that passes with and without the fix is not a regression test — and one written against broken code can assert nothing at all without looking any different. The same applies to a new convention test: seed the defect it claims to catch. For an oracle sweep, keep the revert as test/oracles/seeds/<defect>.patch naming the oracles it fails (OracleSeedTests requires one per registered oracle), and run dotnet run tools/oracles.cs seeds before a release
  5. Finding bugs is systematic, not one-off. Start from dotnet run tools/oracles.cs status: an unheld claim or an unwaived public member is the next oracle to write, and a new sweep goes into oracles.json with a seed. A new public member fails OracleSurfaceCoverageTests until a claim names it or surface-waivers.json says why none should; a reworded doc sentence fails OracleCatalogueTests until its claim follows. A disagreement a sweep finds goes through Candidates.Verdict into candidates.json with its issue, never into an Assert.Inconclusive or a deleted row; the entry fails as candidate fixed once the defect is gone. A fix commit carries Found-By: and Defect-Class: trailers (CONTRIBUTING.md)
  6. Commit with conventional format: feat:, fix:, refactor:, docs:, test:, build:

Issues

Work is tracked as GitHub issues, not as lists in docs or memory. Open the issue before starting the work, and close it from the PR (Closes #n).

  • Labels, on issues and PRs alike (the EntglDb/BLite scheme): exactly one type (bug, enhancement, documentation, tests, chore); every package/* the change affects (core, nodatime, efcore, efcore-nodatime); the area/* concern (ranges, range-set, value-sets, json, literals, aot, release); and severity/* on a bug. Add silent to a bug that answers wrongly without throwing. Its fix went in the next major up to 10.0.0; after 10.0.0 it ships as a minor on every supported line (never a patch), whose changelog entry opens with ### Changed answers, a row linking the issue and its docs/migration.md section (pr-hygiene.sh fails a silent PR into a minor without that row). Add breaking to anything that changes an existing answer or signature, found-by/oracle to what the generated-oracle machinery finds, and stacked to a PR whose base is another open PR
  • PRs carry their issue's labels and milestone, open with Closes #n, and describe the problem, the change and how it was verified
  • Epics: a release is an epic issue with a milestone of the same version (9.1.0). Its work is attached as native sub-issues and carries the same milestone. A theme that spans releases is an epic without a milestone; a finding it produces belongs to the release epic it ships in
  • Roadmap: the pinned roadmap issue (#34) holds every epic as a sub-issue
  • Branches: up to two lines are supported at once, 10.x (LTS, .NET 10) and 11.x (.NET 11). Until the v10.0.0 tag: main is 10.0.0, and release/9.x holds 9.x patches and then takes no changes. There is no preview branch. At the tag, release/10.x is cut from it, and main is retargeted to .NET 11 (#103) and ships 11.0.0-preview.N until 11.0.0. After it, a change for both merges into main first and is cherry-picked (-x) into a PR against release/10.x; additions ship as 10.x minors for six months after v11.0.0, after which 10.x takes fixes only. An issue fixed only on a release branch is closed by hand, because Closes #n acts only on main. SECURITY.md has one row per line; the other line's latest release is CompanionLineRelease in Directory.Build.props. See CONTRIBUTING.md#branches-and-releases

Docs

  • docs/architecture.md — Discriminated union pattern, interface hierarchy, RangeSet internals
  • docs/testing.md — Test organization, patterns, shape-combination matrix
  • docs/oracles.md — The claim catalogue, API-surface coverage, seeds and candidate registry: how bug-finding is run as a system

Critical Rules

  • NEVER create external subtypes of range base records — the private constructor is what makes the five variants the only ranges that can exist, so a switch over them is complete in fact. C# never proved it (exhaustiveness analysis ignores a closed class hierarchy, so a switch expression still warns CS8509 and needs a throwing discard — see docs/ranges.md#pattern-matching), which is exactly why breaking the rule is unrecoverable: the guarantee is entirely a runtime one, and nothing would fail at compile time
  • ALWAYS preserve RangeSet's invariant (sorted, disjoint, non-adjacent, no empties) on every code path that constructs or mutates a set
  • Do NOT add new range types without verifying the generic engines cover them — a new type must implement IRange<T> + IRangeFactory<TRange, T> with the five sealed variants; the engines in Internals/ dispatch per shape through the structural interfaces. The procedure is docs/architecture.md#adding-a-range-or-set-type: measure the domain on a live server, copy the nearest sibling on every axis, and let the discovered tests and the clone sweep list what is left — there is deliberately no template or scaffold
  • Ranges — NEVER decide a binary operation by switching on the receiver's shape. A binary relation is a function of the pair of shapes: read the bounds it actually compares, or switch on (left, right). Switching on the receiver and handling the operand's shapes in an inner switch has now produced the same bug four times — IsAdjacentTo (fixed 6.2.1), IsStrictlyLeftOf and Except (both 7.0.0), and RangeSet.Except(TRange) with an infinity operand (8.0.0). The tell is an inner switch with a _ fallback: it answers false, or returns the receiver unchanged, for exactly the operand shapes nobody wrote an arm for. This applies to value-producing operations, not just predicates — Except returned a well-formed range holding the wrong values, which is the harder one to notice. It hides well because the EF translation is correct, so the disagreement is between memory and the database rather than inside either. ShapeMatrixParityTests asks PostgreSQL for every ordered shape pair and is the check that catches it
  • Ranges — a shape dispatch's _ arm MUST throw, never produce a value. C# cannot prove a switch over interface patterns exhaustive, so the discard arm cannot be removed — the point is that it stops answering. Since 8.0.0 the three engines have one entry point each taking IRange<T> on both sides, switch over (left, right) with one arm per accepted pair, and throw ShapePair.Unreachable naming the pair for the rest, so an unhandled pair is a missing line and a loud failure rather than a plausible value. EngineDispatchConventionTests parses src/**/Internals/ and enforces both halves — throwing discards, and entry points that are not typed by one operand's shape
  • JSON and TypeConverter — every range union, every sealed variant, RangeSet and every value set type MUST carry its own [JsonConverter] and [TypeConverter]. For [TypeConverter] the variant trap is reversed: it is inherited, so a variant without its own silently uses the union's and answers with whatever shape the literal has; TypeConverterContractTests catches that. Since 9.0.0 the literal is the wire form with nothing registered. System.Text.Json does not inherit the attribute, and a value reached through object is handed over as its variant — an attribute on the union alone silently leaves those on the {"Length":…} property dump. Non-generic types name a closed converter (source-generation friendly); generic ones name RangeJsonConverterFactory, because an attribute cannot name an open generic converter. JsonDefaultContractTests discovers every type and fails on a missing one. The range and set interfaces carry InterfaceDeclaredJsonConverter (write through the runtime type, refuse a read), and a variant converter defers to a union converter registered on the options — both so a value's wire form never depends on how it was declared
  • AOT — a generic range set or value set type MUST register its own closed converters (internal static readonly bool ConvertersRegistered = GenericConverters.Register(…)), and nothing may build a converter with MakeGenericType. Under Native AOT an instantiation over a value type that no compiled code creates does not exist, and whether one does depends on unrelated code — an AOT test can pass by accident. Both libraries build with IsAotCompatible; the NativeAOT consumer in test/consumer-smoke-test.sh is the only check that code is actually compiled (seed a defect there, not in a unit test)
  • Literals — NEVER let an IFormatProvider reach the literal. Parse/TryParse read the invariant literal and ignore the provider; formatting without a format string is the invariant literal whatever the provider (a provider applies only with an explicit format). Before 9.0.0, th-TH parsed 2026-01-01 as Gregorian 1483 and de-DE turned $"{DecimalRange [1.5,2)}" into [1,5,2) → [1,52), silently. LiteralProviderContractTests and LiteralCultureContractTests sweep every type under five cultures
  • Literals — temporal element text is strict ISO 8601, NEVER a lenient BCL Parse. IsoTemporal (BCL) and NodaPatterns (NodaTime) accept exactly what this library writes and what PostgreSQL prints in ISO DateStyle — space separator, +05 / +05:30 / ±HH:mm:ss offsets, infinity / -infinity as the extremes — plus HH:mm. A DateTime offset is accepted and never applied — the wall clock is kept, as Local, which is PostgreSQL's timestamp input rule; converting it made one literal a different tsrange value on every machine. Before 9.0.0 DateOnly.Parse(invariant) read [01.02.2027,05.03.2027) month first and an offset-less DateTimeOffset took the machine's offset, silently. A wrapper arity parses as its primitive first and hands the element its round-trip form, and a closed temporal set reads its JSON elements through the same grammar whenever System.Text.Json's built-in converter would have (a registered converter still wins). TemporalLiteralStrictnessContractTests refuses a non-ISO element through every entry point of every discovered temporal type
  • EF Core: new range types are wired exclusively through RangeTypeRegistry.Register (satellites call it from their options-builder extension); never bypass the registry
  • EF Core — a translator maps an operand from the type its overload declares, and throws MissingArm for any other. So does the interceptor's RangeSetContains.Equivalent, which picks the static form of a RangeSet.Contains overload by its operand type (#320). The translators recognize an overload by its name, and RangeSet calls mapped every operand that was neither the set nor the element as a range, so an overload added later with another operand type would have become a range operator over the wrong mapping, without an error (#295) — the receiver-shaped dispatch trap one layer up. Declining is not enough: EF Core's own translators match by name too, as its collection Contains took RangeSet.Contains (#288). TranslatorDispatchSweepTests hands every public overload to the translators, so a new one without an arm fails there first
  • EF Core — NEVER replace, register or derive from IMigrationsModelDiffer or IMigrationsSqlGenerator. EF resolves each as a single service, so when two packages replace one the last registration wins and the other's migration DDL disappears without an error. From 11.0.0 on, CodoMetis.EFCore.SchemaKit owns both in the CodoMetis family: a migration-aware feature goes into SchemaKit or plugs into its contract, never into a replacement here. The custom store types (timerange, float8range, float4range) are created through Npgsql's HasPostgresRange, which needs neither. MigrationServiceOwnershipConventionTests fails on a shipping type implementing either service and on any reference to them in code under src/ (ReplaceService, TryAdd, ServiceDescriptor, a base list); comments may name them
  • EF Core — a temporal value written as text MUST be the value Npgsql would bind. A JSON document and a SQL literal hold the microsecond truncated toward 2000-01-01 (StoreTemporal / NodaStoreTemporal, passed as a definition's storeValue — never folded into normalizeValue, which must stay the core's NormalizeElement) and spell the extremes Npgsql maps as -infinity / infinity; ranges are rebuilt through their factory afterwards and sets deduplicated. PostgreSQL's text input rounds half-to-even, so seven fraction digits make a document or constant a different value from the column. The JSON writer resolves its store text through the registries (StoreText<T>), never from state a definition's constructor set — a compiled model may never build a wrapper family's definition. TemporalStoreParityTests (integration) is the authority
  • EF Core — an inlined range constant arrives typed as its sealed variant. EF evaluates a range built from query-independent values on the client and inlines it as Constant(value, Int32Range.Finite), for which no mapping exists; RangeVariantConstantRetyper in the query interceptor retypes it to the union. Do not "fix" this by mapping the variant types — that would make variant-typed properties mappable
  • EF Core — a constant element operand must be typed. The range operators are polymorphic (anyrange @> anyelement) and PostgreSQL resolves those without implicit coercions, so a bare numeric literal (which it types as integer) does not match int8range. Assert the whole operand in translation tests, never the @> prefix, and execute at least one constant-operand query per element type against a live server
  • Value sets — ALWAYS preserve canonical form (deduplicated, sorted by the family's canonical comparer, no nulls) on every construction path, including reads. String-backed families sort ordinal — never culture, never the element's own IComparable
  • Value sets — NEVER implement IEnumerable<T> on a set type (EF would discover it as a primitive collection), and never let SetTypeRegistry match by store-type name (text[] belongs to the provider's native string[] mapping). New set types are wired exclusively through SetTypeRegistry.Register
  • Value sets: Count/IsEmpty must stay instance properties (extension properties cannot appear in expression trees — CS9296 — and would be untranslatable)
  • Value sets — a set type that normalizes or validates elements in From MUST also override IValueSet<T>.NormalizeElement (and, on the EF side, pass that same function as the definition's normalizeValue). Contains/Add/Remove take a bare element, so without it the probe is compared un-normalized against normalized storage — a silently wrong answer client-side and a wrong bound parameter server-side
  • Value sets — a set type that overrides CanonicalComparer MUST also override IValueSet<T>.CanonicalOrder to return it. Contains/Add/Remove binary-search the canonical array; searching with an order the array was not sorted by misses elements that are present
  • Value sets — a set type whose element type System.Text.Json cannot serialize as a scalar MUST override IValueSetFactory<TSet,T>.ElementJsonConverter. JSON delegates elements to the serializer, which property-dumps an unknown type on write and yields default on read — silently, both legs. The hook is consulted last (only when GetTypeInfo(typeof(T)).Kind != None), so it never overrides a registered converter. The primitive-backed families are natively serializable and leave it null; the wrapper arities and the five NodaTime sets define one. Integer-backed wrappers write a JSON number, not a string, and decimal-backed ones a number keeping scale — the token type must always match the primitive the wrapper wraps. Byte-for-byte parity additionally holds for the string, Guid, integer and decimal arities; for the temporal and NodaTime ones the text differs (seven fraction digits, + escaped) while the value does not, which is asserted in SetJsonConverterTests rather than fixed
  • Value sets — a wrapper arity over a type whose default text form is lossy MUST pin a round-trip format, in FormatValue and in its SetTypeRegistry bridge entry, and parse it strictly. TimeOnly.ToString(null, invariant) is 09:30 and DateTime's is 06/15/2024 10:30:00 — an arity built like Int32Set<T> stores every temporal truncated to the second, and loses DateTimeKind, silently on the way to the column. The four temporal arities and the five NodaTime ones do this — since 9.0.0 the core ParseValue parses strictly too, not only the EF bridge; the string, Guid, integer and decimal ones correctly take the default
  • Value sets — canonical form in SQL is built by ValueSetSqlGuard, never by a translator. For every family with CanonicalizesInSql, the translator emits ValueSetSqlGuard.CanonicalUnion/CanonicalAdd/CanonicalIntersect/CanonicalExcept and the guard's CanonicalExpander builds the subquery: it needs table aliases, and EF Core's alias pass (which runs after translation and before the guard) parses every alias as one letter and a number, while a translator has no SqlAliasManager. Keep ORDER BY x, ordinality with DISTINCT ON: it keeps the first copy of each value as ValueSetCore does (1.0 vs 1.00, -0 vs 0); a plain DISTINCT keeps any copy (seed canonical-set-keeps-any-copy). Drop NULL elements (WHERE x IS NOT NULL), and write the multiset form only where order and duplicates cannot change the answer. A family joins only if PostgreSQL orders its store type as the family's canonical comparer does; a wrapper arity relies on its element's CompareTo ordering as the primitive does (docs/value-sets.md). CanonicalSetOperationParityTests (integration) is the authority
  • Value sets — a text Union, Add, Intersect or Except is NOT canonical in SQL. PostgreSQL sorts text by the database collation, so the translator emits ValueSetSqlGuard.UnguardedUnion/UnguardedAdd/UnguardedIntersect/UnguardedExcept and the guard builds each unsorted: array_cat, array_cat with a one-element array, or a filter of the first array. Only order- and multiplicity-insensitive uses may compose on such a value: @>, <@, && (the proper subset/superset pair is written as <@ AND NOT @> for exactly that reason), cardinality(…) = 0, a null test, the outermost projection (materialization canonicalizes), and two the guard rewrites (#485): cardinality becomes (SELECT count(DISTINCT x) FROM unnest(…)), NULL over a NULL set, and =/<>/NULLIF with such a value on a side become mutual containment, a @> b AND b @> a. A stored-column = keeps = and its index. No COLLATE "C" anywhere: a collated operand turns a GIN-indexed @> into a sequential scan, and #483 rejects a collation on a string set column that may not compare bytes, so the column's own equality is byte equality. Since 9.0.0 everything else is refused on the final SQL tree by ValueSetSqlGuard, run from a decorator around the provider's IRelationalParameterBasedSqlProcessorFactory (every query, ExecuteUpdate and ExecuteDelete passes through it). It fails closed: only the guard builds the placeholders, so a ReplaceService that drops the decorator makes every server-side text operation fail on the server instead of silently skipping the refusals. Never emit array_cat directly, and never match it by name before the guard has run. The walker follows values derived from one — through array_remove (which preserves canonical form rather than establishing it), CASE, COALESCE, NULLIF and subquery/set-operation columns — and the expander recognizes the same set structurally plus the walker's derived columns; GROUP BY, DISTINCT, ORDER BY, deduplicating set operations, IN and UPDATE setters throw. Never go back to recognizing these in LINQ: the 8.0.0 guard matched == inside a list of operators and a projection, a ternary, a coalesce, GroupBy, Distinct and ExecuteUpdate all translated and answered wrongly. The same guard refuses to_jsonb(<value set>) in an UPDATE setter — ExecuteUpdate of a value set inside a JSON column, which writes a JSON array where the document holds the literal, after which the row no longer loads — keyed off the translated setter, so a join or EF.Property cannot bypass it. UnionCanonicalityTests, the text sweep in CanonicalSetOperationParityTests and ExecuteUpdateJsonTests (integration) assert results, not SQL. Do not "fix" text by sorting it in SQL: ORDER BY on text uses the database collation, not ordinal, so it would disagree with the client's canonical order — the same defect one layer down (#479 decides whether text moves to code point order and COLLATE "C")