diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml
index 53c6e40c..d5fa2aff 100644
--- a/.github/workflows/canary.yml
+++ b/.github/workflows/canary.yml
@@ -125,3 +125,37 @@ jobs:
exit 1
fi
echo "ok: the net8 fce and worker documented a net8 target on the .NET ${newest} preview runtime"
+
+ # Beyond the tooling above, layer BEHAVIOURAL coverage of the netstandard2.0 LIBRARIES on the preview. The fce
+ # step already proves the library LOADS on the preview runtime (the worker reflects over a netstandard2.0 target
+ # there); this runs the libraries' own unit and property suites on it, so a runtime regression that breaks
+ # behaviour — not just loading — turns the scheduled run red before that major ships. The test projects target
+ # net10.0; only the `dotnet test` execution is wrapped in DOTNET_ROLL_FORWARD, so the net10 test host binds the
+ # highest installed major (the preview) while the build stays on the .NET 10 SDK. Best-effort and non-blocking
+ # like the rest of the canary. RequestBinder.UnitTests IS included here (unlike the net472 floor job): DateOnly,
+ # which its fixtures bind, exists on every modern .NET — it is only absent from .NET Framework.
+ - name: Test the libraries on the preview runtime
+ if: steps.preview.outcome == 'success'
+ shell: bash
+ run: |
+ set -euo pipefail
+ echo "Installed .NET runtimes:"
+ dotnet --list-runtimes
+ newest="$(dotnet --list-runtimes | sed -nE 's/^Microsoft\.NETCore\.App ([0-9]+)\..*/\1/p' | sort -n | tail -1)"
+ if [ "${newest:-0}" -le 10 ]; then
+ echo "::notice::no runtime newer than the .NET 10 build SDK is installed; skipping the library preview run"
+ exit 0
+ fi
+ for proj in \
+ FirstClassErrors.UnitTests \
+ FirstClassErrors.PropertyTests \
+ FirstClassErrors.RequestBinder.UnitTests \
+ FirstClassErrors.RequestBinder.PropertyTests ; do
+ echo "::group::$proj on the .NET ${newest} preview"
+ # Build on the .NET 10 SDK (no roll-forward), then run ONLY the test host under roll-forward so it binds
+ # the preview major. The env is scoped inline to the test call so it never touches build/evaluation.
+ dotnet build "$proj/$proj.csproj" -c Release
+ DOTNET_ROLL_FORWARD=LatestMajor DOTNET_ROLL_FORWARD_TO_PRERELEASE=1 \
+ dotnet test "$proj/$proj.csproj" -c Release --no-build --logger "console;verbosity=normal"
+ echo "::endgroup::"
+ done
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 33d986a7..590b346d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -70,6 +70,50 @@ jobs:
path: artifacts/coverage/**/coverage.opencover.xml
if-no-files-found: error
+ # build-test above runs the suite on the LATEST .NET (10). This job proves the LIBRARY's OTHER supported
+ # runtime: that the netstandard2.0 assemblies (FirstClassErrors, FirstClassErrors.Testing,
+ # FirstClassErrors.RequestBinder) actually load and pass their tests on .NET Framework 4.7.2 — the support
+ # floor recorded in doc/handwritten/for-maintainers/adr/0022-floor-the-library-on-net-framework-4-7-2.md.
+ # netstandard2.0 is only a COMPILE contract; running on the real .NET Framework CLR (NLS globalization, the
+ # netstandard.dll facade, its reflection stack) is what proves the library behaves there — the ordinary net10
+ # build cannot. .NET Framework tests run on Windows only (there is no Mono on the Linux legs), and the net472
+ # inner build is gated behind EnableNet472Floor (build/Net472TestFloor.props) so it never touches build-test.
+ framework-floor:
+ name: Library on the .NET Framework 4.7.2 floor
+ runs-on: windows-latest
+ # Restore + build + test of three small projects; cap a hung run like the other jobs.
+ timeout-minutes: 15
+ steps:
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
+
+ # global.json pins the .NET 10 SDK; that SDK compiles the net472 target. The .NET Framework runtime that
+ # RUNS net472 assemblies ships on the windows-latest image, and the net472 targeting pack for COMPILATION
+ # is supplied hermetically by the Microsoft.NETFramework.ReferenceAssemblies package — so no extra install.
+ - name: Setup .NET (build SDK)
+ uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5
+ with:
+ dotnet-version: '10.0.x'
+
+ # Only the three library test projects carry the net472 leg (EnableNet472Floor adds it): the tooling test
+ # projects (Roslyn / GenDoc / Cli) stay net10-only by design, and RequestBinder.UnitTests stays net10-only
+ # because its fixtures bind DateOnly, a .NET 6+ type absent from net472. RequestBinder is still floored here
+ # through its property tests. A per-project loop (not a solution-wide -f net472, which would force the TFM
+ # onto the net10-only projects and fail) keeps the net472 scope exactly these three.
+ - name: Test the netstandard2.0 libraries on .NET Framework 4.7.2
+ shell: bash
+ run: |
+ set -euo pipefail
+ for proj in \
+ FirstClassErrors.UnitTests \
+ FirstClassErrors.PropertyTests \
+ FirstClassErrors.RequestBinder.PropertyTests ; do
+ echo "::group::$proj (net472)"
+ dotnet test "$proj/$proj.csproj" -c Release -f net472 -p:EnableNet472Floor=true \
+ --logger "console;verbosity=normal"
+ echo "::endgroup::"
+ done
+
# build-test above runs the whole suite on the LATEST .NET (10). This job proves the other end of the
# supported range: that the fce tool and its worker, which ship targeting net8.0 (the floor — see
# doc/handwritten/for-maintainers/adr/0002-floor-the-tooling-runtime.md), actually run on the .NET 8 RUNTIME. The net8 TFM
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 54b8d091..f1a268bc 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -31,6 +31,7 @@
+
diff --git a/FirstClassErrors.PropertyTests/FirstClassErrors.PropertyTests.csproj b/FirstClassErrors.PropertyTests/FirstClassErrors.PropertyTests.csproj
index 9c417637..ce92872f 100644
--- a/FirstClassErrors.PropertyTests/FirstClassErrors.PropertyTests.csproj
+++ b/FirstClassErrors.PropertyTests/FirstClassErrors.PropertyTests.csproj
@@ -1,7 +1,10 @@
+
+
+
- net10.0
enable
enable
false
diff --git a/FirstClassErrors.RequestBinder.PropertyTests/FirstClassErrors.RequestBinder.PropertyTests.csproj b/FirstClassErrors.RequestBinder.PropertyTests/FirstClassErrors.RequestBinder.PropertyTests.csproj
index 3c3ac82f..d5d3914f 100644
--- a/FirstClassErrors.RequestBinder.PropertyTests/FirstClassErrors.RequestBinder.PropertyTests.csproj
+++ b/FirstClassErrors.RequestBinder.PropertyTests/FirstClassErrors.RequestBinder.PropertyTests.csproj
@@ -1,7 +1,12 @@
+
+
+
- net10.0
enable
enable
false
diff --git a/FirstClassErrors.RequestBinder.PropertyTests/ListBindingInvariantTests.cs b/FirstClassErrors.RequestBinder.PropertyTests/ListBindingInvariantTests.cs
index 14c32a7e..bde2fa8e 100644
--- a/FirstClassErrors.RequestBinder.PropertyTests/ListBindingInvariantTests.cs
+++ b/FirstClassErrors.RequestBinder.PropertyTests/ListBindingInvariantTests.cs
@@ -44,7 +44,10 @@ public void ErrorPathsAreExactlyThePositionsOfFailingElements() {
public void ReorderingElementsReordersTheirErrorPathsAccordingly() {
Prop.ForAll(BinderGen.Slots().ToArbitrary(),
slots => {
- (string? Raw, bool Fails)[] reversed = slots.Reverse().ToArray();
+ // AsEnumerable() pins the LINQ Reverse (a reversed copy). A bare slots.Reverse() binds instead
+ // to the void, in-place MemoryExtensions.Reverse(Span) on the net472 floor, where
+ // System.Memory is in the graph (see build/Net472TestFloor.props).
+ (string? Raw, bool Fails)[] reversed = slots.AsEnumerable().Reverse().ToArray();
return FailingPaths(BindTokens(slots)).SetEquals(PositionsOfFailures(slots))
&& FailingPaths(BindTokens(reversed)).SetEquals(PositionsOfFailures(reversed));
diff --git a/FirstClassErrors.UnitTests/ErrorCodeTests.cs b/FirstClassErrors.UnitTests/ErrorCodeTests.cs
index 99e90739..1fc7e8ab 100644
--- a/FirstClassErrors.UnitTests/ErrorCodeTests.cs
+++ b/FirstClassErrors.UnitTests/ErrorCodeTests.cs
@@ -31,10 +31,15 @@ public void CreatingErrorCodeWithValidCodeSucceeds(string code) {
[InlineData("")]
[InlineData(" ")]
public void CreatingErrorCodeWithBlankCodeIsRejected(string? code) {
- // Exercise & verify
- Check.ThatCode(() => ErrorCode.Create(code!))
- .Throws()
- .WithMessage("Error code cannot be null or whitespace. (Parameter 'code')");
+ // Exercise
+ ArgumentException exception = Assert.Throws(() => ErrorCode.Create(code!));
+
+ // Verify the contract — the message content and the offending parameter — not the
+ // ArgumentException.Message parameter-suffix formatting, which differs between .NET Framework
+ // ("...\nParameter name: code") and modern .NET ("... (Parameter 'code')"). The netstandard2.0
+ // library runs on both; see the framework-floor job in .github/workflows/ci.yml.
+ Check.That(exception.Message).Contains("Error code cannot be null or whitespace.");
+ Check.That(exception.ParamName).IsEqualTo("code");
}
[Fact(DisplayName = "Creating the same code twice is allowed and produces equal instances.")]
diff --git a/FirstClassErrors.UnitTests/ErrorContextKeyTests.cs b/FirstClassErrors.UnitTests/ErrorContextKeyTests.cs
index a3d2447f..4332d470 100644
--- a/FirstClassErrors.UnitTests/ErrorContextKeyTests.cs
+++ b/FirstClassErrors.UnitTests/ErrorContextKeyTests.cs
@@ -75,10 +75,13 @@ public void AKeyCannotBeCreatedWithANullDescriptionProvider() {
[InlineData("")]
[InlineData(" ")]
public void RegisteringKeyWithBlankNameIsRejected(string? name) {
- // Exercise & verify
- Check.ThatCode(() => ErrorContextKey.Create(name!))
- .Throws()
- .WithMessage("Value cannot be null or whitespace. (Parameter 'name')");
+ // Exercise
+ ArgumentException exception = Assert.Throws(() => ErrorContextKey.Create(name!));
+
+ // Verify the contract, not the framework-specific parameter-suffix formatting of
+ // ArgumentException.Message (same net472-floor rationale as ErrorCodeTests).
+ Check.That(exception.Message).Contains("Value cannot be null or whitespace.");
+ Check.That(exception.ParamName).IsEqualTo("name");
}
[Fact(DisplayName = "Re-declaring a key with the same name and type returns the registered instance.")]
diff --git a/FirstClassErrors.UnitTests/FirstClassErrors.UnitTests.csproj b/FirstClassErrors.UnitTests/FirstClassErrors.UnitTests.csproj
index 2f8010a6..f1d40488 100644
--- a/FirstClassErrors.UnitTests/FirstClassErrors.UnitTests.csproj
+++ b/FirstClassErrors.UnitTests/FirstClassErrors.UnitTests.csproj
@@ -1,7 +1,10 @@
+
+
+
- net10.0
enable
enable
false
@@ -32,4 +35,11 @@
+
+
+
+
+
diff --git a/FirstClassErrors/README.nuget.md b/FirstClassErrors/README.nuget.md
index 800c3b55..8cec5349 100644
--- a/FirstClassErrors/README.nuget.md
+++ b/FirstClassErrors/README.nuget.md
@@ -31,7 +31,7 @@ checked at build time and can be extracted into a living error catalog.
separate install. They catch, at build time, what would otherwise surface late:
duplicate error codes, unresolved `[DocumentedBy]` references, documented errors
missing from the catalog, an unused `ToException()` result, and more.
-- **Zero runtime dependencies, .NET Standard 2.0.** Runs on .NET Framework 4.6.1+,
+- **Zero runtime dependencies, .NET Standard 2.0.** Runs on .NET Framework 4.7.2+,
.NET Core 2.0+, .NET 5+ (and Mono / Xamarin / Unity). Nothing added to your
dependency graph.
diff --git a/build/Net472TestFloor.IsExternalInit.cs b/build/Net472TestFloor.IsExternalInit.cs
new file mode 100644
index 00000000..f29de95d
--- /dev/null
+++ b/build/Net472TestFloor.IsExternalInit.cs
@@ -0,0 +1,11 @@
+// Compiler-required marker behind C# `init` accessors and records. The .NET Framework 4.7.2 BCL does not
+// ship it, so this polyfill is compiled ONLY into net472 test builds (see build/Net472TestFloor.props). It is
+// internal, so it never leaves the test assembly and each net472 test assembly gets its own. The shipped
+// FirstClassErrors libraries use neither `init` nor records, so nothing in the product relies on this.
+
+// ReSharper disable once CheckNamespace -- the compiler resolves this type by its exact fully-qualified name.
+namespace System.Runtime.CompilerServices {
+
+ internal static class IsExternalInit { }
+
+}
diff --git a/build/Net472TestFloor.props b/build/Net472TestFloor.props
new file mode 100644
index 00000000..017ecb1d
--- /dev/null
+++ b/build/Net472TestFloor.props
@@ -0,0 +1,50 @@
+
+
+
+
+
+ net10.0
+
+
+ net10.0;net472
+
+
+
+
+ latest
+
+ Exe
+
+
+
+
+
+
+
+
+
+
diff --git a/doc/handwritten/for-maintainers/adr/0022-floor-the-library-on-net-framework-4-7-2.fr.md b/doc/handwritten/for-maintainers/adr/0022-floor-the-library-on-net-framework-4-7-2.fr.md
new file mode 100644
index 00000000..6f9f0333
--- /dev/null
+++ b/doc/handwritten/for-maintainers/adr/0022-floor-the-library-on-net-framework-4-7-2.fr.md
@@ -0,0 +1,168 @@
+# ADR-0022 | Fixer le floor .NET Framework de la librairie à 4.7.2
+
+🌍 🇬🇧 [English](0022-floor-the-library-on-net-framework-4-7-2.md) · 🇫🇷 Français (ce fichier)
+
+**Statut :** Accepté
+**Date :** 2026-07-19
+**Décideurs :** Reefact
+
+## Contexte
+
+Les librairies livrées — `FirstClassErrors`, `FirstClassErrors.Testing` et
+`FirstClassErrors.RequestBinder` — ciblent **`netstandard2.0`**. Un assembly
+`netstandard2.0` est *consommable* par n'importe quel runtime qui implémente le
+standard, et le standard désigne **.NET Framework 4.6.1** comme son minimum sur cette
+plateforme.
+
+Ce minimum 4.6.1 est rétro-ajouté. `netstandard2.0` est sorti après .NET Framework
+4.6.1, et sa prise en charge a été greffée après coup : sur 4.6.1 à 4.7.1, la façade
+`netstandard.dll` et un ensemble de shims `System.*` sont livrés comme des ressources
+NuGet et exigent des *binding redirects* côté consommateur pour se charger. **.NET
+Framework 4.7.2 est la première version qui embarque ces façades in-box**, et la
+recommandation officielle de Microsoft pour `netstandard2.0` est d'utiliser 4.7.2 ou
+ultérieur.
+
+Le support renforce la même ligne. .NET Framework 4.6, 4.6.1 (et 4.5.2) sont en fin
+de support depuis avril 2022 ; 4.6.2 est la plus ancienne encore maintenue ; et
+**4.8.1 est la dernière version de .NET Framework** — il n'y aura pas de 4.9.
+
+Jusqu'ici le produit annonçait, dans `FirstClassErrors/README.nuget.md` et comme une
+phrase incidente de la Justification de l'[ADR-0002](0002-floor-the-tooling-runtime.fr.md),
+que la librairie « tourne sur .NET Framework 4.6.1+ ». Rien dans la CI n'a jamais
+chargé les assemblies sur un runtime .NET Framework : `build-test` exécute la suite sur
+.NET 10 et le job `floor` n'exécute que *l'outillage* sur le runtime .NET 8. L'annonce
+de compatibilité n'a donc jamais été vérifiée.
+
+La stack de test est **xUnit v3**, dont la cible .NET Framework la plus basse est
+**`net472`** ; il n'existe aucun moyen supporté de faire tourner ces projets de tests
+sur un .NET Framework antérieur. La CI garde déjà les deux autres bornes runtime du
+produit : le floor .NET 8 de l'outillage ([ADR-0002](0002-floor-the-tooling-runtime.fr.md))
+et, en amont de la publication, la prochaine preview de .NET (`canary.yml`).
+
+## Décision
+
+Le floor .NET Framework supporté pour les librairies `netstandard2.0` est **4.7.2**.
+
+## Justification
+
+4.7.2 est la plus basse version de .NET Framework sur laquelle la librairie tourne
+*sans plomberie côté consommateur* : c'est la première à embarquer les façades
+`netstandard2.0` in-box, si bien que la promesse « tourne presque partout » devient
+vraie au lieu d'être conditionnée à des *binding redirects*. C'est donc le floor
+honnête, là où 4.6.1 était le floor théorique.
+
+Une annonce de support ne vaut que ce qui la vérifie. La ligne 4.6.1 n'a jamais été
+exercée, ce qui est un passif pour une librairie dont la raison d'être est la
+diagnosticabilité en production. Fixer le floor à 4.7.2 rend l'annonce *vérifiable à
+chaque pull request*, car 4.7.2 est aussi le plus bas framework sur lequel la stack de
+test elle-même peut tourner — le même nombre clôt à la fois la question du support et
+celle de la vérification.
+
+4.6.x est le mauvais endroit pour ancrer le garde-fou. 4.6 et 4.6.1 sont en fin de vie,
+et la fragilité façade-et-binding-redirects de 4.6.1–4.7.1 rendrait un signal rouge
+ambigu — la faute de la librairie, ou celle de la plateforme ? Un floor existe pour
+donner un signal sans ambiguïté, et seule 4.7.2 en fournit un avec la stack actuelle.
+
+Fixer le floor de la librairie à 4.7.2 est le symétrique du floor .NET 8 de
+l'outillage : chaque borne runtime supportée est prouvée par son propre job, de sorte
+que le produit énonce ses runtimes supportés précisément plutôt que par affirmation.
+Cette décision **précise** la phrase incidente « 4.6.1 » de
+l'[ADR-0002](0002-floor-the-tooling-runtime.fr.md) sans la remplacer : la décision de
+cet ADR est le floor net8 de *l'outillage*, non le floor .NET Framework de la
+librairie, qui n'avait pas d'ADR propre jusqu'ici.
+
+## Alternatives envisagées
+
+### Continuer d'annoncer .NET Framework 4.6.1+ (statu quo)
+
+Envisagé parce que c'est le minimum `netstandard2.0` sur le papier et que cela
+n'exige aucun changement.
+
+Rejeté parce que cela n'a jamais été vérifié et ne peut l'être à moindre coût : la
+prise en charge de `netstandard2.0` par 4.6.1 est rétro-ajoutée et fragile, les
+versions concernées sont largement en fin de vie, et xUnit v3 ne peut pas cibler en
+dessous de `net472` — prouver l'annonce demanderait une seconde stack de test et des
+*binding redirects* côté consommateur, pour un runtime que presque plus personne ne
+devrait déployer.
+
+### Fixer le floor à 4.6.2 (la plus ancienne 4.6.x encore maintenue)
+
+Envisagé parce que 4.6.2, contrairement à 4.6/4.6.1, est encore maintenue, ce qui
+garderait le plus bas numéro encore supporté.
+
+Rejeté parce que 4.6.2 précède les façades in-box : elle porte la même fragilité de
+*binding redirects* que 4.6.1, et reste sous le floor de xUnit v3 — elle demeure donc
+invérifiable avec la stack actuelle, et le signal du garde-fou resterait ambigu.
+
+### Fixer le floor des librairies sur toute la matrice .NET moderne (net6/net8/… en jambes bloquantes)
+
+Envisagé comme la réponse conventionnelle « tester sur tout » pour une réassurance
+large.
+
+Rejeté parce que la librairie est un unique assembly `netstandard2.0` et que les
+runtimes modernes forment une seule famille CoreCLR : le delta comportemental entre
+majors, pour des value objects sans dépendance, est négligeable ; les majors en fin de
+vie ne devraient pas être « floorés » du tout ; et une matrice par-major réintroduit
+exactement le tapis roulant par-release que l'[ADR-0002](0002-floor-the-tooling-runtime.fr.md)
+a rejeté. La seule frontière qui compte est .NET Framework versus .NET moderne, que
+`net472` couvre, tandis que le dernier runtime (`build-test`) et la prochaine preview
+(`canary.yml`) couvrent déjà l'extrémité moderne.
+
+## Conséquences
+
+### Positives
+
+* Le support .NET Framework annoncé est désormais **vérifié à chaque pull request** par
+ un job Windows dédié, et non plus seulement affirmé.
+* Le floor est **gelé** : 4.8.1 est la dernière version de .NET Framework, donc ce
+ garde-fou ne poursuit jamais une cible mouvante et n'exige aucun entretien
+ par-release.
+* L'histoire des runtimes supportés du produit est symétrique et précise : un floor
+ .NET Framework de la librairie (4.7.2) et un floor de l'outillage (.NET 8), chacun
+ prouvé par son propre job, avec la prochaine preview surveillée en amont.
+
+### Négatives
+
+* Les consommateurs figés sur .NET Framework 4.6.1–4.7.1 perdent une annonce de
+ support qui n'avait jamais été vérifiée ; ils doivent être sur 4.7.2 ou ultérieur.
+ Accepté : 4.7.2 est le floor `netstandard2.0` pratique et les versions inférieures
+ sont largement en fin de vie.
+* Un petit polyfill `IsExternalInit` réservé aux tests et un chemin de build
+ conditionné à `net472` sont ajoutés aux projets de tests concernés. Les **librairies
+ livrées ne sont pas touchées** — elles n'utilisent ni `init` ni records — donc rien
+ dans le produit ne dépend du polyfill.
+
+### Risques
+
+* La jambe `net472` ne tourne que sous Windows ; une régression spécifique à
+ .NET Framework est invisible sur les jambes Linux jusqu'à l'exécution du job Windows.
+ Atténué en exécutant le job à chaque pull request.
+* `FirstClassErrors.RequestBinder.UnitTests` ne peut pas rejoindre le floor car ses
+ fixtures lient `DateOnly`, un type .NET 6+ absent de .NET Framework ; RequestBinder
+ est « flooré » via ses property tests à la place. Accepté : les scénarios exclus
+ exercent un type qui ne peut de toute façon pas exister sur `net472`.
+
+## Actions de suivi
+
+* Indiquer 4.7.2+ dans `FirstClassErrors/README.nuget.md` (fait dans ce changement).
+* Ajouter le job `framework-floor` à `ci.yml` et le fichier partagé
+ `build/Net472TestFloor.props` qui porte la jambe `net472` conditionnée (fait dans ce
+ changement).
+* Faire de `framework-floor` un **status check requis** dans la protection de branche
+ pour qu'il bloque les merges, conformément à l'intention que le floor soit imposé, et
+ non pas indicatif.
+* Si un mainteneur souhaite réconcilier la phrase incidente « 4.6.1 » de
+ l'[ADR-0002](0002-floor-the-tooling-runtime.fr.md), y ajouter une note d'errata
+ renvoyant à cet ADR ; cet ADR fait autorité sur le floor .NET Framework de la
+ librairie.
+
+## Références
+
+* [ADR-0002](0002-floor-the-tooling-runtime.fr.md) — le floor du runtime de
+ l'outillage ; la décision sœur, sur une borne runtime, que cet ADR précise.
+* [ADR-0001](0001-lock-the-analyzer-roslyn-floor.fr.md) — le floor Roslyn de
+ l'analyseur, la troisième borne d'outillage supporté.
+* `FirstClassErrors/README.nuget.md` — l'énoncé de support côté utilisateur.
+* `build/Net472TestFloor.props`, le job `framework-floor` dans
+ `.github/workflows/ci.yml`, et l'exécution des tests de librairies sur la preview
+ dans `.github/workflows/canary.yml` — là où cette décision est imposée.
diff --git a/doc/handwritten/for-maintainers/adr/0022-floor-the-library-on-net-framework-4-7-2.md b/doc/handwritten/for-maintainers/adr/0022-floor-the-library-on-net-framework-4-7-2.md
new file mode 100644
index 00000000..c3b5c52c
--- /dev/null
+++ b/doc/handwritten/for-maintainers/adr/0022-floor-the-library-on-net-framework-4-7-2.md
@@ -0,0 +1,157 @@
+# ADR-0022 | Floor the library's .NET Framework support at 4.7.2
+
+🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0022-floor-the-library-on-net-framework-4-7-2.fr.md)
+
+**Status:** Accepted
+**Date:** 2026-07-19
+**Decision Makers:** Reefact
+
+## Context
+
+The shipped libraries — `FirstClassErrors`, `FirstClassErrors.Testing` and
+`FirstClassErrors.RequestBinder` — target **`netstandard2.0`**. A `netstandard2.0`
+assembly is *consumable* by any runtime that implements the standard, and the
+standard names **.NET Framework 4.6.1** as its minimum on that platform.
+
+That 4.6.1 minimum is retrofitted. `netstandard2.0` shipped after .NET Framework
+4.6.1, and support for it was added back: on 4.6.1 through 4.7.1 the `netstandard.dll`
+facade and a set of `System.*` shims are delivered as NuGet assets and require
+consumer-side binding redirects to load. **.NET Framework 4.7.2 is the first version
+that ships those facades in-box**, and Microsoft's own `netstandard2.0` support
+guidance recommends 4.7.2 or later.
+
+Servicing reinforces the same line. .NET Framework 4.6, 4.6.1 (and 4.5.2) reached
+end of support in April 2022; 4.6.2 is the oldest still serviced; and **4.8.1 is the
+last .NET Framework** — there will be no 4.9.
+
+Until now the product advertised, in `FirstClassErrors/README.nuget.md` and as an
+incidental line in the Rationale of [ADR-0002](0002-floor-the-tooling-runtime.md),
+that the library "runs on .NET Framework 4.6.1+". Nothing in CI ever loaded the
+assemblies on a .NET Framework runtime: `build-test` runs the suite on .NET 10 and
+the `floor` job runs only the *tooling* on the .NET 8 runtime. The compatibility
+claim was therefore never verified.
+
+The test stack is **xUnit v3**, whose lowest supported .NET Framework target is
+**`net472`**; there is no supported way to run these test projects on an earlier
+.NET Framework. CI already guards the two other runtime bounds of the product: the
+tooling's .NET 8 floor ([ADR-0002](0002-floor-the-tooling-runtime.md)) and, ahead of
+release, the next .NET preview (`canary.yml`).
+
+## Decision
+
+The supported .NET Framework floor for the `netstandard2.0` libraries is **4.7.2**.
+
+## Rationale
+
+4.7.2 is the lowest .NET Framework version on which the library runs *without
+consumer-side plumbing*: it is the first to ship the `netstandard2.0` facades in-box,
+so the "runs almost everywhere" promise becomes true rather than conditional on
+binding redirects. It is therefore the honest floor, where 4.6.1 was the theoretical
+one.
+
+A support claim is only worth what verifies it. The 4.6.1 line was never exercised,
+which is a liability for a library whose entire purpose is production diagnosability.
+Flooring at 4.7.2 makes the claim *checkable on every pull request*, because 4.7.2 is
+also the lowest framework the test stack itself can run on — the same number closes
+both the support question and the verification question.
+
+4.6.x is the wrong place to anchor the guard. 4.6 and 4.6.1 are end-of-life, and the
+4.6.1–4.7.1 facade-and-binding-redirect fragility would make a red signal ambiguous —
+the library's fault, or the platform's? A floor exists to give an unambiguous signal,
+and only 4.7.2 provides one with the current stack.
+
+Flooring the library at 4.7.2 mirrors the tooling floor at .NET 8: each supported
+runtime bound is proven by its own dedicated job, so the product states its supported
+runtimes precisely instead of by assertion. This decision **refines** the incidental
+"4.6.1" claim in [ADR-0002](0002-floor-the-tooling-runtime.md) without superseding it:
+that ADR's decision is the *tooling's* net8 floor, not the library's .NET Framework
+floor, which had no ADR of its own until now.
+
+## Alternatives Considered
+
+### Keep advertising .NET Framework 4.6.1+ (status quo)
+
+Considered because it is the `netstandard2.0` minimum on paper and demands no change.
+
+Rejected because it was never verified and cannot be verified cheaply: 4.6.1's
+`netstandard2.0` support is retrofitted and fragile, the versions concerned are
+largely end-of-life, and xUnit v3 cannot target below `net472`, so proving the claim
+would require a second test stack and consumer binding redirects for a runtime almost
+nobody should still deploy.
+
+### Floor at 4.6.2 (the oldest serviced 4.6.x)
+
+Considered because 4.6.2, unlike 4.6/4.6.1, is still serviced, so it would keep the
+lowest still-supported number.
+
+Rejected because 4.6.2 predates the in-box facades: it carries the same
+binding-redirect fragility as 4.6.1, and it is still below the xUnit v3 floor, so it
+remains unverifiable with the current stack — the guard's signal would stay
+ambiguous.
+
+### Floor the libraries across the whole modern .NET matrix (net6/net8/… as blocking legs)
+
+Considered as the conventional "test on everything" answer for broad reassurance.
+
+Rejected because the library is a single `netstandard2.0` assembly and the modern
+runtimes are one CoreCLR family: the behavioural delta across majors, for
+zero-dependency value objects, is negligible; end-of-life majors should not be
+floored at all; and a per-major matrix re-introduces exactly the per-release treadmill
+that [ADR-0002](0002-floor-the-tooling-runtime.md) rejected. The single valuable
+boundary is .NET Framework versus modern .NET, which `net472` covers, while the latest
+runtime (`build-test`) and the next preview (`canary.yml`) already cover the modern
+end.
+
+## Consequences
+
+### Positive
+
+* The advertised .NET Framework support is now **verified on every pull request** by a
+ dedicated Windows job, not merely claimed.
+* The floor is **frozen**: 4.8.1 is the last .NET Framework, so this guard never
+ chases a moving target and needs no per-release upkeep.
+* The product's supported-runtime story is symmetric and precise: a library
+ .NET Framework floor (4.7.2) and a tooling floor (.NET 8), each proven by its own
+ job, with the next preview watched ahead of release.
+
+### Negative
+
+* Consumers pinned to .NET Framework 4.6.1–4.7.1 lose a claim of support they never
+ actually had verified; they must be on 4.7.2 or later. Accepted: 4.7.2 is the
+ practical `netstandard2.0` floor and the lower versions are largely end-of-life.
+* A small, test-only `IsExternalInit` polyfill and a `net472`-conditioned build path
+ are added to the affected test projects. The **shipped libraries are untouched** —
+ they use neither `init` nor records — so nothing in the product depends on the
+ polyfill.
+
+### Risks
+
+* The `net472` leg runs on Windows only, so a .NET-Framework-specific regression is
+ invisible on the Linux legs until the Windows job runs. Mitigated by running the job
+ on every pull request.
+* `FirstClassErrors.RequestBinder.UnitTests` cannot join the floor because its
+ fixtures bind `DateOnly`, a .NET 6+ type absent from .NET Framework; RequestBinder is
+ floored through its property tests instead. Accepted: the excluded scenarios exercise
+ a type that cannot exist on `net472` in the first place.
+
+## Follow-up Actions
+
+* State 4.7.2+ in `FirstClassErrors/README.nuget.md` (done in this change).
+* Add the `framework-floor` job to `ci.yml` and the shared `build/Net472TestFloor.props`
+ that carries the gated `net472` leg (done in this change).
+* Make `framework-floor` a **required status check** in branch protection so it blocks
+ merges, matching the intent that the floor is enforced, not advisory.
+* Should a maintainer wish to reconcile the incidental "4.6.1" line in
+ [ADR-0002](0002-floor-the-tooling-runtime.md), add an erratum note there pointing to
+ this ADR; this ADR is the authority on the library's .NET Framework floor.
+
+## References
+
+* [ADR-0002](0002-floor-the-tooling-runtime.md) — the tooling runtime floor; the
+ sibling runtime-bound decision this ADR refines.
+* [ADR-0001](0001-lock-the-analyzer-roslyn-floor.md) — the analyzer's Roslyn floor,
+ the third supported-tooling bound.
+* `FirstClassErrors/README.nuget.md` — the user-facing support statement.
+* `build/Net472TestFloor.props`, the `framework-floor` job in
+ `.github/workflows/ci.yml`, and the library preview run in
+ `.github/workflows/canary.yml` — where this decision is enforced.
diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md
index 34798908..1ee0d4eb 100644
--- a/doc/handwritten/for-maintainers/adr/README.md
+++ b/doc/handwritten/for-maintainers/adr/README.md
@@ -195,3 +195,4 @@ Optional supporting material:
| [ADR-0019](0019-document-overridden-binder-errors-in-the-consumers-catalog.md) | Document overridden binder errors in the consumer's own catalog | Accepted |
| [ADR-0020](0020-materialize-dummies-only-through-generate.md) | Materialize dummies only through Generate() | Accepted |
| [ADR-0021](0021-bind-out-of-dto-arguments-as-peers-through-a-source-agnostic-entry.md) | Bind out-of-DTO arguments as peers through a source-agnostic untyped entry | Proposed |
+| [ADR-0022](0022-floor-the-library-on-net-framework-4-7-2.md) | Floor the library's .NET Framework support at 4.7.2 | Accepted |