Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,7 @@ Use child validators when your DTO contains nested objects or collections that e
public sealed record PurchaseOrderDto
{
public required Guid OrderId { get; set; }
public required DateTime PlacedAt { get; set; }
public required string CustomerEmail { get; set; } = string.Empty;
public required ShippingAddressDto ShippingAddress { get; set; }
public required List<string> Tags { get; set; }
Expand Down Expand Up @@ -703,6 +704,11 @@ public sealed class PurchaseOrderValidator : Validator<PurchaseOrderDto>
// 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();

// Timestamps must be unambiguous, so require UTC. With the default System.Text.Json
// converter this means the payload has to carry a trailing "Z".
context.Check(dto.PlacedAt).IsUtc();

dto.CustomerEmail = context.Check(dto.CustomerEmail).IsEmail();

// If dto.ShippingAddress is null the child validator emits a null error automatically.
Expand Down Expand Up @@ -759,6 +765,21 @@ public sealed class OrderItemValidator : Validator<OrderItemDto>

`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.

`IsUtc`, `IsLocal`, and `IsUnspecified` assert the `DateTime.Kind` of the checked value and fail with the `Utc`, `Local`, and `Unspecified` error codes. They partition `DateTimeKind`: every `DateTime` is accepted by exactly one of them. The contract is only the kind of the value the check sees, independent of its origin — `DateTime.UtcNow`, `DateTime.SpecifyKind`, a custom converter, and non-JSON transports can all produce `Utc` just as a JSON payload with a trailing `Z` does.

That matters for JSON requests, because the default `System.Text.Json` converter maps the ISO 8601 forms to kinds like this:

| Wire value | `DateTime.Kind` | Accepted by |
| --- | --- | --- |
| `2026-08-02T10:00:00Z` | `Utc` | `IsUtc` |
| `2026-08-02T10:00:00+00:00` | `Local` | `IsLocal` |
| `2026-08-02T10:00:00+02:00` | `Local` | `IsLocal` |
| `2026-08-02T10:00:00` | `Unspecified` | `IsUnspecified` |

So `IsUtc` effectively requires a trailing `Z` on the wire: an explicit numeric offset is converted to server-local time and deserializes as `Local`, and that includes `+00:00` even though it denotes the same instant as `Z`. Document the `Z` requirement for your clients — a `+00:00` payload is rejected. Conversely, the two `Local` values above only agree on the instant; their wall-clock value depends on the server's time zone, which is rarely what a portable API wants.

`DateTime` values reach these assertions after the per-check normalizer or `ValidationContextOptions.ValueNormalizer` has run — the default `TrimStringNormalizer` passes non-strings through unchanged. Do not install a normalizer that coerces `Unspecified` to `Utc`: it destroys exactly the signal being validated, and `IsUtc` would then pass for every request. Convert to UTC in your mapping code, after validation. Note also that `default(DateTime)` is `Unspecified`, so `IsUnspecified` accepts a missing value; pair it with a range check when that matters.

> **What is `ValidatedValue<T>`?**
>
> `ValidatedValue<T>` is the handshake type between a validator and its callers within a single validation pipeline run. Rather than surfacing errors immediately as `Result<T>`, it carries the signal back: either a successfully validated value via `ValidatedValue<T>.Success(value)`, or `ValidatedValue<T>.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<T>` directly unless you are writing a transforming validator — see [Mapping to Domain Objects](#mapping-to-domain-objects).
Expand Down
77 changes: 77 additions & 0 deletions ai-plans/0075-datetime-assertions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Add `DateTimeKind` Assertions

## Rationale

With the default `System.Text.Json` converter, a request `DateTime` retains the distinction between a trailing `Z` (`Utc`), an explicit numeric offset converted to server-local time (`Local`), and no zone (`Unspecified`). The library cannot currently require any of these kinds, leaving values vulnerable to later persistence or comparison under the wrong time-zone assumption.

Add `IsUtc`, `IsLocal`, and `IsUnspecified` as the first candidate from #74, following `IsUuidV7` from #72: dedicated error codes, customizable messages, and metadata-free OpenAPI contracts.

## Acceptance Criteria

- [x] `Check<DateTime>` exposes `IsUtc`, `IsLocal`, and `IsUnspecified` in built-in-message and `ErrorOverrides` overloads; all honor `shortCircuitOnError` and an already-short-circuited check.
- [x] Failures use new `ValidationErrorCodes.Utc`, `Local`, and `Unspecified` codes and customizable `ValidationErrorTemplates` properties that survive `with`-expression copies.
- [x] All three built-in-message overloads are discoverable by OpenAPI source generation. A validator using them generates a document against the built-in registry without generator errors, `WithErrorMetadata`, or an unregistered-code failure.
- [x] A 3×3 kind matrix proves that each assertion accepts exactly one `DateTimeKind` and that together they partition the enum.
- [x] A DTO deserialization test covers the four ISO 8601 forms below, including `+00:00` separately from a non-zero offset, and asserts both the resulting kind and accepted assertion.
- [x] `IsUtc` is documented as testing the normalized value's `Kind`, independent of its origin. XML remarks and the README separately explain that the default JSON converter requires trailing `Z` because `+00:00` produces `Local` and is rejected.
- [x] Tests also cover both overload forms, short-circuit propagation, template customization, and generated OpenAPI metadata. Solution coverage remains above 95%.
- [x] The README shows the assertions in request validation, and the 0.7.0 `<PackageReleaseNotes>` of both affected packages describe their respective changes.

## Technical Details

### Semantics and normalization

Each predicate compares `check.Value.Kind` with its corresponding `DateTimeKind`. The contract is only the kind of the normalized value: `DateTime.UtcNow`, `DateTime.SpecifyKind`, custom converters, and non-JSON transports may all produce `Utc`. Do not add a standalone `DateTimeExtensions` predicate; direct `Kind` comparison is already the clearest outside a check chain and is available on `netstandard2.0`.

Measured on .NET 10 with the default `System.Text.Json` converter. Only the kind is host-independent; the two `Local` values themselves depend on the server's zone:

| Wire value | `DateTime.Kind` | Accepted by |
| --- | --- | --- |
| `2026-08-02T10:00:00Z` | `Utc` | `IsUtc` |
| `2026-08-02T10:00:00+00:00` | `Local` | `IsLocal` |
| `2026-08-02T10:00:00+02:00` | `Local` | `IsLocal` |
| `2026-08-02T10:00:00` | `Unspecified` | `IsUnspecified` |

Although `+00:00` and `Z` denote the same instant, the default converter maps them to different kinds. Present the trailing-`Z` requirement as JSON-specific guidance in XML remarks, the README, and the OpenAPI description—not as the assertion's transport-independent contract.

`ValidationContext.Check<T>` applies its per-check normalizer or `Options.ValueNormalizer` before creating `Check<T>`. The default `TrimStringNormalizer` preserves non-strings, but a custom normalizer may rewrite a `DateTime`; the assertions then describe that normalized value. Document this and warn in the README that coercing `Unspecified` to `Utc` destroys the signal. Do not guard against that caller choice in code.

`IsLocal` completes the enum partition despite its limited value in portable APIs, where server-relative time is rarely desirable.

### Errors and registration

Use three metadata-free rules rather than `HasKind(DateTimeKind)` with `expectedKind` metadata, consistent with the existing named empty/null rules:

```csharp
public const string Utc = "Utc";
public const string Local = "Local";
public const string Unspecified = "Unspecified";
```

The terse names follow existing codes such as `Empty`, `Null`, and `Email`; the error target disambiguates `Unspecified` for consumers.

Default templates are `new DisplayName(" must be represented in UTC")`, `new DisplayName(" must be a local date and time")`, and `new DisplayName(" must not specify a time zone")`. The UTC message describes representation without claiming a JSON origin; JSON-specific `Z` guidance belongs in contextual documentation. Two rejected alternatives, recorded so they are not reintroduced: `"must be in UTC"` describes the instant and so reads as already satisfied on a `+00:00` payload, and `"must be encoded with a trailing 'Z'"` is false for a value that never arrived as JSON. `IsUnspecified` likewise avoids `"must have an unspecified kind"`, which names a CLR concept the client cannot see.

Follow the `UuidV7` registration shape:

- Add the constants; three definitions with `TryGetStableMessageProvider`, which applies here because these messages have no per-error parameters and are therefore cacheable; three shared `BuiltInValidationErrorDefinitions` properties; and three template defaults/properties.
- Copy all three properties in `ValidationErrorTemplates`' copy constructor so customizations survive subsequent `with` expressions.
- Register all three as `ErrorMetadataContract.NoMetadata` and extend `BuiltInValidationErrorContractsTests`' exhaustive no-metadata list. This requires a generated-document test because a missing registry entry fails during document construction, not through a generator diagnostic.
- Put assertions and definitions in `Checks.Temporal.cs` and `Definitions/BuiltInValidationErrorDefinitions.Temporal.cs`.
- Annotate each built-in-message overload with `[ValidationRule(...)]` and `[ValidationRuleMessage(...)]`; the default `ValidationRuleMetadataShape.Registered` is correct. No generator change is needed, but omitted method-level attributes make a rule silently undiscoverable.

### Tests and documentation

The 3×3 matrix is the central assertion test and must reject both wrong kinds for every rule, killing equality mutations. The wire-format test guards the JSON premise: assert kind and validation outcome, not the time-zone-dependent wall-clock value. It overlaps the matrix by design and must not be folded into it — it is a regression detector for third-party behavior, so if a future `System.Text.Json` release changes the mapping, the assertions keep working while their documented meaning shifts, and this test is what surfaces that.

Retain named cases for `DateTime.UtcNow`, `DateTime.Now`, and `default(DateTime)`. `UtcNow` proves that `IsUtc` tests kind rather than JSON origin; `default(DateTime)` documents the `Unspecified` default-value trap. Keep remaining assertion tests focused on both overloads, short-circuit behavior, and customization through `ValidationErrorTemplates.Default with { … }`.

The Validation package release notes cover the three assertions, codes, and templates. Validation.OpenApi covers the three new registry entries.

### Deliberately out of scope

- **`Check<DateTimeOffset>` assertions.** `Offset == TimeSpan.Zero` is a canonicalization rule, not a kind check. The same `+00:00` JSON value fails `DateTime.IsUtc` but would pass this rule, while both CLR types map to OpenAPI `string`/`date-time`; an unzoned `DateTimeOffset` also inherits the server offset and becomes host-dependent. Any future rule needs its own `ZeroOffset` code/message and design, tracked in #74.
- **`IsInThePast` / `IsInTheFuture`.** Tracked in #74; they need a clock abstraction, tolerance, and a resolved `TimeProvider` strategy for `netstandard2.0`.
- **A UTC-converting normalizer.** Coercion would hide the condition being validated and belongs in mapping.
- **`Check<DateTime?>`.** No built-in assertion has nullable value-type overloads; use `IsNotNull` first.
- **`DateOnly`, `TimeOnly`, and `TimeSpan`.** They carry neither kind nor offset.
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,10 @@ private static FrozenDictionary<string, ErrorMetadataContract> CreateContracts()
[ValidationErrorCodes.Email] = ErrorMetadataContract.NoMetadata,
[ValidationErrorCodes.DigitsOnly] = ErrorMetadataContract.NoMetadata,
[ValidationErrorCodes.LettersAndDigitsOnly] = ErrorMetadataContract.NoMetadata,
[ValidationErrorCodes.UuidV7] = ErrorMetadataContract.NoMetadata
[ValidationErrorCodes.UuidV7] = ErrorMetadataContract.NoMetadata,
[ValidationErrorCodes.Utc] = ErrorMetadataContract.NoMetadata,
[ValidationErrorCodes.Local] = ErrorMetadataContract.NoMetadata,
[ValidationErrorCodes.Unspecified] = ErrorMetadataContract.NoMetadata
}.ToFrozenDictionary(StringComparer.Ordinal);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- 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.
- Registers built-in metadata-free contracts for the new Utc, Local, and Unspecified validation error codes.
</PackageReleaseNotes>
</PropertyGroup>

Expand Down
Loading
Loading