From 11c4f946bb3cdc3e6aa7c44994c8e0080e689fb1 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 14:58:35 +0200 Subject: [PATCH 1/5] docs(validation): plan the IsUuidV7 assertion Check offers only IsEmpty/IsNotEmpty, so a service accepting a client-generated identifier cannot state that it must be a UUIDv7. A v4 GUID from a misconfigured client is accepted and surfaces later as index fragmentation. Key decisions recorded in the plan: - Require both the version nibble (7) and the RFC 9562 variant bits (10). Guid.Version reports the version regardless of the variant, so a version-only test accepts values Guid.CreateVersion7 cannot produce. - Read both fields by reinterpreting the Guid over a field overlay with Unsafe.As, rather than through ToByteArray or TryWriteBytes. This is allocation-free on both target frameworks and, more importantly, removes a per-target byte index: Guid's default order is mixed-endian, which would put the version nibble at index 7 on netstandard2.0 and 6 in a big-endian span, an off-by-one the net10.0-only suite could not catch. - Mirror the identical GuidFields overlay already shipping in CanonicalTextFormatter, including its narrow CS0649 suppression, which TreatWarningsAsErrors makes mandatory. The layout assumption is already load-bearing in the core library rather than new to this change. - Treat Guid.Version and Guid.Variant as the test oracle, not the implementation. netstandard2.0 has neither, so using them would reintroduce the per-target split the overlay exists to avoid. - Verify with an exhaustive 16x16 version/variant nibble matrix against that oracle, asserting the accepted count of 4 of 256. This pins both offsets and both masks, and replaces hand-picked boundary values and a random sample. - Give the invariant one name on two receivers: public GuidExtensions.IsUuidV7(Guid) alongside Checks.IsUuidV7(Check), keeping Checks the home of Check extensions. - Move release notes in both Validation and Validation.OpenApi: the built-in contract registry's key set is public behavior even though that package's source barely changes. - Defer timestamp plausibility, Check overloads, and a generalized IsUuidVersion(int). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01B9QDDRWbDXTrE12S6ErBt4 --- ai-plans/0072-is-uuid-v7.md | 231 ++++++++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 ai-plans/0072-is-uuid-v7.md diff --git a/ai-plans/0072-is-uuid-v7.md b/ai-plans/0072-is-uuid-v7.md new file mode 100644 index 0000000..98050c6 --- /dev/null +++ b/ai-plans/0072-is-uuid-v7.md @@ -0,0 +1,231 @@ +# Add an `IsUuidV7` Assertion + +## Rationale + +Distributed systems increasingly let clients generate their own identifiers, and UUIDv7 (RFC 9562 §5.7) is the format +of choice because its leading 48-bit Unix millisecond timestamp keeps client-generated keys roughly sortable and +index-friendly. A server accepting such an identifier over the wire currently has no way to state that requirement: +`Check` offers only `IsEmpty`/`IsNotEmpty`, so a v4 GUID from a misconfigured client is accepted and only shows +up later as index fragmentation. + +`Checks.IsUuidV7` closes that gap as the first Guid-shaped format assertion, following the same structure as the +string format assertions (`IsEmail`, `ContainsOnlyDigits`): a dedicated error code, a customizable message template, +and a metadata-free OpenAPI contract. It is the first of several assertions planned for the validation library; this +plan covers only this one. + +## Acceptance Criteria + +- [ ] `Check` exposes `IsUuidV7` in both built-in-message and `ErrorOverrides` overloads, each honoring + `shortCircuitOnError` and the already-short-circuited state, matching the shape of every other built-in assertion. +- [ ] The same invariant is available standalone as public `GuidExtensions.IsUuidV7(this Guid value)`, under that one + name, with the assertion delegating to it so the bit manipulation exists in a single place. +- [ ] The assertion passes only for GUIDs whose RFC 9562 version field is `7` **and** whose two most significant + variant bits are `10`. `Guid.Empty`, `Guid.NewGuid()` (v4), and version-7 values carrying a non-RFC variant all fail. +- [ ] Failures carry the new `ValidationErrorCodes.UuidV7` code and a message from a new customizable + `ValidationErrorTemplates.UuidV7` template, which survives `with`-expression copies of `ValidationErrorTemplates`. +- [ ] The rule is discoverable by OpenAPI source generation and resolves to a registered metadata-free contract, so a + validator using it documents the `UuidV7` code without an explicit hint and without an unknown-code diagnostic. +- [ ] An exhaustive version-nibble × variant-nibble matrix checks the predicate against an oracle built from **both** + `Guid.Version` and `Guid.Variant`, so the BCL guards the whole layout assumption rather than half of it. +- [ ] Automated tests additionally cover both overloads, short-circuit propagation, template customization, and the + generated OpenAPI metadata. Solution test coverage stays above 95%. +- [ ] One predicate implementation serves both target frameworks, without conditional compilation, and the passing + path allocates nothing on either. +- [ ] The README shows the assertion where client-generated identifiers are validated, and the 0.7.0 + `` of **both** affected packages record their own part of the change. + +## Technical Details + +### Predicate + +RFC 9562 places the version in the high nibble of octet 6 and the variant in the two most significant bits of +octet 8. Both must be checked: `Guid.Version` reports the version nibble regardless of the variant bits, so a value +such as `017f22e2-79b0-7cc3-08c4-dc0c0c07398f` — an NCS-reserved variant — would pass a version-only test even though +`Guid.CreateVersion7` can never produce it. Requiring `10` keeps the assertion a statement about RFC 9562 identifiers +rather than about a nibble. The remaining two bits of the variant nibble are free, so `8`, `9`, `a`, and `b` are all +acceptable there. + +Read both fields by reinterpreting the `Guid` over a struct mirroring its field layout. This is allocation-free on +both target frameworks and needs no conditional compilation: + +```csharp +#pragma warning disable CS0649 // Fields are populated by reinterpreting Guid storage via Unsafe.As. +private struct GuidFields +{ + public int A; + public short B; + public short C; // high nibble of the high byte carries the version + public byte D; // top two bits carry the variant +} +#pragma warning restore CS0649 + +ref var fields = ref Unsafe.As(ref value); +// version: (fields.C >> 12) & 0x0F variant: (fields.D & 0xC0) == 0x80 +``` + +The `CS0649` suppression is mandatory rather than cosmetic: nothing assigns these fields in source, so the compiler +reports them as never assigned, and `TreatWarningsAsErrors` turns that into a Release build failure. Keep it narrow, +around the type only. + +The obvious implementation — `value.Version == 7 && (value.Variant & 0xC) == 0x8` — is not available. Both properties +are public on `net10.0` (`Version` since .NET 9), but `netstandard2.0` has neither, and `Polyfill` supplies +`Guid.CreateVersion7` there without them. Using them would reintroduce exactly the per-target split this section +exists to avoid. They are the right *oracle*, not the right implementation, and the test project targets `net10.0` +only, so it can use them freely. + +The byte-based alternatives are worse still. `Guid.TryWriteBytes(…, bigEndian: true, …)` is .NET 8+, and +`netstandard2.0` has neither it nor the `netstandard2.1` `TryWriteBytes`. That leaves `ToByteArray()` on the legacy +target, which allocates a 16-byte array per call *and* splits the two targets apart: `Guid`'s default byte order is +mixed-endian, so the version nibble sits at index 7 there but at index 6 in a big-endian span. That off-by-one is +exactly the kind of defect this repository cannot catch, because the suite runs on `net10.0` only. + +Reading the fields removes both problems and is endianness-independent, since the version comes from a `short` read +rather than a byte position. + +This technique is not new to the repository, which is the main argument for it. `CanonicalTextFormatter` already +declares an identical `GuidFields` overlay — same `int, short, short, byte…` shape, same `CS0649` suppression +(`src/Light.PortableResults/Text/CanonicalTextFormatter.cs:1085`) — and reinterprets through it at line 785 to format +GUIDs canonically. The `Guid` layout assumption is therefore already load-bearing and shipping in the core library's +formatting path; this plan reuses an established assumption rather than introducing a new one. Mirror that +declaration, including the pragma and the comment explaining it. The duplication is deliberate: the existing type is +a private nested type in a different assembly, and promoting it to shared public API would widen this change across +package boundaries for no benefit. + +Two supporting facts, both already true in the Validation assembly: `TrimStringNormalizer` uses `Unsafe.As` with no +TFM guard, so `System.Runtime.CompilerServices.Unsafe` demonstrably resolves there — it reaches `netstandard2.0` +consumers transitively through `Light.PortableResults` → `System.Text.Json` → `System.Memory` — and the technique +needs no `AllowUnsafeBlocks`, which this project does not set even though the core project does. + +The layout assumption is sound and, more to the point, verifiable. `Guid`'s field order is fixed by its +`(int, short, short, byte…)` constructor, by `ToByteArray()`, and by its role as the interop mapping of the Win32 +`GUID`; the two expressions above are what `Guid.Version` and `Guid.Variant` themselves compute from `_c` and `_d`. +The assumption spans both fields, so the oracle must too — verifying only the version would leave the `D` offset and +the variant mask unguarded. The matrix test below delegates both to the BCL. + +Prototyped against .NET 10: across all 256 matrix cases the predicate agrees with the oracle and both reinterpreted +fields agree with `Guid.Version` and `Guid.Variant` individually, one million calls allocate zero bytes, and the same +source compiles clean for `netstandard2.0` under `TreatWarningsAsErrors` with `Unsafe` resolved only transitively. +The per-field agreement is prototype evidence for this plan, not a shipped test — see the test section. + +### Public surface + +The invariant gets one name, `IsUuidV7`, on two receivers: + +```csharp +namespace Light.PortableResults.Validation; + +public static class GuidExtensions +{ + public static bool IsUuidV7(this Guid value); +} + +public static partial class Checks +{ + public static Check IsUuidV7(this Check check, bool shortCircuitOnError = false); + public static Check IsUuidV7(this Check check, ErrorOverrides overrides, bool shortCircuitOnError = false); +} +``` + +The raw predicate is public rather than a private helper like `LooksLikeEmail`: it is properly encapsulated, useful +outside a check chain — a repository or message handler guarding the same invariant — and the root `AGENTS.md` +prefers public APIs. It lives in its own `GuidExtensions` class, not in `Checks`, so that `Checks` remains +exclusively the home of `Check` extensions; `CheckExtensions` is the existing precedent for a top-level +`*Extensions` class in this namespace, and no `GuidExtensions` type exists in the solution today. + +Both members share the name because they state the same thing, and nothing forces them apart: the receivers are +`Guid` and `Check`, so overload resolution never has to choose between them even though a single +`using Light.PortableResults.Validation;` imports both. The assertion is then the usual short-circuit test plus a +call to the predicate, keeping the bit manipulation in exactly one place. + +Importing the namespace does surface `IsUuidV7` on every `Guid` in scope. That is intended — it is the point of +making the predicate public — and it is why the name is specific enough to carry its own meaning at a bare `Guid` +call site. + +### Registration points + +Adding a built-in rule touches five places beyond the assertion itself; two are easy to miss because omitting them +compiles and fails silently: + +- `ValidationErrorTemplates`' **copy constructor** must copy the new `UuidV7` property. It is what `with` expressions + run, so a missing line silently resets a caller's customized template to the default. A test must customize the + template through `ValidationErrorTemplates.Default with { … }` and assert the resulting message. +- `BuiltInValidationErrorContracts.Contracts` must register `[ValidationErrorCodes.UuidV7] = ErrorMetadataContract.NoMetadata`, + and `BuiltInValidationErrorContractsTests`' expected no-metadata code list must grow with it — that test asserts the + registry's full key set. Without the entry, a validator using the assertion cannot document the code from the + built-in registry. + +The remaining additions, each following its `Email` counterpart exactly: + +- the `ValidationErrorCodes.UuidV7` constant; +- a `UuidV7ValidationErrorDefinition` class modeled on `EmailValidationErrorDefinition`, including + `TryGetStableMessageProvider`, since the message has no per-error parameters and is cacheable; +- the shared `BuiltInValidationErrorDefinitions.UuidV7` static property returning that instance — the class alone is + not enough, and it is what the assertion passes to `AddBuiltInError`; +- the `ValidationErrorTemplates.UuidV7` property, defaulting to `new DisplayName(" must be a version 7 UUID")`, in + addition to the copy-constructor line above — the property and the copy are two separate edits to the same type. + +Place the assertion and definition in `Checks.Guids.cs` and `BuiltInValidationErrorDefinitions.Guids.cs`, matching the +existing partial-class-per-family layout, and the predicate in `GuidExtensions.cs` beside `CheckExtensions.cs`. + +Source generation needs no generator change: it discovers rules through `[ValidationRule(ValidationErrorCodes.UuidV7)]` +on the built-in-message overload, with `[ValidationRuleMessage("{displayName} must be a version 7 UUID")]` supplying +the compile-time example message. The default `ValidationRuleMetadataShape.Registered` is correct — the rule has no +metadata of its own. + +### Test data and cases + +A single exhaustive matrix replaces hand-picked boundary values. Start from RFC 9562's own example, +`017f22e2-79b0-7cc3-98c4-dc0c0c07398f`, and substitute the version nibble at string index 14 and the variant nibble at +string index 19 across all 16 × 16 combinations, and assert for each of the 256 GUIDs that + +```csharp +guid.IsUuidV7() == (guid.Version == 7 && (guid.Variant & 0xC) == 0x8) +``` + +`Guid.Variant` returns the full high nibble of octet 8 rather than the two variant bits alone, which is why the oracle +masks with `0xC` instead of comparing against `0x2`. + +That single boolean is enough to pin both offsets and both masks, even though it never reads the extracted fields +individually — which it could not do anyway, since only the `bool` is public and inventing accessors purely to test +internals would be the wrong trade. The matrix varies the two nibbles independently over their full ranges, so any +error in either field — a shifted offset, a widened or narrowed mask — changes which of the 256 inputs are accepted, +and the oracle disagrees. Assert the accepted count as a second, blunter statement of the same property: exactly 4 of +256 pass, being version `7` crossed with variant nibbles `8`–`b`. Any drift moves that number, which makes it a +useful mutation-testing target. + +This subsumes every boundary that a hand-written list would have enumerated (versions `6` and `8`; NCS, Microsoft, and +reserved variants) while being deterministic, so it also replaces the random-sample cross-check. Keep `Guid.Empty`, +`Guid.NewGuid()`, and `Guid.CreateVersion7()` as three separate named cases: they are statements about the platform's +own values rather than about the bit layout, and the matrix cannot make them. + +Assertion-level tests then stay small, since the predicate is already pinned: one accepted and one rejected value +through `Check`, both overloads, short-circuit propagation, and template customization. + +The suite runs on `net10.0` only, so `netstandard2.0` gets compile verification rather than execution. That is an +argument for the shared implementation above rather than something to test around: with no per-target branch, the +behavior these tests pin is the behavior both packages ship, and the layout it rests on is identical on .NET +Framework, where `Guid` is the same interop-mapped type. + +### Release notes + +Two packages ship a change, so both sets of 0.7.0 notes move: + +- **Light.PortableResults.Validation** — the `IsUuidV7` assertion, the `UuidV7` error code, the customizable + `ValidationErrorTemplates.UuidV7` template, and the new public `GuidExtensions` type. +- **Light.PortableResults.Validation.OpenApi** — the built-in metadata contract registered for the `UuidV7` code. + +The OpenApi entry is easy to skip because that package's own source barely changes, but its registry's key set is +public behavior: a consumer reading the built-in contracts, or narrowing error schemas against them, sees a new entry. +Note that these notes are capability-level rather than a per-release changelog, so the entry is one short line in that +register — not a changelog section imported from the Validation package. + +### Deliberately out of scope + +- **Timestamp plausibility.** A v7 identifier from a client with a wrong clock is structurally valid but useless for + ordering. Validating the leading 48-bit timestamp against a permitted skew window needs a clock abstraction, a + configurable tolerance, and metadata carrying the bounds — a separate rule with a separate error code, not a + parameter on this one. +- **`Check` overloads.** No built-in assertion offers nullable value-type overloads today; `IsNotNull` covers + the null case generically. Adding them for one assertion would be inconsistent. +- **Other UUID versions.** A generalized `IsUuidVersion(int)` would need version metadata on the error and an OpenAPI + contract to match. Revisit only if a second version is actually requested. From d705708b02a36168b4912bbbeb9bafb195ab5d6c Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 15:02:15 +0200 Subject: [PATCH 2/5] docs(validation): condense the plan without dropping decisions Tighten the IsUuidV7 plan from 231 to 192 lines. All nine acceptance criteria and every normative instruction are preserved; the reduction is redundant prose, chiefly in the Predicate section, where the rejection of each alternative, the layout justification, and the endianness argument each restated points the others had already made. Also correct a stale count: the registration points list says six places rather than five, which is what it has enumerated since the shared BuiltInValidationErrorDefinitions.UuidV7 property was split out from the definition class. Restore one fact the condensation had dropped: Guid.Version arrived in .NET 9, which is why the oracle is available to the net10.0-only test project but not to the netstandard2.0 implementation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01B9QDDRWbDXTrE12S6ErBt4 --- ai-plans/0072-is-uuid-v7.md | 239 +++++++++++++++--------------------- 1 file changed, 100 insertions(+), 139 deletions(-) diff --git a/ai-plans/0072-is-uuid-v7.md b/ai-plans/0072-is-uuid-v7.md index 98050c6..4d7a029 100644 --- a/ai-plans/0072-is-uuid-v7.md +++ b/ai-plans/0072-is-uuid-v7.md @@ -2,25 +2,23 @@ ## Rationale -Distributed systems increasingly let clients generate their own identifiers, and UUIDv7 (RFC 9562 §5.7) is the format -of choice because its leading 48-bit Unix millisecond timestamp keeps client-generated keys roughly sortable and -index-friendly. A server accepting such an identifier over the wire currently has no way to state that requirement: -`Check` offers only `IsEmpty`/`IsNotEmpty`, so a v4 GUID from a misconfigured client is accepted and only shows -up later as index fragmentation. +UUIDv7 (RFC 9562 §5.7) leads with a 48-bit Unix millisecond timestamp, which keeps client-generated keys roughly +sortable and index-friendly — the reason distributed systems let clients mint their own identifiers. A server +accepting one cannot currently state that requirement: `Check` offers only `IsEmpty`/`IsNotEmpty`, so a v4 GUID +from a misconfigured client is accepted and surfaces later as index fragmentation. -`Checks.IsUuidV7` closes that gap as the first Guid-shaped format assertion, following the same structure as the -string format assertions (`IsEmail`, `ContainsOnlyDigits`): a dedicated error code, a customizable message template, -and a metadata-free OpenAPI contract. It is the first of several assertions planned for the validation library; this -plan covers only this one. +`Checks.IsUuidV7` closes the gap as the first Guid-shaped format assertion, structured like the string format +assertions (`IsEmail`, `ContainsOnlyDigits`): a dedicated error code, a customizable message template, and a +metadata-free OpenAPI contract. More assertions are planned for the library; this plan covers only this one. ## Acceptance Criteria - [ ] `Check` exposes `IsUuidV7` in both built-in-message and `ErrorOverrides` overloads, each honoring - `shortCircuitOnError` and the already-short-circuited state, matching the shape of every other built-in assertion. + `shortCircuitOnError` and the already-short-circuited state, matching every other built-in assertion. - [ ] The same invariant is available standalone as public `GuidExtensions.IsUuidV7(this Guid value)`, under that one name, with the assertion delegating to it so the bit manipulation exists in a single place. - [ ] The assertion passes only for GUIDs whose RFC 9562 version field is `7` **and** whose two most significant - variant bits are `10`. `Guid.Empty`, `Guid.NewGuid()` (v4), and version-7 values carrying a non-RFC variant all fail. + variant bits are `10`. `Guid.Empty`, `Guid.NewGuid()` (v4), and version-7 values with a non-RFC variant all fail. - [ ] Failures carry the new `ValidationErrorCodes.UuidV7` code and a message from a new customizable `ValidationErrorTemplates.UuidV7` template, which survives `with`-expression copies of `ValidationErrorTemplates`. - [ ] The rule is discoverable by OpenAPI source generation and resolves to a registered metadata-free contract, so a @@ -38,15 +36,13 @@ plan covers only this one. ### Predicate -RFC 9562 places the version in the high nibble of octet 6 and the variant in the two most significant bits of -octet 8. Both must be checked: `Guid.Version` reports the version nibble regardless of the variant bits, so a value -such as `017f22e2-79b0-7cc3-08c4-dc0c0c07398f` — an NCS-reserved variant — would pass a version-only test even though -`Guid.CreateVersion7` can never produce it. Requiring `10` keeps the assertion a statement about RFC 9562 identifiers -rather than about a nibble. The remaining two bits of the variant nibble are free, so `8`, `9`, `a`, and `b` are all -acceptable there. +RFC 9562 puts the version in the high nibble of octet 6 and the variant in the two most significant bits of octet 8, +and both must be checked. `Guid.Version` reports the version regardless of the variant, so +`017f22e2-79b0-7cc3-08c4-dc0c0c07398f` — an NCS-reserved variant that `Guid.CreateVersion7` cannot produce — would +pass a version-only test. The variant's remaining two bits are free, so nibbles `8`–`b` are all acceptable. -Read both fields by reinterpreting the `Guid` over a struct mirroring its field layout. This is allocation-free on -both target frameworks and needs no conditional compilation: +Read both fields by reinterpreting the `Guid` over a struct mirroring its layout, which is allocation-free on both +targets and needs no conditional compilation: ```csharp #pragma warning disable CS0649 // Fields are populated by reinterpreting Guid storage via Unsafe.As. @@ -63,54 +59,40 @@ ref var fields = ref Unsafe.As(ref value); // version: (fields.C >> 12) & 0x0F variant: (fields.D & 0xC0) == 0x80 ``` -The `CS0649` suppression is mandatory rather than cosmetic: nothing assigns these fields in source, so the compiler -reports them as never assigned, and `TreatWarningsAsErrors` turns that into a Release build failure. Keep it narrow, -around the type only. - -The obvious implementation — `value.Version == 7 && (value.Variant & 0xC) == 0x8` — is not available. Both properties -are public on `net10.0` (`Version` since .NET 9), but `netstandard2.0` has neither, and `Polyfill` supplies -`Guid.CreateVersion7` there without them. Using them would reintroduce exactly the per-target split this section -exists to avoid. They are the right *oracle*, not the right implementation, and the test project targets `net10.0` -only, so it can use them freely. - -The byte-based alternatives are worse still. `Guid.TryWriteBytes(…, bigEndian: true, …)` is .NET 8+, and -`netstandard2.0` has neither it nor the `netstandard2.1` `TryWriteBytes`. That leaves `ToByteArray()` on the legacy -target, which allocates a 16-byte array per call *and* splits the two targets apart: `Guid`'s default byte order is -mixed-endian, so the version nibble sits at index 7 there but at index 6 in a big-endian span. That off-by-one is -exactly the kind of defect this repository cannot catch, because the suite runs on `net10.0` only. - -Reading the fields removes both problems and is endianness-independent, since the version comes from a `short` read -rather than a byte position. - -This technique is not new to the repository, which is the main argument for it. `CanonicalTextFormatter` already -declares an identical `GuidFields` overlay — same `int, short, short, byte…` shape, same `CS0649` suppression -(`src/Light.PortableResults/Text/CanonicalTextFormatter.cs:1085`) — and reinterprets through it at line 785 to format -GUIDs canonically. The `Guid` layout assumption is therefore already load-bearing and shipping in the core library's -formatting path; this plan reuses an established assumption rather than introducing a new one. Mirror that -declaration, including the pragma and the comment explaining it. The duplication is deliberate: the existing type is -a private nested type in a different assembly, and promoting it to shared public API would widen this change across -package boundaries for no benefit. - -Two supporting facts, both already true in the Validation assembly: `TrimStringNormalizer` uses `Unsafe.As` with no -TFM guard, so `System.Runtime.CompilerServices.Unsafe` demonstrably resolves there — it reaches `netstandard2.0` -consumers transitively through `Light.PortableResults` → `System.Text.Json` → `System.Memory` — and the technique -needs no `AllowUnsafeBlocks`, which this project does not set even though the core project does. - -The layout assumption is sound and, more to the point, verifiable. `Guid`'s field order is fixed by its -`(int, short, short, byte…)` constructor, by `ToByteArray()`, and by its role as the interop mapping of the Win32 -`GUID`; the two expressions above are what `Guid.Version` and `Guid.Variant` themselves compute from `_c` and `_d`. -The assumption spans both fields, so the oracle must too — verifying only the version would leave the `D` offset and -the variant mask unguarded. The matrix test below delegates both to the BCL. - -Prototyped against .NET 10: across all 256 matrix cases the predicate agrees with the oracle and both reinterpreted -fields agree with `Guid.Version` and `Guid.Variant` individually, one million calls allocate zero bytes, and the same -source compiles clean for `netstandard2.0` under `TreatWarningsAsErrors` with `Unsafe` resolved only transitively. -The per-field agreement is prototype evidence for this plan, not a shipped test — see the test section. +`CS0649` fires because nothing assigns these fields in source, and `TreatWarningsAsErrors` turns it into a Release +build failure, so the suppression is mandatory rather than cosmetic. Keep it narrow, around the type only. + +Neither obvious alternative works. `value.Version == 7 && (value.Variant & 0xC) == 0x8` needs two properties that +`netstandard2.0` lacks (`Version` arrived in .NET 9; `Polyfill` supplies `CreateVersion7` there without either), so it +would reintroduce the per-target split this section exists to avoid. They are the right *oracle*, not the right implementation, and the +`net10.0`-only test project may use them freely. The byte-based route is worse: `TryWriteBytes(…, bigEndian: true, …)` +is .NET 8+ and `netstandard2.0` has neither it nor the `netstandard2.1` overload, leaving `ToByteArray()`, which +allocates 16 bytes per call *and* splits the targets apart — `Guid`'s default order is mixed-endian, putting the +version nibble at index 7 there against index 6 in a big-endian span. A `net10.0`-only suite cannot catch that +off-by-one. A `short` read carries no such dependency. + +The technique is already established here, which is the main argument for it. `CanonicalTextFormatter` declares an +identical overlay — same shape, same suppression (`src/Light.PortableResults/Text/CanonicalTextFormatter.cs:1085`) — +and reinterprets through it at line 785 to format GUIDs canonically, so the layout assumption is load-bearing in +shipping core code rather than new to this change. Mirror that declaration, pragma and comment included. The +duplication is deliberate: the existing type is private to another assembly, and promoting it would widen this change +across package boundaries for no benefit. Two supporting facts already hold inside the Validation assembly: +`TrimStringNormalizer` uses `Unsafe.As` with no TFM guard, so `System.Runtime.CompilerServices.Unsafe` resolves there +— reaching `netstandard2.0` consumers through `Light.PortableResults` → `System.Text.Json` → `System.Memory` — and the +technique needs no `AllowUnsafeBlocks`, which this project does not set even though the core project does. + +The assumption itself is fixed by `Guid`'s `(int, short, short, byte…)` constructor, by `ToByteArray()`, and by its +role as the Win32 `GUID` interop mapping; the two expressions above are what `Guid.Version` and `Guid.Variant` compute +from `_c` and `_d`. It spans both fields, so the oracle must too — checking only the version would leave the `D` +offset and the variant mask unguarded. + +Prototyped against .NET 10: across all 256 matrix cases the predicate agrees with the oracle and each field agrees +with its property, one million calls allocate zero bytes, and the same source compiles clean for `netstandard2.0` +under `TreatWarningsAsErrors` with `Unsafe` resolved only transitively. The per-field agreement is evidence for this +plan, not a shipped test. ### Public surface -The invariant gets one name, `IsUuidV7`, on two receivers: - ```csharp namespace Light.PortableResults.Validation; @@ -126,106 +108,85 @@ public static partial class Checks } ``` -The raw predicate is public rather than a private helper like `LooksLikeEmail`: it is properly encapsulated, useful -outside a check chain — a repository or message handler guarding the same invariant — and the root `AGENTS.md` -prefers public APIs. It lives in its own `GuidExtensions` class, not in `Checks`, so that `Checks` remains -exclusively the home of `Check` extensions; `CheckExtensions` is the existing precedent for a top-level -`*Extensions` class in this namespace, and no `GuidExtensions` type exists in the solution today. - -Both members share the name because they state the same thing, and nothing forces them apart: the receivers are -`Guid` and `Check`, so overload resolution never has to choose between them even though a single -`using Light.PortableResults.Validation;` imports both. The assertion is then the usual short-circuit test plus a -call to the predicate, keeping the bit manipulation in exactly one place. - -Importing the namespace does surface `IsUuidV7` on every `Guid` in scope. That is intended — it is the point of -making the predicate public — and it is why the name is specific enough to carry its own meaning at a bare `Guid` -call site. +The predicate is public rather than a private helper like `LooksLikeEmail`: it is properly encapsulated, useful +outside a check chain — a repository or message handler guarding the same invariant — and the root `AGENTS.md` prefers +public APIs. It gets its own class so that `Checks` remains exclusively the home of `Check` extensions; +`CheckExtensions` is the precedent for a top-level `*Extensions` class in this namespace, and no `GuidExtensions` +exists in the solution today. Both members share the one name because they state the same thing and their receivers +differ, so overload resolution never has to choose even though a single `using` imports both. The assertion is then +the usual short-circuit test plus a call to the predicate. Importing the namespace does surface `IsUuidV7` on every +`Guid` in scope: intended, and why the name is specific enough to carry its meaning at a bare `Guid` call site. ### Registration points -Adding a built-in rule touches five places beyond the assertion itself; two are easy to miss because omitting them -compiles and fails silently: - -- `ValidationErrorTemplates`' **copy constructor** must copy the new `UuidV7` property. It is what `with` expressions - run, so a missing line silently resets a caller's customized template to the default. A test must customize the - template through `ValidationErrorTemplates.Default with { … }` and assert the resulting message. -- `BuiltInValidationErrorContracts.Contracts` must register `[ValidationErrorCodes.UuidV7] = ErrorMetadataContract.NoMetadata`, - and `BuiltInValidationErrorContractsTests`' expected no-metadata code list must grow with it — that test asserts the - registry's full key set. Without the entry, a validator using the assertion cannot document the code from the - built-in registry. +Six places change beyond the assertion. Two fail silently when missed: -The remaining additions, each following its `Email` counterpart exactly: +- `ValidationErrorTemplates`' **copy constructor** must copy the new property. It is what `with` expressions run, so + omitting it silently resets a caller's customized template to the default. A test must customize through + `ValidationErrorTemplates.Default with { … }` and assert the resulting message. +- `BuiltInValidationErrorContracts.Contracts` must register + `[ValidationErrorCodes.UuidV7] = ErrorMetadataContract.NoMetadata`, and `BuiltInValidationErrorContractsTests`' + expected no-metadata list must grow with it, since that test asserts the registry's full key set. Without the entry + the code cannot be documented from the built-in registry. -- the `ValidationErrorCodes.UuidV7` constant; -- a `UuidV7ValidationErrorDefinition` class modeled on `EmailValidationErrorDefinition`, including - `TryGetStableMessageProvider`, since the message has no per-error parameters and is cacheable; -- the shared `BuiltInValidationErrorDefinitions.UuidV7` static property returning that instance — the class alone is - not enough, and it is what the assertion passes to `AddBuiltInError`; -- the `ValidationErrorTemplates.UuidV7` property, defaulting to `new DisplayName(" must be a version 7 UUID")`, in - addition to the copy-constructor line above — the property and the copy are two separate edits to the same type. +The other four follow their `Email` counterparts: the `ValidationErrorCodes.UuidV7` constant; a +`UuidV7ValidationErrorDefinition` modeled on `EmailValidationErrorDefinition`, including `TryGetStableMessageProvider` +because the message has no per-error parameters and is cacheable; the shared +`BuiltInValidationErrorDefinitions.UuidV7` static property returning that instance, which is what the assertion passes +to `AddBuiltInError`; and the `ValidationErrorTemplates.UuidV7` property defaulting to +`new DisplayName(" must be a version 7 UUID")` — a separate edit from the copy-constructor line above. Place the assertion and definition in `Checks.Guids.cs` and `BuiltInValidationErrorDefinitions.Guids.cs`, matching the -existing partial-class-per-family layout, and the predicate in `GuidExtensions.cs` beside `CheckExtensions.cs`. - -Source generation needs no generator change: it discovers rules through `[ValidationRule(ValidationErrorCodes.UuidV7)]` -on the built-in-message overload, with `[ValidationRuleMessage("{displayName} must be a version 7 UUID")]` supplying -the compile-time example message. The default `ValidationRuleMetadataShape.Registered` is correct — the rule has no +partial-class-per-family layout, and the predicate in `GuidExtensions.cs` beside `CheckExtensions.cs`. Source +generation needs no generator change: it discovers the rule through `[ValidationRule(ValidationErrorCodes.UuidV7)]` on +the built-in-message overload, with `[ValidationRuleMessage("{displayName} must be a version 7 UUID")]` as the +compile-time example message. The default `ValidationRuleMetadataShape.Registered` is correct — the rule carries no metadata of its own. ### Test data and cases -A single exhaustive matrix replaces hand-picked boundary values. Start from RFC 9562's own example, -`017f22e2-79b0-7cc3-98c4-dc0c0c07398f`, and substitute the version nibble at string index 14 and the variant nibble at -string index 19 across all 16 × 16 combinations, and assert for each of the 256 GUIDs that +One exhaustive matrix replaces hand-picked boundaries. From RFC 9562's own example +`017f22e2-79b0-7cc3-98c4-dc0c0c07398f`, substitute the version nibble at string index 14 and the variant nibble at +index 19 across all 16 × 16 combinations, asserting for each of the 256 GUIDs that ```csharp guid.IsUuidV7() == (guid.Version == 7 && (guid.Variant & 0xC) == 0x8) ``` -`Guid.Variant` returns the full high nibble of octet 8 rather than the two variant bits alone, which is why the oracle -masks with `0xC` instead of comparing against `0x2`. - -That single boolean is enough to pin both offsets and both masks, even though it never reads the extracted fields -individually — which it could not do anyway, since only the `bool` is public and inventing accessors purely to test -internals would be the wrong trade. The matrix varies the two nibbles independently over their full ranges, so any -error in either field — a shifted offset, a widened or narrowed mask — changes which of the 256 inputs are accepted, -and the oracle disagrees. Assert the accepted count as a second, blunter statement of the same property: exactly 4 of -256 pass, being version `7` crossed with variant nibbles `8`–`b`. Any drift moves that number, which makes it a -useful mutation-testing target. +`Guid.Variant` returns octet 8's full high nibble rather than the two variant bits alone, hence masking with `0xC` +rather than comparing against `0x2`. -This subsumes every boundary that a hand-written list would have enumerated (versions `6` and `8`; NCS, Microsoft, and -reserved variants) while being deterministic, so it also replaces the random-sample cross-check. Keep `Guid.Empty`, -`Guid.NewGuid()`, and `Guid.CreateVersion7()` as three separate named cases: they are statements about the platform's -own values rather than about the bit layout, and the matrix cannot make them. +That single boolean pins both offsets and both masks without reading the fields individually — which it could not do +anyway, since only the `bool` is public, and inventing accessors purely to observe internals would be the wrong trade. +Because the matrix varies both nibbles independently over their full ranges, any shifted offset or altered mask +changes which of the 256 inputs are accepted, and the oracle disagrees. Assert the accepted count as a blunter +statement of the same property: exactly 4 of 256 pass, version `7` crossed with variant nibbles `8`–`b`. Any drift +moves that number, which makes it a useful mutation-testing target. -Assertion-level tests then stay small, since the predicate is already pinned: one accepted and one rejected value -through `Check`, both overloads, short-circuit propagation, and template customization. +This subsumes every boundary a hand-written list would enumerate (versions `6` and `8`; NCS, Microsoft, and reserved +variants) and, being deterministic, also replaces a random sample. Keep `Guid.Empty`, `Guid.NewGuid()`, and +`Guid.CreateVersion7()` as named cases: they state things about the platform's own values that the matrix cannot. +Assertion-level tests then stay small — one accepted and one rejected value through `Check`, both overloads, +short-circuit propagation, and template customization. -The suite runs on `net10.0` only, so `netstandard2.0` gets compile verification rather than execution. That is an -argument for the shared implementation above rather than something to test around: with no per-target branch, the -behavior these tests pin is the behavior both packages ship, and the layout it rests on is identical on .NET -Framework, where `Guid` is the same interop-mapped type. +The suite runs on `net10.0` only, so `netstandard2.0` is compile-verified rather than executed. That argues for the +shared implementation rather than for testing around it: with no per-target branch, what these tests pin is what both +packages ship, and the layout holds identically on .NET Framework, where `Guid` is the same interop-mapped type. ### Release notes -Two packages ship a change, so both sets of 0.7.0 notes move: - -- **Light.PortableResults.Validation** — the `IsUuidV7` assertion, the `UuidV7` error code, the customizable - `ValidationErrorTemplates.UuidV7` template, and the new public `GuidExtensions` type. -- **Light.PortableResults.Validation.OpenApi** — the built-in metadata contract registered for the `UuidV7` code. - -The OpenApi entry is easy to skip because that package's own source barely changes, but its registry's key set is -public behavior: a consumer reading the built-in contracts, or narrowing error schemas against them, sees a new entry. -Note that these notes are capability-level rather than a per-release changelog, so the entry is one short line in that -register — not a changelog section imported from the Validation package. +Both packages' 0.7.0 notes move. **Validation** gains the assertion, the `UuidV7` code, the customizable template, and +the new public `GuidExtensions` type; **Validation.OpenApi** gains the built-in metadata contract registered for that +code. The second is easy to skip because that package's own source barely changes, but its registry's key set is +public behavior — a consumer reading the built-in contracts, or narrowing error schemas against them, sees a new +entry. Those notes are capability-level rather than a per-release changelog, so it is one short line there. ### Deliberately out of scope - **Timestamp plausibility.** A v7 identifier from a client with a wrong clock is structurally valid but useless for - ordering. Validating the leading 48-bit timestamp against a permitted skew window needs a clock abstraction, a - configurable tolerance, and metadata carrying the bounds — a separate rule with a separate error code, not a - parameter on this one. + ordering. Checking the leading timestamp against a permitted skew window needs a clock abstraction, a configurable + tolerance, and metadata carrying the bounds: a separate rule with its own error code, not a parameter on this one. - **`Check` overloads.** No built-in assertion offers nullable value-type overloads today; `IsNotNull` covers the null case generically. Adding them for one assertion would be inconsistent. -- **Other UUID versions.** A generalized `IsUuidVersion(int)` would need version metadata on the error and an OpenAPI - contract to match. Revisit only if a second version is actually requested. +- **Other UUID versions.** A generalized `IsUuidVersion(int)` would need version metadata on the error and a matching + OpenAPI contract. Revisit only if a second version is actually requested. From fbf1ad3e748a23bf07c3ea8af7c890cbda1b5ae9 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 15:09:35 +0200 Subject: [PATCH 3/5] chore: reformat 0072 plan Signed-off-by: Kenny Pflug --- ai-plans/0072-is-uuid-v7.md | 160 +++++++++--------------------------- 1 file changed, 38 insertions(+), 122 deletions(-) diff --git a/ai-plans/0072-is-uuid-v7.md b/ai-plans/0072-is-uuid-v7.md index 4d7a029..619df18 100644 --- a/ai-plans/0072-is-uuid-v7.md +++ b/ai-plans/0072-is-uuid-v7.md @@ -2,47 +2,29 @@ ## Rationale -UUIDv7 (RFC 9562 §5.7) leads with a 48-bit Unix millisecond timestamp, which keeps client-generated keys roughly -sortable and index-friendly — the reason distributed systems let clients mint their own identifiers. A server -accepting one cannot currently state that requirement: `Check` offers only `IsEmpty`/`IsNotEmpty`, so a v4 GUID -from a misconfigured client is accepted and surfaces later as index fragmentation. +UUIDv7 (RFC 9562 §5.7) leads with a 48-bit Unix millisecond timestamp, which keeps client-generated keys roughly sortable and index-friendly — the reason distributed systems let clients mint their own identifiers. A server accepting one cannot currently state that requirement: `Check` offers only `IsEmpty`/`IsNotEmpty`, so a v4 GUID from a misconfigured client is accepted and surfaces later as index fragmentation. -`Checks.IsUuidV7` closes the gap as the first Guid-shaped format assertion, structured like the string format -assertions (`IsEmail`, `ContainsOnlyDigits`): a dedicated error code, a customizable message template, and a -metadata-free OpenAPI contract. More assertions are planned for the library; this plan covers only this one. +`Checks.IsUuidV7` closes the gap as the first Guid-shaped format assertion, structured like the string format assertions (`IsEmail`, `ContainsOnlyDigits`): a dedicated error code, a customizable message template, and a metadata-free OpenAPI contract. More assertions are planned for the library; this plan covers only this one. ## Acceptance Criteria -- [ ] `Check` exposes `IsUuidV7` in both built-in-message and `ErrorOverrides` overloads, each honoring - `shortCircuitOnError` and the already-short-circuited state, matching every other built-in assertion. -- [ ] The same invariant is available standalone as public `GuidExtensions.IsUuidV7(this Guid value)`, under that one - name, with the assertion delegating to it so the bit manipulation exists in a single place. -- [ ] The assertion passes only for GUIDs whose RFC 9562 version field is `7` **and** whose two most significant - variant bits are `10`. `Guid.Empty`, `Guid.NewGuid()` (v4), and version-7 values with a non-RFC variant all fail. -- [ ] Failures carry the new `ValidationErrorCodes.UuidV7` code and a message from a new customizable - `ValidationErrorTemplates.UuidV7` template, which survives `with`-expression copies of `ValidationErrorTemplates`. -- [ ] The rule is discoverable by OpenAPI source generation and resolves to a registered metadata-free contract, so a - validator using it documents the `UuidV7` code without an explicit hint and without an unknown-code diagnostic. -- [ ] An exhaustive version-nibble × variant-nibble matrix checks the predicate against an oracle built from **both** - `Guid.Version` and `Guid.Variant`, so the BCL guards the whole layout assumption rather than half of it. -- [ ] Automated tests additionally cover both overloads, short-circuit propagation, template customization, and the - generated OpenAPI metadata. Solution test coverage stays above 95%. -- [ ] One predicate implementation serves both target frameworks, without conditional compilation, and the passing - path allocates nothing on either. -- [ ] The README shows the assertion where client-generated identifiers are validated, and the 0.7.0 - `` of **both** affected packages record their own part of the change. +- [ ] `Check` exposes `IsUuidV7` in both built-in-message and `ErrorOverrides` overloads, each honoring `shortCircuitOnError` and the already-short-circuited state, matching every other built-in assertion. +- [ ] The same invariant is available standalone as public `GuidExtensions.IsUuidV7(this Guid value)`, under that one name, with the assertion delegating to it so the bit manipulation exists in a single place. +- [ ] The assertion passes only for GUIDs whose RFC 9562 version field is `7` **and** whose two most significant variant bits are `10`. `Guid.Empty`, `Guid.NewGuid()` (v4), and version-7 values with a non-RFC variant all fail. +- [ ] Failures carry the new `ValidationErrorCodes.UuidV7` code and a message from a new customizable `ValidationErrorTemplates.UuidV7` template, which survives `with`-expression copies of `ValidationErrorTemplates`. +- [ ] The rule is discoverable by OpenAPI source generation and resolves to a registered metadata-free contract, so a validator using it documents the `UuidV7` code without an explicit hint and without an unknown-code diagnostic. +- [ ] An exhaustive version-nibble × variant-nibble matrix checks the predicate against an oracle built from **both** `Guid.Version` and `Guid.Variant`, so the BCL guards the whole layout assumption rather than half of it. +- [ ] Automated tests additionally cover both overloads, short-circuit propagation, template customization, and the generated OpenAPI metadata. Solution test coverage stays above 95%. +- [ ] One predicate implementation serves both target frameworks, without conditional compilation, and the passing path allocates nothing on either. +- [ ] The README shows the assertion where client-generated identifiers are validated, and the 0.7.0 `` of **both** affected packages record their own part of the change. ## Technical Details ### Predicate -RFC 9562 puts the version in the high nibble of octet 6 and the variant in the two most significant bits of octet 8, -and both must be checked. `Guid.Version` reports the version regardless of the variant, so -`017f22e2-79b0-7cc3-08c4-dc0c0c07398f` — an NCS-reserved variant that `Guid.CreateVersion7` cannot produce — would -pass a version-only test. The variant's remaining two bits are free, so nibbles `8`–`b` are all acceptable. +RFC 9562 puts the version in the high nibble of octet 6 and the variant in the two most significant bits of octet 8, and both must be checked. `Guid.Version` reports the version regardless of the variant, so `017f22e2-79b0-7cc3-08c4-dc0c0c07398f` — an NCS-reserved variant that `Guid.CreateVersion7` cannot produce — would pass a version-only test. The variant's remaining two bits are free, so nibbles `8`–`b` are all acceptable. -Read both fields by reinterpreting the `Guid` over a struct mirroring its layout, which is allocation-free on both -targets and needs no conditional compilation: +Read both fields by reinterpreting the `Guid` over a struct mirroring its layout, which is allocation-free on both targets and needs no conditional compilation: ```csharp #pragma warning disable CS0649 // Fields are populated by reinterpreting Guid storage via Unsafe.As. @@ -59,37 +41,15 @@ ref var fields = ref Unsafe.As(ref value); // version: (fields.C >> 12) & 0x0F variant: (fields.D & 0xC0) == 0x80 ``` -`CS0649` fires because nothing assigns these fields in source, and `TreatWarningsAsErrors` turns it into a Release -build failure, so the suppression is mandatory rather than cosmetic. Keep it narrow, around the type only. - -Neither obvious alternative works. `value.Version == 7 && (value.Variant & 0xC) == 0x8` needs two properties that -`netstandard2.0` lacks (`Version` arrived in .NET 9; `Polyfill` supplies `CreateVersion7` there without either), so it -would reintroduce the per-target split this section exists to avoid. They are the right *oracle*, not the right implementation, and the -`net10.0`-only test project may use them freely. The byte-based route is worse: `TryWriteBytes(…, bigEndian: true, …)` -is .NET 8+ and `netstandard2.0` has neither it nor the `netstandard2.1` overload, leaving `ToByteArray()`, which -allocates 16 bytes per call *and* splits the targets apart — `Guid`'s default order is mixed-endian, putting the -version nibble at index 7 there against index 6 in a big-endian span. A `net10.0`-only suite cannot catch that -off-by-one. A `short` read carries no such dependency. - -The technique is already established here, which is the main argument for it. `CanonicalTextFormatter` declares an -identical overlay — same shape, same suppression (`src/Light.PortableResults/Text/CanonicalTextFormatter.cs:1085`) — -and reinterprets through it at line 785 to format GUIDs canonically, so the layout assumption is load-bearing in -shipping core code rather than new to this change. Mirror that declaration, pragma and comment included. The -duplication is deliberate: the existing type is private to another assembly, and promoting it would widen this change -across package boundaries for no benefit. Two supporting facts already hold inside the Validation assembly: -`TrimStringNormalizer` uses `Unsafe.As` with no TFM guard, so `System.Runtime.CompilerServices.Unsafe` resolves there -— reaching `netstandard2.0` consumers through `Light.PortableResults` → `System.Text.Json` → `System.Memory` — and the -technique needs no `AllowUnsafeBlocks`, which this project does not set even though the core project does. - -The assumption itself is fixed by `Guid`'s `(int, short, short, byte…)` constructor, by `ToByteArray()`, and by its -role as the Win32 `GUID` interop mapping; the two expressions above are what `Guid.Version` and `Guid.Variant` compute -from `_c` and `_d`. It spans both fields, so the oracle must too — checking only the version would leave the `D` -offset and the variant mask unguarded. - -Prototyped against .NET 10: across all 256 matrix cases the predicate agrees with the oracle and each field agrees -with its property, one million calls allocate zero bytes, and the same source compiles clean for `netstandard2.0` -under `TreatWarningsAsErrors` with `Unsafe` resolved only transitively. The per-field agreement is evidence for this -plan, not a shipped test. +`CS0649` fires because nothing assigns these fields in source, and `TreatWarningsAsErrors` turns it into a Release build failure, so the suppression is mandatory rather than cosmetic. Keep it narrow, around the type only. + +Neither obvious alternative works. `value.Version == 7 && (value.Variant & 0xC) == 0x8` needs two properties that `netstandard2.0` lacks (`Version` arrived in .NET 9; `Polyfill` supplies `CreateVersion7` there without either), so it would reintroduce the per-target split this section exists to avoid. They are the right *oracle*, not the right implementation, and the `net10.0`-only test project may use them freely. The byte-based route is worse: `TryWriteBytes(…, bigEndian: true, …)` is .NET 8+ and `netstandard2.0` has neither it nor the `netstandard2.1` overload, leaving `ToByteArray()`, which allocates 16 bytes per call *and* splits the targets apart — `Guid`'s default order is mixed-endian, putting the version nibble at index 7 there against index 6 in a big-endian span. A `net10.0`-only suite cannot catch that off-by-one. A `short` read carries no such dependency. + +The technique is already established here, which is the main argument for it. `CanonicalTextFormatter` declares an identical overlay — same shape, same suppression (`src/Light.PortableResults/Text/CanonicalTextFormatter.cs:1085`) — and reinterprets through it at line 785 to format GUIDs canonically, so the layout assumption is load-bearing in shipping core code rather than new to this change. Mirror that declaration, pragma and comment included. The duplication is deliberate: the existing type is private to another assembly, and promoting it would widen this change across package boundaries for no benefit. Two supporting facts already hold inside the Validation assembly: `TrimStringNormalizer` uses `Unsafe.As` with no TFM guard, so `System.Runtime.CompilerServices.Unsafe` resolves there — reaching `netstandard2.0` consumers through `Light.PortableResults` → `System.Text.Json` → `System.Memory` — and the technique needs no `AllowUnsafeBlocks`, which this project does not set even though the core project does. + +The assumption itself is fixed by `Guid`'s `(int, short, short, byte…)` constructor, by `ToByteArray()`, and by its role as the Win32 `GUID` interop mapping; the two expressions above are what `Guid.Version` and `Guid.Variant` compute from `_c` and `_d`. It spans both fields, so the oracle must too — checking only the version would leave the `D` offset and the variant mask unguarded. + +Prototyped against .NET 10: across all 256 matrix cases the predicate agrees with the oracle and each field agrees with its property, one million calls allocate zero bytes, and the same source compiles clean for `netstandard2.0` under `TreatWarningsAsErrors` with `Unsafe` resolved only transitively. The per-field agreement is evidence for this plan, not a shipped test. ### Public surface @@ -108,85 +68,41 @@ public static partial class Checks } ``` -The predicate is public rather than a private helper like `LooksLikeEmail`: it is properly encapsulated, useful -outside a check chain — a repository or message handler guarding the same invariant — and the root `AGENTS.md` prefers -public APIs. It gets its own class so that `Checks` remains exclusively the home of `Check` extensions; -`CheckExtensions` is the precedent for a top-level `*Extensions` class in this namespace, and no `GuidExtensions` -exists in the solution today. Both members share the one name because they state the same thing and their receivers -differ, so overload resolution never has to choose even though a single `using` imports both. The assertion is then -the usual short-circuit test plus a call to the predicate. Importing the namespace does surface `IsUuidV7` on every -`Guid` in scope: intended, and why the name is specific enough to carry its meaning at a bare `Guid` call site. +The predicate is public rather than a private helper like `LooksLikeEmail`: it is properly encapsulated, useful outside a check chain — a repository or message handler guarding the same invariant — and the root `AGENTS.md` prefers public APIs. It gets its own class so that `Checks` remains exclusively the home of `Check` extensions; `CheckExtensions` is the precedent for a top-level `*Extensions` class in this namespace, and no `GuidExtensions` exists in the solution today. Both members share the one name because they state the same thing and their receivers differ, so overload resolution never has to choose even though a single `using` imports both. The assertion is then the usual short-circuit test plus a call to the predicate. Importing the namespace does surface `IsUuidV7` on every `Guid` in scope: intended, and why the name is specific enough to carry its meaning at a bare `Guid` call site. ### Registration points Six places change beyond the assertion. Two fail silently when missed: -- `ValidationErrorTemplates`' **copy constructor** must copy the new property. It is what `with` expressions run, so - omitting it silently resets a caller's customized template to the default. A test must customize through - `ValidationErrorTemplates.Default with { … }` and assert the resulting message. -- `BuiltInValidationErrorContracts.Contracts` must register - `[ValidationErrorCodes.UuidV7] = ErrorMetadataContract.NoMetadata`, and `BuiltInValidationErrorContractsTests`' - expected no-metadata list must grow with it, since that test asserts the registry's full key set. Without the entry - the code cannot be documented from the built-in registry. - -The other four follow their `Email` counterparts: the `ValidationErrorCodes.UuidV7` constant; a -`UuidV7ValidationErrorDefinition` modeled on `EmailValidationErrorDefinition`, including `TryGetStableMessageProvider` -because the message has no per-error parameters and is cacheable; the shared -`BuiltInValidationErrorDefinitions.UuidV7` static property returning that instance, which is what the assertion passes -to `AddBuiltInError`; and the `ValidationErrorTemplates.UuidV7` property defaulting to -`new DisplayName(" must be a version 7 UUID")` — a separate edit from the copy-constructor line above. - -Place the assertion and definition in `Checks.Guids.cs` and `BuiltInValidationErrorDefinitions.Guids.cs`, matching the -partial-class-per-family layout, and the predicate in `GuidExtensions.cs` beside `CheckExtensions.cs`. Source -generation needs no generator change: it discovers the rule through `[ValidationRule(ValidationErrorCodes.UuidV7)]` on -the built-in-message overload, with `[ValidationRuleMessage("{displayName} must be a version 7 UUID")]` as the -compile-time example message. The default `ValidationRuleMetadataShape.Registered` is correct — the rule carries no -metadata of its own. +- `ValidationErrorTemplates`' **copy constructor** must copy the new property. It is what `with` expressions run, so omitting it silently resets a caller's customized template to the default. A test must customize through `ValidationErrorTemplates.Default with { … }` and assert the resulting message. +- `BuiltInValidationErrorContracts.Contracts` must register `[ValidationErrorCodes.UuidV7] = ErrorMetadataContract.NoMetadata`, and `BuiltInValidationErrorContractsTests`' expected no-metadata list must grow with it, since that test asserts the registry's full key set. Without the entry the code cannot be documented from the built-in registry. + +The other four follow their `Email` counterparts: the `ValidationErrorCodes.UuidV7` constant; a `UuidV7ValidationErrorDefinition` modeled on `EmailValidationErrorDefinition`, including `TryGetStableMessageProvider` because the message has no per-error parameters and is cacheable; the shared `BuiltInValidationErrorDefinitions.UuidV7` static property returning that instance, which is what the assertion passes to `AddBuiltInError`; and the `ValidationErrorTemplates.UuidV7` property defaulting to `new DisplayName(" must be a version 7 UUID")` — a separate edit from the copy-constructor line above. + +Place the assertion and definition in `Checks.Guids.cs` and `BuiltInValidationErrorDefinitions.Guids.cs`, matching the partial-class-per-family layout, and the predicate in `GuidExtensions.cs` beside `CheckExtensions.cs`. Source generation needs no generator change: it discovers the rule through `[ValidationRule(ValidationErrorCodes.UuidV7)]` on the built-in-message overload, with `[ValidationRuleMessage("{displayName} must be a version 7 UUID")]` as the compile-time example message. The default `ValidationRuleMetadataShape.Registered` is correct — the rule carries no metadata of its own. ### Test data and cases -One exhaustive matrix replaces hand-picked boundaries. From RFC 9562's own example -`017f22e2-79b0-7cc3-98c4-dc0c0c07398f`, substitute the version nibble at string index 14 and the variant nibble at -index 19 across all 16 × 16 combinations, asserting for each of the 256 GUIDs that +One exhaustive matrix replaces hand-picked boundaries. From RFC 9562's own example `017f22e2-79b0-7cc3-98c4-dc0c0c07398f`, substitute the version nibble at string index 14 and the variant nibble at index 19 across all 16 × 16 combinations, asserting for each of the 256 GUIDs that ```csharp guid.IsUuidV7() == (guid.Version == 7 && (guid.Variant & 0xC) == 0x8) ``` -`Guid.Variant` returns octet 8's full high nibble rather than the two variant bits alone, hence masking with `0xC` -rather than comparing against `0x2`. +`Guid.Variant` returns octet 8's full high nibble rather than the two variant bits alone, hence masking with `0xC` rather than comparing against `0x2`. -That single boolean pins both offsets and both masks without reading the fields individually — which it could not do -anyway, since only the `bool` is public, and inventing accessors purely to observe internals would be the wrong trade. -Because the matrix varies both nibbles independently over their full ranges, any shifted offset or altered mask -changes which of the 256 inputs are accepted, and the oracle disagrees. Assert the accepted count as a blunter -statement of the same property: exactly 4 of 256 pass, version `7` crossed with variant nibbles `8`–`b`. Any drift -moves that number, which makes it a useful mutation-testing target. +That single boolean pins both offsets and both masks without reading the fields individually — which it could not do anyway, since only the `bool` is public, and inventing accessors purely to observe internals would be the wrong trade. Because the matrix varies both nibbles independently over their full ranges, any shifted offset or altered mask changes which of the 256 inputs are accepted, and the oracle disagrees. Assert the accepted count as a blunter statement of the same property: exactly 4 of 256 pass, version `7` crossed with variant nibbles `8`–`b`. Any drift moves that number, which makes it a useful mutation-testing target. -This subsumes every boundary a hand-written list would enumerate (versions `6` and `8`; NCS, Microsoft, and reserved -variants) and, being deterministic, also replaces a random sample. Keep `Guid.Empty`, `Guid.NewGuid()`, and -`Guid.CreateVersion7()` as named cases: they state things about the platform's own values that the matrix cannot. -Assertion-level tests then stay small — one accepted and one rejected value through `Check`, both overloads, -short-circuit propagation, and template customization. +This subsumes every boundary a hand-written list would enumerate (versions `6` and `8`; NCS, Microsoft, and reserved variants) and, being deterministic, also replaces a random sample. Keep `Guid.Empty`, `Guid.NewGuid()`, and `Guid.CreateVersion7()` as named cases: they state things about the platform's own values that the matrix cannot. Assertion-level tests then stay small — one accepted and one rejected value through `Check`, both overloads, short-circuit propagation, and template customization. -The suite runs on `net10.0` only, so `netstandard2.0` is compile-verified rather than executed. That argues for the -shared implementation rather than for testing around it: with no per-target branch, what these tests pin is what both -packages ship, and the layout holds identically on .NET Framework, where `Guid` is the same interop-mapped type. +The suite runs on `net10.0` only, so `netstandard2.0` is compile-verified rather than executed. That argues for the shared implementation rather than for testing around it: with no per-target branch, what these tests pin is what both target assets ship, and the layout holds identically on .NET Framework, where `Guid` is the same interop-mapped type. ### Release notes -Both packages' 0.7.0 notes move. **Validation** gains the assertion, the `UuidV7` code, the customizable template, and -the new public `GuidExtensions` type; **Validation.OpenApi** gains the built-in metadata contract registered for that -code. The second is easy to skip because that package's own source barely changes, but its registry's key set is -public behavior — a consumer reading the built-in contracts, or narrowing error schemas against them, sees a new -entry. Those notes are capability-level rather than a per-release changelog, so it is one short line there. +Both packages' 0.7.0 notes move. **Validation** gains the assertion, the `UuidV7` code, the customizable template, and the new public `GuidExtensions` type; **Validation.OpenApi** gains the built-in metadata contract registered for that code. The second is easy to skip because that package's own source barely changes, but its registry's key set is public behavior — a consumer reading the built-in contracts, or narrowing error schemas against them, sees a new entry. Those notes are capability-level rather than a per-release changelog, so it is one short line there. ### Deliberately out of scope -- **Timestamp plausibility.** A v7 identifier from a client with a wrong clock is structurally valid but useless for - ordering. Checking the leading timestamp against a permitted skew window needs a clock abstraction, a configurable - tolerance, and metadata carrying the bounds: a separate rule with its own error code, not a parameter on this one. -- **`Check` overloads.** No built-in assertion offers nullable value-type overloads today; `IsNotNull` covers - the null case generically. Adding them for one assertion would be inconsistent. -- **Other UUID versions.** A generalized `IsUuidVersion(int)` would need version metadata on the error and a matching - OpenAPI contract. Revisit only if a second version is actually requested. +- **Timestamp plausibility.** A v7 identifier from a client with a wrong clock is structurally valid but useless for ordering. Checking the leading timestamp against a permitted skew window needs a clock abstraction, a configurable tolerance, and metadata carrying the bounds: a separate rule with its own error code, not a parameter on this one. +- **`Check` overloads.** No built-in assertion offers nullable value-type overloads today; `IsNotNull` covers the null case generically. Adding them for one assertion would be inconsistent. +- **Other UUID versions.** A generalized `IsUuidVersion(int)` would need version metadata on the error and a matching OpenAPI contract. Revisit only if a second version is actually requested. From c707b475c2ed5521ca547214015c35d6d398ca7a Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 15:24:02 +0200 Subject: [PATCH 4/5] feat(validation): add the IsUuidV7 assertion Add IsUuidV7 to Check in both the built-in-message and ErrorOverrides overloads, backed by the new UuidV7 error code, a customizable ValidationErrorTemplates.UuidV7 template, and a metadata-free OpenAPI contract registered for the code. Expose the invariant standalone as public GuidExtensions.IsUuidV7 so the bit manipulation lives in one place and callers can guard the same rule outside a check chain. The predicate reinterprets the Guid over a field overlay mirroring CanonicalTextFormatter's, which serves both target frameworks without conditional compilation and allocates nothing on the passing path (measured: 0 bytes over 1,000,000 calls). Check both RFC 9562 fields, not just the version: a version-7 value carrying a non-RFC variant cannot be produced by Guid.CreateVersion7 and must fail. An exhaustive 16x16 version-nibble by variant-nibble matrix pins both offsets and both masks against an oracle built from Guid.Version and Guid.Variant, and asserts that exactly the four version-7 by variant 8-b combinations are accepted. Closes #72 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01B9QDDRWbDXTrE12S6ErBt4 --- README.md | 6 +- ai-plans/0072-is-uuid-v7.md | 18 +- .../BuiltInValidationErrorContracts.cs | 3 +- ....PortableResults.Validation.OpenApi.csproj | 1 + .../Checks.Guids.cs | 72 ++++++ ...BuiltInValidationErrorDefinitions.Guids.cs | 32 +++ .../GuidExtensions.cs | 39 +++ .../Light.PortableResults.Validation.csproj | 1 + .../Messaging/ValidationErrorTemplates.cs | 13 + .../ValidationErrorCodes.cs | 2 + .../BuiltInValidationErrorContractsTests.cs | 3 +- ...eratedValidationOpenApiIntegrationTests.cs | 63 +++++ .../UuidV7ValidationTests.cs | 239 ++++++++++++++++++ 13 files changed, 480 insertions(+), 12 deletions(-) create mode 100644 src/Light.PortableResults.Validation/Checks.Guids.cs create mode 100644 src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Guids.cs create mode 100644 src/Light.PortableResults.Validation/GuidExtensions.cs create mode 100644 tests/Light.PortableResults.Validation.Tests/UuidV7ValidationTests.cs diff --git a/README.md b/README.md index a49df6e..0639f04 100644 --- a/README.md +++ b/README.md @@ -700,7 +700,9 @@ public sealed class PurchaseOrderValidator : Validator PurchaseOrderDto dto ) { - context.Check(dto.OrderId).IsNotEmpty(); + // The client mints the order ID, so require a UUIDv7 — its leading timestamp keeps + // client-generated keys roughly sortable and index-friendly. Guid.Empty and v4 GUIDs fail. + context.Check(dto.OrderId).IsUuidV7(); dto.CustomerEmail = context.Check(dto.CustomerEmail).IsEmail(); // If dto.ShippingAddress is null the child validator emits a null error automatically. @@ -755,6 +757,8 @@ public sealed class OrderItemValidator : Validator } ``` +`IsUuidV7` fails with the `UuidV7` error code unless the GUID's RFC 9562 version field is `7` **and** its variant bits are the RFC variant. The same invariant is available standalone as `guid.IsUuidV7()` (`GuidExtensions`) when a repository or message handler needs to guard it outside a check chain. + > **What is `ValidatedValue`?** > > `ValidatedValue` is the handshake type between a validator and its callers within a single validation pipeline run. Rather than surfacing errors immediately as `Result`, it carries the signal back: either a successfully validated value via `ValidatedValue.Success(value)`, or `ValidatedValue.NoValue` when errors were added. `checkpoint.ToValidatedValue(dto)` chooses the right outcome based on whether any errors were added since the checkpoint was created. You never need to construct `ValidatedValue` directly unless you are writing a transforming validator — see [Mapping to Domain Objects](#mapping-to-domain-objects). diff --git a/ai-plans/0072-is-uuid-v7.md b/ai-plans/0072-is-uuid-v7.md index 619df18..377d7b9 100644 --- a/ai-plans/0072-is-uuid-v7.md +++ b/ai-plans/0072-is-uuid-v7.md @@ -8,15 +8,15 @@ UUIDv7 (RFC 9562 §5.7) leads with a 48-bit Unix millisecond timestamp, which ke ## Acceptance Criteria -- [ ] `Check` exposes `IsUuidV7` in both built-in-message and `ErrorOverrides` overloads, each honoring `shortCircuitOnError` and the already-short-circuited state, matching every other built-in assertion. -- [ ] The same invariant is available standalone as public `GuidExtensions.IsUuidV7(this Guid value)`, under that one name, with the assertion delegating to it so the bit manipulation exists in a single place. -- [ ] The assertion passes only for GUIDs whose RFC 9562 version field is `7` **and** whose two most significant variant bits are `10`. `Guid.Empty`, `Guid.NewGuid()` (v4), and version-7 values with a non-RFC variant all fail. -- [ ] Failures carry the new `ValidationErrorCodes.UuidV7` code and a message from a new customizable `ValidationErrorTemplates.UuidV7` template, which survives `with`-expression copies of `ValidationErrorTemplates`. -- [ ] The rule is discoverable by OpenAPI source generation and resolves to a registered metadata-free contract, so a validator using it documents the `UuidV7` code without an explicit hint and without an unknown-code diagnostic. -- [ ] An exhaustive version-nibble × variant-nibble matrix checks the predicate against an oracle built from **both** `Guid.Version` and `Guid.Variant`, so the BCL guards the whole layout assumption rather than half of it. -- [ ] Automated tests additionally cover both overloads, short-circuit propagation, template customization, and the generated OpenAPI metadata. Solution test coverage stays above 95%. -- [ ] One predicate implementation serves both target frameworks, without conditional compilation, and the passing path allocates nothing on either. -- [ ] The README shows the assertion where client-generated identifiers are validated, and the 0.7.0 `` of **both** affected packages record their own part of the change. +- [x] `Check` exposes `IsUuidV7` in both built-in-message and `ErrorOverrides` overloads, each honoring `shortCircuitOnError` and the already-short-circuited state, matching every other built-in assertion. +- [x] The same invariant is available standalone as public `GuidExtensions.IsUuidV7(this Guid value)`, under that one name, with the assertion delegating to it so the bit manipulation exists in a single place. +- [x] The assertion passes only for GUIDs whose RFC 9562 version field is `7` **and** whose two most significant variant bits are `10`. `Guid.Empty`, `Guid.NewGuid()` (v4), and version-7 values with a non-RFC variant all fail. +- [x] Failures carry the new `ValidationErrorCodes.UuidV7` code and a message from a new customizable `ValidationErrorTemplates.UuidV7` template, which survives `with`-expression copies of `ValidationErrorTemplates`. +- [x] The rule is discoverable by OpenAPI source generation and resolves to a registered metadata-free contract, so a validator using it documents the `UuidV7` code without an explicit hint and without an unknown-code diagnostic. +- [x] An exhaustive version-nibble × variant-nibble matrix checks the predicate against an oracle built from **both** `Guid.Version` and `Guid.Variant`, so the BCL guards the whole layout assumption rather than half of it. +- [x] Automated tests additionally cover both overloads, short-circuit propagation, template customization, and the generated OpenAPI metadata. Solution test coverage stays above 95%. +- [x] One predicate implementation serves both target frameworks, without conditional compilation, and the passing path allocates nothing on either. +- [x] The README shows the assertion where client-generated identifiers are validated, and the 0.7.0 `` of **both** affected packages record their own part of the change. ## Technical Details diff --git a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs index fd80e14..81c8e1b 100644 --- a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs +++ b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs @@ -126,7 +126,8 @@ private static FrozenDictionary CreateContracts() [ValidationErrorCodes.NotNullOrWhiteSpace] = ErrorMetadataContract.NoMetadata, [ValidationErrorCodes.Email] = ErrorMetadataContract.NoMetadata, [ValidationErrorCodes.DigitsOnly] = ErrorMetadataContract.NoMetadata, - [ValidationErrorCodes.LettersAndDigitsOnly] = ErrorMetadataContract.NoMetadata + [ValidationErrorCodes.LettersAndDigitsOnly] = ErrorMetadataContract.NoMetadata, + [ValidationErrorCodes.UuidV7] = ErrorMetadataContract.NoMetadata }.ToFrozenDictionary(StringComparer.Ordinal); } diff --git a/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj b/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj index 6fbbe30..7f39cd6 100644 --- a/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj +++ b/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj @@ -12,6 +12,7 @@ - Typed comparison and range helper metadata contracts for endpoint-specific narrowing. - Opt-in source generator for deriving Minimal API and MVC validation OpenAPI metadata from synchronous validators. - Native AOT compatible. + - Registers a built-in metadata-free contract for the new UuidV7 validation error code. diff --git a/src/Light.PortableResults.Validation/Checks.Guids.cs b/src/Light.PortableResults.Validation/Checks.Guids.cs new file mode 100644 index 0000000..27513e2 --- /dev/null +++ b/src/Light.PortableResults.Validation/Checks.Guids.cs @@ -0,0 +1,72 @@ +using System; +using Light.PortableResults.Validation.Definitions; + +namespace Light.PortableResults.Validation; + +/// +/// Provides assertions for instances. +/// +public static partial class Checks +{ + /// + /// Adds a validation error when the checked GUID is not a version 7 UUID as defined by RFC 9562 (section 5.7). + /// + /// The check carrying the value and validation context. + /// + /// When , marks the check as short-circuited after a failure so that subsequent + /// assertions in the chain are skipped; defaults to . + /// + /// The current check for fluent chaining. + /// + /// The value passes only when its version field is 7 and its two most significant variant bits are + /// 10. and version 4 GUIDs created by therefore + /// fail. Use to check the same invariant outside a check chain. + /// + [ValidationRule(ValidationErrorCodes.UuidV7)] + [ValidationRuleMessage("{displayName} must be a version 7 UUID")] + public static Check IsUuidV7(this Check check, bool shortCircuitOnError = false) => + check.IsShortCircuited || check.Value.IsUuidV7() ? + check : + AddBuiltInError(check, BuiltInValidationErrorDefinitions.UuidV7, shortCircuitOnError); + + /// + /// Adds a validation error when the checked GUID is not a version 7 UUID as defined by RFC 9562 + /// (section 5.7), applying the specified inline error overrides. + /// + /// The check carrying the value and validation context. + /// + /// Inline overrides for the built-in error details. Pass a plain to replace only + /// the message, or supply a full to also override the code, category, or + /// metadata. At least one field must be set. + /// + /// + /// When , marks the check as short-circuited after a failure so that subsequent + /// assertions in the chain are skipped; defaults to . + /// + /// The current check for fluent chaining. + /// + /// The value passes only when its version field is 7 and its two most significant variant bits are + /// 10. and version 4 GUIDs created by therefore + /// fail. Use to check the same invariant outside a check chain. + /// + /// + /// Thrown when has no field set, or when + /// is non- but empty or whitespace. + /// + public static Check IsUuidV7( + this Check check, + ErrorOverrides overrides, + bool shortCircuitOnError = false + ) + { + EnsureErrorOverrides(overrides); + return check.IsShortCircuited || check.Value.IsUuidV7() ? + check : + AddBuiltInErrorWithOverrides( + check, + BuiltInValidationErrorDefinitions.UuidV7, + overrides, + shortCircuitOnError + ); + } +} diff --git a/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Guids.cs b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Guids.cs new file mode 100644 index 0000000..93e94a0 --- /dev/null +++ b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Guids.cs @@ -0,0 +1,32 @@ +using Light.PortableResults.Validation.Messaging; + +namespace Light.PortableResults.Validation.Definitions; + +public static partial class BuiltInValidationErrorDefinitions +{ + /// + /// Gets the shared definition for version 7 UUID validation failures. + /// + public static ValidationErrorDefinition UuidV7 { get; } = new UuidV7ValidationErrorDefinition(); + + /// + /// Reusable built-in validation error definition for version 7 UUID validation failures. + /// + public sealed class UuidV7ValidationErrorDefinition : ValidationErrorDefinition + { + /// + /// Initializes a new instance of . + /// + public UuidV7ValidationErrorDefinition() : base(code: ValidationErrorCodes.UuidV7) { } + + /// + public override bool TryGetStableMessageProvider( + ReadOnlyValidationContext context, + out object provider + ) => TryGetStableProvider(context.ErrorTemplates.UuidV7, out provider); + + /// + public override ValidationErrorMessage ProvideMessage(in ValidationErrorMessageContext context) => + context.ValidationContext.ErrorTemplates.UuidV7.ProvideMessage(in context); + } +} diff --git a/src/Light.PortableResults.Validation/GuidExtensions.cs b/src/Light.PortableResults.Validation/GuidExtensions.cs new file mode 100644 index 0000000..8a9350d --- /dev/null +++ b/src/Light.PortableResults.Validation/GuidExtensions.cs @@ -0,0 +1,39 @@ +using System; +using System.Runtime.CompilerServices; + +namespace Light.PortableResults.Validation; + +/// +/// Provides extension methods for . +/// +public static class GuidExtensions +{ + /// + /// Checks whether the specified GUID is a version 7 UUID as defined by RFC 9562 (section 5.7). + /// + /// The GUID to inspect. + /// + /// when the version field is 7 and the two most significant variant bits + /// are 10 (the RFC 9562 variant); otherwise . + /// + /// + /// Both fields are checked: a value whose version nibble is 7 but that carries a non-RFC variant + /// (for example the NCS-reserved variant) is not a UUIDv7 and cannot be produced by + /// Guid.CreateVersion7. This method reads the GUID's storage directly and allocates nothing. + /// + public static bool IsUuidV7(this Guid value) + { + ref var fields = ref Unsafe.As(ref value); + return ((fields.C >> 12) & 0x0F) == 7 && (fields.D & 0xC0) == 0x80; + } + +#pragma warning disable CS0649 // Fields are populated by reinterpreting Guid storage via Unsafe.As. + private struct GuidFields + { + public int A; + public short B; + public short C; // the high nibble of the high byte carries the version + public byte D; // the two most significant bits carry the variant + } +#pragma warning restore CS0649 +} diff --git a/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj b/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj index 13b83cc..d8cb236 100644 --- a/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj +++ b/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj @@ -15,6 +15,7 @@ - Built-in comparison and range metadata now preserves the typed scalar vocabulary and its canonical validation-message encodings. - Adds a net10.0 asset so DateOnly and TimeOnly validation boundaries retain their dedicated metadata kinds. + - Adds the IsUuidV7 assertion for Check<Guid>, with the UuidV7 error code, a customizable message template, and the standalone GuidExtensions.IsUuidV7 predicate. diff --git a/src/Light.PortableResults.Validation/Messaging/ValidationErrorTemplates.cs b/src/Light.PortableResults.Validation/Messaging/ValidationErrorTemplates.cs index c6e4446..cf5c3f1 100644 --- a/src/Light.PortableResults.Validation/Messaging/ValidationErrorTemplates.cs +++ b/src/Light.PortableResults.Validation/Messaging/ValidationErrorTemplates.cs @@ -81,6 +81,9 @@ public sealed partial record ValidationErrorTemplates private static readonly IValidationErrorMessageTemplate DefaultLettersAndDigitsOnlyTemplate = new DisplayName(" must contain only letters and digits"); + private static readonly IValidationErrorMessageTemplate DefaultUuidV7Template = + new DisplayName(" must be a version 7 UUID"); + private static readonly IValidationErrorMessageTemplate DefaultCountTemplate = new DisplayNameWithParameter(" must contain exactly ", " item(s)"); @@ -136,6 +139,7 @@ private ValidationErrorTemplates(ValidationErrorTemplates original) Email = original.Email; DigitsOnly = original.DigitsOnly; LettersAndDigitsOnly = original.LettersAndDigitsOnly; + UuidV7 = original.UuidV7; Count = original.Count; MinCount = original.MinCount; MaxCount = original.MaxCount; @@ -368,6 +372,15 @@ public IValidationErrorMessageTemplate LettersAndDigitsOnly init => field = value ?? throw new ArgumentNullException(nameof(value)); } = DefaultLettersAndDigitsOnlyTemplate; + /// + /// Gets the template for version 7 UUID validation failures. + /// + public IValidationErrorMessageTemplate UuidV7 + { + get; + init => field = value ?? throw new ArgumentNullException(nameof(value)); + } = DefaultUuidV7Template; + /// /// Gets the template for exact-count validation failures. /// diff --git a/src/Light.PortableResults.Validation/ValidationErrorCodes.cs b/src/Light.PortableResults.Validation/ValidationErrorCodes.cs index bf32445..cdf6773 100644 --- a/src/Light.PortableResults.Validation/ValidationErrorCodes.cs +++ b/src/Light.PortableResults.Validation/ValidationErrorCodes.cs @@ -59,6 +59,8 @@ public static class ValidationErrorCodes public const string DigitsOnly = "DigitsOnly"; /// Validation error code for letters-and-digits-only failures. public const string LettersAndDigitsOnly = "LettersAndDigitsOnly"; + /// Validation error code for version 7 UUID failures. + public const string UuidV7 = "UuidV7"; /// Validation error code for predicate-based failures. public const string Predicate = "Predicate"; } diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs index 77631ae..35228ff 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs @@ -108,7 +108,8 @@ public void Contracts_ShouldContainExpectedBuiltInCodes() ValidationErrorCodes.NotNullOrWhiteSpace, ValidationErrorCodes.Email, ValidationErrorCodes.DigitsOnly, - ValidationErrorCodes.LettersAndDigitsOnly + ValidationErrorCodes.LettersAndDigitsOnly, + ValidationErrorCodes.UuidV7 ]; BuiltInValidationErrorContracts.Contracts.Keys.Should() diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs index 46a1914..caa7755 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs @@ -75,6 +75,47 @@ public async Task ProducesPortableValidationProblemFor_ShouldApplyGeneratedSchem genericProblemResponse.Content!["application/problem+json"].Examples.Should().BeNullOrEmpty(); } + [Fact] + public async Task ProducesPortableValidationProblemFor_ShouldDocumentUuidV7FromTheBuiltInContracts() + { + await using var app = ValidationOpenApiDocumentTestUtilities.CreateApp( + contracts => contracts.RegisterBuiltInValidationErrors(), + endpoints => + { + endpoints + .MapPost("/generated-validation/uuid-v7", static () => Results.BadRequest()) + .WithName("GeneratedUuidV7Validation") + .ProducesPortableValidationProblemFor( + configure: builder => builder.UseFormat(ValidationProblemSerializationFormat.Rich) + ); + } + ); + + var document = await ValidationOpenApiDocumentTestUtilities.GetOpenApiDocumentAsync(app); + var operation = document.Paths["/generated-validation/uuid-v7"].Operations![HttpMethod.Post]; + var response = (OpenApiResponse) operation.Responses![StatusCodes.Status400BadRequest.ToString()]; + var mediaType = response.Content!["application/problem+json"]; + var schemaReference = (OpenApiSchemaReference) mediaType.Schema!; + var envelope = ValidationOpenApiDocumentTestUtilities.GetSchemaComponent( + document, + ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId(schemaReference) + ); + var errors = (OpenApiSchema) ((OpenApiSchema) envelope.Properties!["errors"]).Items!; + + errors.OneOf!.Select( + static schema => + ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId((OpenApiSchemaReference) schema) + ) + .Should() + .BeEquivalentTo("PortableError__UuidV7"); + + var example = (OpenApiExample) mediaType.Examples!["ValidationProblem"]; + var body = example.Value.Should().BeOfType().Subject; + var exampleErrors = body["errors"].Should().BeOfType().Subject; + exampleErrors.ToJsonString().Should().Contain("\"code\":\"UuidV7\""); + exampleErrors.ToJsonString().Should().Contain("\"message\":\"id must be a version 7 UUID\""); + } + [Fact] public async Task MvcAttribute_ShouldApplyGeneratedSchemasExamplesAndOverrides() { @@ -294,6 +335,28 @@ GeneratedRatingDto dto } } +public sealed class GeneratedClientIdentifierDto +{ + public Guid Id { get; init; } +} + +[GeneratePortableValidationOpenApi] +public sealed partial class GeneratedClientIdentifierValidator : Validator +{ + public GeneratedClientIdentifierValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + GeneratedClientIdentifierDto dto + ) + { + context.Check(dto.Id).IsUuidV7(); + return checkpoint.ToValidatedValue(dto); + } +} + public sealed class GeneratedValidationMvcMetadata { public string TraceId { get; init; } = string.Empty; diff --git a/tests/Light.PortableResults.Validation.Tests/UuidV7ValidationTests.cs b/tests/Light.PortableResults.Validation.Tests/UuidV7ValidationTests.cs new file mode 100644 index 0000000..04e4250 --- /dev/null +++ b/tests/Light.PortableResults.Validation.Tests/UuidV7ValidationTests.cs @@ -0,0 +1,239 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using Light.PortableResults.Validation.Messaging; +using Xunit; + +namespace Light.PortableResults.Validation.Tests; + +public sealed class UuidV7ValidationTests +{ + // The example UUIDv7 from RFC 9562. The version nibble sits at index 14 and the variant nibble at index 19. + private const string RfcExampleUuid = "017f22e2-79b0-7cc3-98c4-dc0c0c07398f"; + private const int VersionNibbleIndex = 14; + private const int VariantNibbleIndex = 19; + private const string HexDigits = "0123456789abcdef"; + + private static readonly Guid AcceptedUuidV7 = Guid.Parse(RfcExampleUuid); + private static readonly Guid RejectedUuid = Guid.Parse("017f22e2-79b0-4cc3-98c4-dc0c0c07398f"); + + [Fact] + public void IsUuidV7_ShouldAgreeWithVersionAndVariantOracle_AcrossTheFullNibbleMatrix() + { + for (var versionNibble = 0; versionNibble < 16; versionNibble++) + { + for (var variantNibble = 0; variantNibble < 16; variantNibble++) + { + var guid = CreateGuidWithNibbles(versionNibble, variantNibble); + + guid.IsUuidV7() + .Should() + .Be( + guid.Version == 7 && (guid.Variant & 0xC) == 0x8, + "the version nibble is {0:x} and the variant nibble is {1:x}", + versionNibble, + variantNibble + ); + } + } + } + + [Fact] + public void IsUuidV7_ShouldAcceptOnlyVersion7CrossedWithTheRfcVariantNibbles() + { + var acceptedNibbles = new List<(int VersionNibble, int VariantNibble)>(); + + for (var versionNibble = 0; versionNibble < 16; versionNibble++) + { + for (var variantNibble = 0; variantNibble < 16; variantNibble++) + { + if (CreateGuidWithNibbles(versionNibble, variantNibble).IsUuidV7()) + { + acceptedNibbles.Add((versionNibble, variantNibble)); + } + } + } + + acceptedNibbles.Should().BeEquivalentTo( + [(7, 0x8), (7, 0x9), (7, 0xA), (7, 0xB)] + ); + } + + [Fact] + public void IsUuidV7_ShouldRejectEmptyGuid() => Guid.Empty.IsUuidV7().Should().BeFalse(); + + [Fact] + public void IsUuidV7_ShouldRejectNewGuid() => Guid.NewGuid().IsUuidV7().Should().BeFalse(); + + [Fact] + public void IsUuidV7_ShouldAcceptCreateVersion7() => Guid.CreateVersion7().IsUuidV7().Should().BeTrue(); + + [Fact] + public void IsUuidV7_ShouldAddError_WhenGuidIsNotAVersion7Uuid() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + + context.Check(RejectedUuid, target: "orderId", displayName: "Order ID").IsUuidV7(); + + context.Errors.Should().ContainSingle( + error => + error.Target == "orderId" && + error.Code == "UuidV7" && + error.Message == "Order ID must be a version 7 UUID" + ); + } + + [Fact] + public void IsUuidV7_ShouldNotAddError_WhenGuidIsAVersion7Uuid() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + + context.Check(AcceptedUuidV7, target: "orderId", displayName: "Order ID").IsUuidV7(); + + context.Errors.Should().BeEmpty(); + } + + [Fact] + public void IsUuidV7_ShouldAddError_WhenOverridesAreUsed() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + + context + .Check(RejectedUuid, target: "orderId", displayName: "Order ID") + .IsUuidV7(new ErrorOverrides { Code = "OrderIdNotSortable" }); + + context.Errors.Should().ContainSingle( + error => error.Target == "orderId" && error.Code == "OrderIdNotSortable" + ); + } + + [Fact] + public void IsUuidV7_ShouldNotAddError_WhenOverridesAreUsedAndGuidIsAVersion7Uuid() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + + context + .Check(AcceptedUuidV7, target: "orderId", displayName: "Order ID") + .IsUuidV7(new ErrorOverrides { Code = "Unused" }); + + context.Errors.Should().BeEmpty(); + } + + [Fact] + public void IsUuidV7_ShouldThrow_WhenOverridesAreEmpty() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + var check = context.Check(AcceptedUuidV7, target: "orderId"); + + var act = () => check.IsUuidV7(new ErrorOverrides()); + + act.Should().Throw(); + } + + [Fact] + public void IsUuidV7_ShouldRespectShortCircuit() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + var check = context.Check(RejectedUuid, target: "orderId").ShortCircuit(); + + check.IsUuidV7().IsShortCircuited.Should().BeTrue(); + context.Errors.Should().BeEmpty(); + } + + [Fact] + public void IsUuidV7_ShouldRespectShortCircuit_WhenOverridesAreUsed() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + var check = context.Check(RejectedUuid, target: "orderId").ShortCircuit(); + + check.IsUuidV7(new ErrorOverrides { Code = "Unused" }).IsShortCircuited.Should().BeTrue(); + context.Errors.Should().BeEmpty(); + } + + [Fact] + public void IsUuidV7_ShouldShortCircuit_WhenRequested() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + var check = context.Check(RejectedUuid, target: "orderId", displayName: "Order ID"); + + check.IsUuidV7(shortCircuitOnError: true).IsShortCircuited.Should().BeTrue(); + context.Errors.Should().ContainSingle(error => error.Target == "orderId" && error.Code == "UuidV7"); + } + + [Fact] + public void IsUuidV7_ShouldShortCircuit_WhenOverridesAreUsedAndRequested() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + var check = context.Check(RejectedUuid, target: "orderId", displayName: "Order ID"); + + check + .IsUuidV7(new ErrorOverrides { Message = "Order ID must be sortable" }, shortCircuitOnError: true) + .IsShortCircuited.Should() + .BeTrue(); + context.Errors.Should().ContainSingle( + error => error.Target == "orderId" && error.Message == "Order ID must be sortable" + ); + } + + [Fact] + public void IsUuidV7_ShouldNotShortCircuit_WhenNotRequested() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + var check = context.Check(RejectedUuid, target: "orderId", displayName: "Order ID"); + + check.IsUuidV7().IsShortCircuited.Should().BeFalse(); + } + + [Fact] + public void IsUuidV7_ShouldUseTheCustomizedTemplate() + { + var context = CreateContextWithTemplates( + ValidationErrorTemplates.Default with + { + UuidV7 = new ValidationErrorTemplates.Constant("Only time-ordered identifiers are accepted") + } + ); + + context.Check(RejectedUuid, target: "orderId", displayName: "Order ID").IsUuidV7(); + + context.Errors.Should().ContainSingle( + error => + error.Code == "UuidV7" && + error.Message == "Only time-ordered identifiers are accepted" + ); + } + + [Fact] + public void IsUuidV7_ShouldKeepTheCustomizedTemplate_WhenTemplatesAreCopiedAgain() + { + var customizedTemplates = ValidationErrorTemplates.Default with + { + UuidV7 = new ValidationErrorTemplates.Constant("Only time-ordered identifiers are accepted") + }; + var context = CreateContextWithTemplates( + customizedTemplates with { NotNull = new ValidationErrorTemplates.Constant("Value is required") } + ); + + context.Check(RejectedUuid, target: "orderId", displayName: "Order ID").IsUuidV7(); + + context.Errors.Should().ContainSingle( + error => + error.Code == "UuidV7" && + error.Message == "Only time-ordered identifiers are accepted" + ); + } + + private static ValidationContext CreateContextWithTemplates(ValidationErrorTemplates templates) + { + var options = new ValidationContextOptions() with { ErrorTemplates = templates }; + return new DefaultValidationContextFactory(options).CreateValidationContext(); + } + + private static Guid CreateGuidWithNibbles(int versionNibble, int variantNibble) + { + var characters = RfcExampleUuid.ToCharArray(); + characters[VersionNibbleIndex] = HexDigits[versionNibble]; + characters[VariantNibbleIndex] = HexDigits[variantNibble]; + return Guid.Parse(new string(characters)); + } +} From 1c0ebf9162bcca646e9d163d5d6fc53404146b12 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 15:34:26 +0200 Subject: [PATCH 5/5] test(validation): close the IsUuidV7 test parity gaps Add UuidV7 to the two registry-style suites that enumerate every built-in error definition: the stable-provider test, which contributes the BeSameAs assertion on ErrorTemplates.UuidV7 that UuidV7ValidationTests never made directly, and the ProvideMessage family. Those lists are what the next person adding a definition reads as the checklist, so leaving UuidV7 out of them was a consistency gap even though coverage was already complete. Rename the stable-provider test to name the Guid family. It is partitioned against ComparableDefinitions_ShouldExposeStableProviders, so the enumeration in its name has to stay honest. Record why the RFC-derived accepted set is not redundant with the version-by-variant matrix. Guid.Version and Guid.Variant compute the same two expressions the predicate evaluates, so the matrix pins the field overlay's offsets but cannot pin the masks; only the hardcoded accepted set does that. Both tests walk the same 256 inputs, which invites deleting one of them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01B9QDDRWbDXTrE12S6ErBt4 --- .../UuidV7ValidationTests.cs | 7 +++++++ .../ValidationErrorDefinitionTests.cs | 15 ++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/Light.PortableResults.Validation.Tests/UuidV7ValidationTests.cs b/tests/Light.PortableResults.Validation.Tests/UuidV7ValidationTests.cs index 04e4250..ff7a49d 100644 --- a/tests/Light.PortableResults.Validation.Tests/UuidV7ValidationTests.cs +++ b/tests/Light.PortableResults.Validation.Tests/UuidV7ValidationTests.cs @@ -17,6 +17,10 @@ public sealed class UuidV7ValidationTests private static readonly Guid AcceptedUuidV7 = Guid.Parse(RfcExampleUuid); private static readonly Guid RejectedUuid = Guid.Parse("017f22e2-79b0-4cc3-98c4-dc0c0c07398f"); + // The BCL computes Version as (_c >> 12) & 0xF and Variant as _d >> 4, which are the same two expressions + // the predicate evaluates over its own field overlay. This oracle therefore pins the overlay's field + // offsets — the load-bearing assumption — but cannot pin the masks, because a wrong mask would have to be + // wrong in the BCL too. The masks are pinned by the RFC-derived accepted set in the test below. [Fact] public void IsUuidV7_ShouldAgreeWithVersionAndVariantOracle_AcrossTheFullNibbleMatrix() { @@ -38,6 +42,9 @@ public void IsUuidV7_ShouldAgreeWithVersionAndVariantOracle_AcrossTheFullNibbleM } } + // Not redundant with the matrix above: the expected set is read off RFC 9562 rather than derived from + // Guid.Version and Guid.Variant, so this is the only test that constrains the version and variant masks + // independently of the BCL. Keep it even though both tests walk the same 256 inputs. [Fact] public void IsUuidV7_ShouldAcceptOnlyVersion7CrossedWithTheRfcVariantNibbles() { diff --git a/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs b/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs index 1769468..df6aefe 100644 --- a/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs +++ b/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs @@ -519,7 +519,7 @@ public void ParameterizedTemplateDefinitions_ShouldReportStableProviders() } [Fact] - public void CountEqualityStringEnumAndDecimalDefinitions_ShouldExposeStableProviders() + public void CountEqualityStringEnumDecimalAndGuidDefinitions_ShouldExposeStableProviders() { var context = DefaultValidationContextFactory.Create().CreateValidationContext(); var readOnlyContext = context.AsReadOnly(); @@ -538,6 +538,7 @@ public void CountEqualityStringEnumAndDecimalDefinitions_ShouldExposeStableProvi var lettersAndDigitsOnly = BuiltInValidationErrorDefinitions.LettersAndDigitsOnly; var enumValue = BuiltInValidationErrorDefinitions.IsInEnum(); var precisionScale = BuiltInValidationErrorDefinitions.PrecisionScale(4, 2, ignoreTrailingZeros: true); + var uuidV7 = BuiltInValidationErrorDefinitions.UuidV7; count.TryGetStableMessageProvider(readOnlyContext, out var countProvider).Should().BeTrue(); minCount.TryGetStableMessageProvider(readOnlyContext, out var minCountProvider).Should().BeTrue(); @@ -555,6 +556,7 @@ public void CountEqualityStringEnumAndDecimalDefinitions_ShouldExposeStableProvi .BeTrue(); enumValue.TryGetStableMessageProvider(readOnlyContext, out var enumProvider).Should().BeTrue(); precisionScale.TryGetStableMessageProvider(readOnlyContext, out var precisionScaleProvider).Should().BeTrue(); + uuidV7.TryGetStableMessageProvider(readOnlyContext, out var uuidV7Provider).Should().BeTrue(); countProvider.Should().BeSameAs(context.ErrorTemplates.Count); minCountProvider.Should().BeSameAs(context.ErrorTemplates.MinCount); @@ -571,6 +573,7 @@ public void CountEqualityStringEnumAndDecimalDefinitions_ShouldExposeStableProvi lettersDigitsProvider.Should().BeSameAs(context.ErrorTemplates.LettersAndDigitsOnly); enumProvider.Should().BeSameAs(context.ErrorTemplates.Enum); precisionScaleProvider.Should().BeSameAs(context.ErrorTemplates.PrecisionScale); + uuidV7Provider.Should().BeSameAs(context.ErrorTemplates.UuidV7); } [Fact] @@ -708,6 +711,16 @@ public void PrecisionScale_ShouldProvideExpectedMessage() precisionScale.ProvideMessage(messageContext).Text.Should().ContainAll("4", "2"); } + [Fact] + public void UuidV7_ShouldProvideExpectedMessage() + { + var context = DefaultValidationContextFactory.Create().CreateValidationContext(); + var messageContext = context.Check(Guid.Empty, target: "orderId", displayName: "Order ID") + .CreateMessageContext(); + var uuidV7 = BuiltInValidationErrorDefinitions.UuidV7; + uuidV7.ProvideMessage(messageContext).Text.Should().ContainAll("Order ID", "version 7 UUID"); + } + [Fact] public void Count_ShouldThrow_WhenCacheIsNull() {