diff --git a/.gitignore b/.gitignore
index 91d35d7..a7eef41 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,4 +8,5 @@ obj/
*.cobertura.xml
*.received.*
.DS_Store
-*.lscache
\ No newline at end of file
+*.lscache
+.codex
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 87635af..91e322f 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -9,6 +9,7 @@
+
@@ -17,7 +18,9 @@
+
+
@@ -27,4 +30,4 @@
-
\ No newline at end of file
+
diff --git a/Light.PortableResults.slnx b/Light.PortableResults.slnx
index ea8edb3..0a9bf74 100644
--- a/Light.PortableResults.slnx
+++ b/Light.PortableResults.slnx
@@ -49,6 +49,13 @@
+
+
+
+
+
+
+
@@ -75,12 +82,16 @@
+
+
+
+
diff --git a/README.md b/README.md
index 6bbd182..18a69e4 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,18 @@ ASP.NET Core MVC integration with support for Dependency Injection and `IActionR
dotnet add package Light.PortableResults.AspNetCore.Mvc
```
+OpenAPI integration:
+
+```bash
+dotnet add package Light.PortableResults.AspNetCore.OpenApi
+```
+
+Built-in validation error contracts for OpenAPI:
+
+```bash
+dotnet add package Light.PortableResults.Validation.OpenApi
+```
+
If you only need the Result Pattern itself, `Light.PortableResults` is the most lightweight dependency.
## 🤓 Basic Usage
@@ -359,7 +371,7 @@ Content-Type: application/problem+json
"errors": [
{
"message": "comment must be between 10 and 1000 characters long",
- "code": "LengthIn",
+ "code": "LengthInRange",
"target": "comment",
"category": "Validation",
"metadata": {
@@ -375,7 +387,7 @@ Content-Type: application/problem+json
},
{
"message": "rating must be between 1 and 5",
- "code": "IsInBetween",
+ "code": "InRange",
"target": "rating",
"category": "Validation",
"metadata": {
@@ -1322,6 +1334,186 @@ services
`ValidateWithPortableResults` integrates with the standard options validation pipeline, supports named options, and forwards the current options name to the `ValidationContext`. Use `ValidationContext.TryGetItem(ConfigurationConstants.OptionsNameKey, out var optionsName);` to access the options name in your validator.
+## OpenAPI Support
+
+OpenAPI support lives in the dedicated `Light.PortableResults.AspNetCore.OpenApi` package. It is opt-in and does not change runtime serialization. `LightResult` / `LightActionResult` still serialize through the JSON writers in `Light.PortableResults`; the OpenAPI package only contributes endpoint metadata plus a document transformer. If you use `Light.PortableResults.Validation`, add `Light.PortableResults.Validation.OpenApi` to opt into the library-owned built-in validation error contracts.
+
+### Registration
+
+```csharp
+using Light.PortableResults.AspNetCore.MinimalApis;
+using Light.PortableResults.AspNetCore.OpenApi;
+using Light.PortableResults.Validation.OpenApi;
+
+builder.Services
+ .AddOpenApi()
+ .AddPortableResultsForMinimalApis()
+ .AddPortableResultsOpenApi(contracts => contracts.RegisterBuiltInValidationErrors());
+```
+
+Use `AddPortableResultsForMvc()` instead of `AddPortableResultsForMinimalApis()` for MVC applications. OpenAPI support is intentionally separate so applications that never generate OpenAPI documents do not take on the extra dependency.
+
+### Public surface
+
+Minimal APIs expose three helpers in `Light.PortableResults.AspNetCore.OpenApi`:
+
+- `ProducesPortableSuccessResponse(...)`
+- `ProducesPortableProblem(...)`
+- `ProducesPortableValidationProblem(...)`
+
+MVC exposes three matching attributes:
+
+- `[ProducesPortableSuccessResponse]`
+- `[ProducesPortableProblem]`
+- `[ProducesPortableValidationProblem]`
+
+`ProducesPortableSuccessResponse` documents both runtime success shapes:
+
+- Under `MetadataSerializationMode.ErrorsOnly`, the documented body is the bare `TValue`.
+- Under `MetadataSerializationMode.Always`, the documented body is `{ value, metadata }`.
+
+Use `UseMetadataSerializationMode(...)` on Minimal APIs or the `MetadataSerializationMode = ...` named argument on the MVC attribute when the endpoint’s documented shape differs from the DI default.
+
+`ProducesPortableValidationProblem(...)` automatically selects the rich or ASP.NET Core-compatible validation envelope from `PortableResultsHttpWriteOptions.ValidationProblemSerializationFormat`. Use `UseFormat(...)` on Minimal APIs or `Format = ...` on the MVC attribute for a per-endpoint override.
+
+PortableResults OpenAPI metadata is authoritative for a given `(status code, content type)` response slot. If another OpenAPI contributor already documented the same slot, the document transformer replaces that media-type schema instead of merging it. Avoid combining `ProducesPortable...` helpers or attributes with ASP.NET Core response-schema helpers for the same response slot unless you want PortableResults to win.
+
+### Documenting metadata
+
+Top-level metadata and per-error-code metadata are caller-owned contracts. The OpenAPI package documents them explicitly; the runtime still writes `MetadataObject`.
+
+`WithErrorCodes(...)`, endpoint-scoped `WithErrorMetadata(code)`, and the typed validation helpers such as `WithInRangeError()` narrow error items exhaustively by default once you document at least one code. The generated item schema becomes a discriminated `oneOf` over the documented variants with `code` required, so you are asserting that every emitted error item has a non-null `code` and that the code is in the documented set.
+
+If an endpoint can still emit additional codes outside the documented set, opt out explicitly with `AllowUnknownErrorCodes()` on the Minimal API builders or `AllowUnknownErrorCodes = true` on the MVC attributes. In that mode the generated schema falls back to the non-exhaustive `anyOf` shape with the canonical `PortableError` / `PortableValidationErrorDetail` branch preserved for unknown codes.
+
+`AllowUnknownErrorCodes()` does not relax the `code` requirement on narrowed item schemas. If an endpoint can emit code-less errors, do not narrow that endpoint's error items in the first place; use the canonical envelope schema instead.
+
+When top-level metadata or documented error items are narrowed, the generated response envelope is a flattened concrete object schema that copies the canonical problem-details properties and overrides only `errors` / `errorDetails` / `metadata`. This improves Swagger UI and code-generator output without changing the runtime wire format.
+
+For built-in validation errors, reference `Light.PortableResults.Validation.OpenApi` and pass the catalog registration to `AddPortableResultsOpenApi(...)` once:
+
+```csharp
+using Light.PortableResults.AspNetCore.OpenApi;
+using Light.PortableResults.Validation.OpenApi;
+
+builder.Services.AddPortableResultsOpenApi(
+ contracts => contracts.RegisterBuiltInValidationErrors()
+);
+```
+
+Use `ValidationErrorCodes` when opting endpoints into built-in codes. Codes such as `NotEmpty`, `LengthInRange`, and `Count` reuse global schemas from the built-in catalog:
+
+```csharp
+using Light.PortableResults.Validation;
+using Light.PortableResults.Validation.OpenApi;
+
+app.MapPut("/api/movieRatings", AddMovieRating)
+ .ProducesPortableValidationProblem(
+ configure: x =>
+ x.UseFormat(ValidationProblemSerializationFormat.Rich)
+ .WithErrorCodes(ValidationErrorCodes.NotEmpty, ValidationErrorCodes.LengthInRange)
+ .WithInRangeError()
+ );
+```
+
+Use `AllowUnknownErrorCodes()` when the endpoint may emit additional documented-shape errors outside the documented code set, for example when built-in validation codes are documented but a downstream lookup may still add a custom code:
+
+```csharp
+app.MapGet("/api/movies", GetMovies)
+ .ProducesPortableValidationProblem(
+ configure: x =>
+ x.UseFormat(ValidationProblemSerializationFormat.Rich)
+ .WithErrorCodes(ValidationErrorCodes.NotEmpty)
+ .WithInRangeError()
+ .AllowUnknownErrorCodes()
+ );
+```
+
+Comparison and range codes are polymorphic at the global code level, so the validation bridge also ships typed endpoint helpers: `WithEqualToError()`, `WithNotEqualToError()`, `WithGreaterThanError()`, `WithGreaterThanOrEqualToError()`, `WithLessThanError()`, `WithLessThanOrEqualToError()`, `WithInRangeError()`, `WithNotInRangeError()`, and `WithExclusiveRangeError()`. These helpers use the existing inline metadata path, so an endpoint can document concrete metadata such as integer range boundaries for `IsInBetween(1, 5)` while still reusing global schemas for shape-fixed codes.
+
+Register reusable per-error-code metadata contracts once in DI by passing them to `AddPortableResultsOpenApi(...)`:
+
+```csharp
+using Light.PortableResults.AspNetCore.OpenApi;
+
+builder.Services.AddPortableResultsOpenApi(contracts =>
+{
+ contracts.ForCode("VersionMismatch");
+ contracts.ForCode("InsufficientFunds");
+});
+```
+
+User-defined codes continue to use the type-based overloads above, or endpoint-scoped `WithErrorMetadata(code)` when a contract only applies to one operation.
+
+Then opt the relevant codes into each endpoint:
+
+```csharp
+using Light.PortableResults;
+using Light.PortableResults.AspNetCore.MinimalApis;
+using Light.PortableResults.AspNetCore.OpenApi;
+using Light.PortableResults.Http.Writing;
+
+app.MapPut("/api/movieRatings", async (MovieRatingDto dto, AddMovieRatingService service) =>
+ {
+ var result = await service.AddMovieRatingAsync(dto);
+ return result.ToMinimalApiResult();
+ })
+ .ProducesPortableSuccessResponse(
+ configure: x =>
+ x.WithMetadata()
+ .UseMetadataSerializationMode(MetadataSerializationMode.Always)
+ )
+ .ProducesPortableValidationProblem(
+ configure: x =>
+ x.UseFormat(ValidationProblemSerializationFormat.Rich)
+ .WithErrorCodes("VersionMismatch")
+ )
+ .ProducesPortableProblem(
+ statusCode: StatusCodes.Status404NotFound,
+ configure: x =>
+ x.WithMetadata()
+ .WithErrorMetadata("MovieNotFound")
+ )
+ .ProducesPortableProblem();
+```
+
+The MVC equivalent uses named attribute arguments:
+
+```csharp
+using Light.PortableResults;
+using Light.PortableResults.AspNetCore.Mvc;
+using Light.PortableResults.AspNetCore.OpenApi;
+using Light.PortableResults.Http.Writing;
+using Microsoft.AspNetCore.Mvc;
+
+[ApiController]
+[Route("api/movieRatings")]
+public sealed class AddMovieRatingsController(AddMovieRatingService service) : ControllerBase
+{
+ [HttpPut]
+ [ProducesPortableSuccessResponse(
+ TopLevelMetadataType = typeof(MovieRatingResponseMetadata),
+ MetadataSerializationMode = MetadataSerializationMode.Always
+ )]
+ [ProducesPortableValidationProblem(
+ Format = ValidationProblemSerializationFormat.Rich,
+ ErrorCodes = new[] { "VersionMismatch" }
+ )]
+ [ProducesPortableProblem(
+ statusCode: StatusCodes.Status404NotFound,
+ TopLevelMetadataType = typeof(MovieProblemMetadata),
+ InlineErrorMetadataCodes = new[] { "MovieNotFound" },
+ InlineErrorMetadataContracts = new[] { ErrorMetadataContract.FromType(typeof(MovieNotFoundMetadata)) }
+ )]
+ [ProducesPortableProblem]
+ public async Task> AddMovieRating(AddMovieRatingDto dto)
+ {
+ var result = await service.AddMovieRatingAsync(dto);
+ return result.ToMvcActionResult();
+ }
+}
+```
+
## ⚙️ Configuration for HTTP and CloudEvents
### HTTP write options (`PortableResultsHttpWriteOptions`)
diff --git a/ai-plans/0040-0-openapi-support.md b/ai-plans/0040-0-openapi-support.md
new file mode 100644
index 0000000..8754f33
--- /dev/null
+++ b/ai-plans/0040-0-openapi-support.md
@@ -0,0 +1,156 @@
+# OpenAPI Support for Portable Problem Details
+
+## Rationale
+
+Light.PortableResults can already serialize failure responses as RFC 9457 Problem Details over HTTP, but there is no corresponding way to document those failure shapes for OpenAPI. Callers building Minimal APIs or MVC endpoints on top of `ToMinimalApiResult` and `ToMvcActionResult` have no library-provided helpers to tell OpenAPI generators which problem schema an endpoint produces for validation errors or for other failure codes such as 401, 404, or 409.
+
+This plan closes that gap by adding schema-only CLR types and explicit endpoint metadata helpers for failure responses. The implementation stays intentionally static: OpenAPI generators need only a CLR type to infer a schema, while the runtime HTTP writers continue to serialize failures dynamically from `Errors`, `ProblemDetailsInfo`, and `PortableResultsHttpWriteOptions`. No automatic OpenAPI inference, runtime schema transformers, or dedicated OpenAPI integration package is introduced.
+
+The plan also revisits the success-side OpenAPI naming and usage model. A successful `Result` has only two meaningful body contracts: either the body is just `TValue`, or the body is `{ value, metadata }`. If the body is just `TValue`, callers should use the normal ASP.NET Core OpenAPI metadata helpers such as `Produces(...)` or `ProducesResponseTypeAttribute`. If the body includes `metadata`, then the metadata type is part of the contract and must be explicit. Because of that, the Light.PortableResults-specific success-side OpenAPI surface should only exist for the wrapped `{ value, metadata }` case and must always require `TMetadata`.
+
+Introducing the failure-side types also creates an opportunity to establish a consistent naming scheme across the whole OpenAPI surface. The existing success-side types (`WrappedResponse`, `ProducesPortableResult(...)`, `ProducesPortableResultAttribute<...>`) carry generic names that do not clearly identify them as schema-only OpenAPI helpers. This plan renames them to `PortableSuccessResponse`, `ProducesPortableSuccessResponse`, and `ProducesPortableSuccessResponseAttribute`. Since the library is still pre-1.0, the breaking rename is acceptable now.
+
+## Acceptance Criteria
+
+- [x] `WrappedResponse` is renamed to `PortableSuccessResponse`, the existing success-side helpers are renamed to `ProducesPortableSuccessResponse` and `ProducesPortableSuccessResponseAttribute`, and the single-generic success-side helpers are removed. After this change, `WrappedResponse`, `ProducesPortableResult`, `ProducesPortableResultAttribute`, and any other success-side `object`-based OpenAPI convenience surface no longer exist anywhere in the codebase.
+- [x] The success-side OpenAPI model is explicit: callers who serialize only `TValue` in successful responses use the standard ASP.NET Core OpenAPI metadata APIs, while callers who serialize `{ value, metadata }` use the Light.PortableResults-specific success helper with an explicit `TMetadata`.
+- [x] `Light.PortableResults.AspNetCore.Shared` contains the exact schema-only OpenAPI types defined in this plan: `PortableSuccessResponse`, `PortableError`, `PortableError`, `PortableValidationErrorDetail`, `PortableValidationErrorDetail`, `PortableProblemDetails`, `PortableProblemDetails`, `PortableRichValidationProblemDetails`, `PortableRichValidationProblemDetails`, `PortableAspNetCoreValidationProblemDetails`, and `PortableAspNetCoreValidationProblemDetails`.
+- [x] `Light.PortableResults.AspNetCore.MinimalApis` contains the exact `RouteHandlerBuilder` extension method overloads defined in this plan, including their status-code defaults and content-type defaults. There are no one-generic middle-tier overloads for success or failure responses.
+- [x] `Light.PortableResults.AspNetCore.Mvc` contains the exact response metadata attributes defined in this plan, including their status-code defaults and content-type defaults. There are no one-generic middle-tier attributes for success or failure responses.
+- [x] The generic parameter meanings are fixed as follows throughout the public API: `TMetadata` on `PortableSuccessResponse` is success-body metadata, `TErrorMetadata` is metadata on each rich error item, `TErrorDetailMetadata` is metadata on each ASP.NET Core-compatible `errorDetails` item, and `TProblemMetadata` is top-level problem metadata.
+- [x] Rich problem helpers and ASP.NET Core-compatible validation problem helpers are separate public APIs so callers must choose the schema that matches their configured HTTP serialization format.
+- [x] The implementation does not change the runtime HTTP serialization behavior of `LightResult`, `LightResult`, `LightActionResult`, `LightActionResult`, or the JSON writers in `Light.PortableResults`.
+- [x] Automated tests are written for the renamed success helpers and for all new Minimal API and MVC OpenAPI metadata helpers. The MVC test project gains a new unit test class for attribute metadata.
+- [x] `README.md` documents the breaking rename, the new OpenAPI support for error responses, and the success-side rule that plain `TValue` success responses use standard ASP.NET Core OpenAPI helpers while `{ value, metadata }` success responses use `ProducesPortableSuccessResponse` or `ProducesPortableSuccessResponseAttribute`.
+
+## Technical Details
+
+### Public Schema Types
+
+Add the following schema-only types to `Light.PortableResults.AspNetCore.Shared`. These types exist only for OpenAPI schema generation and are not used by the runtime HTTP writing pipeline.
+
+All schema-only model types in this plan should be public and not sealed. They are OpenAPI contract types, are not instantiated by the library at runtime, and may be useful to callers as reusable contract models.
+
+The failure-side non-generic and two-generic variants form a two-tier hierarchy. Convenience non-generic subtypes inherit from the two-generic base with `object` substituted for both type parameters. The two-generic failure-side base types must therefore not be sealed. `PortableSuccessResponse` does not need a non-generic or single-generic subtype because the success-side OpenAPI surface should only be used when the metadata type is explicit.
+
+`PortableSuccessResponse` is only used to document wrapped success bodies that contain both `value` and `metadata`. Plain `TValue` success bodies are documented with the standard ASP.NET Core OpenAPI metadata APIs.
+
+**Success response:**
+- `PortableSuccessResponse` with the properties `TValue Value` and `TMetadata Metadata`.
+
+**Error items:**
+- `PortableError` with the properties `string Message`, `string? Code`, `string? Target`, `ErrorCategory Category`, and `object? Metadata`.
+- `PortableError` with the properties `string Message`, `string? Code`, `string? Target`, `ErrorCategory Category`, and `TMetadata Metadata`.
+- `PortableValidationErrorDetail` with the properties `string Target`, `int Index`, `string? Code`, `ErrorCategory? Category`, and `object? Metadata`. The `Index` property is the zero-based position of the corresponding error message within the `errors[target]` array for the same target.
+- `PortableValidationErrorDetail` with the properties `string Target`, `int Index`, `string? Code`, `ErrorCategory? Category`, and `TMetadata Metadata`.
+
+**Problem details (generic + non-generic):**
+- `PortableProblemDetails` deriving from `ProblemDetails` with the properties `IReadOnlyList> Errors` and `TProblemMetadata Metadata`.
+- `PortableProblemDetails` inheriting from `PortableProblemDetails`.
+- `PortableRichValidationProblemDetails` deriving from `ProblemDetails` with the properties `IReadOnlyList> Errors` and `TProblemMetadata Metadata`.
+- `PortableRichValidationProblemDetails` inheriting from `PortableRichValidationProblemDetails`.
+- `PortableAspNetCoreValidationProblemDetails` deriving from `HttpValidationProblemDetails` with the properties `IReadOnlyList>? ErrorDetails` and `TProblemMetadata Metadata`.
+- `PortableAspNetCoreValidationProblemDetails` inheriting from `PortableAspNetCoreValidationProblemDetails`.
+
+`PortableRichValidationProblemDetails` is structurally identical to `PortableProblemDetails`, but the separation is intentional: keeping them as distinct CLR types causes OpenAPI generators to emit different schema definitions with meaningful names for general errors and validation errors. Do not collapse the two families into one type.
+
+The success-response rename should be applied consistently across the code base, tests, XML documentation, and README.
+
+### Minimal API Helpers
+
+Rename the existing success helper in `PortableResultsEndpointExtensions` to the following exact signature:
+
+- `public static RouteHandlerBuilder ProducesPortableSuccessResponse(this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status200OK, string contentType = "application/json")`
+
+Remove the single-generic success helper. Callers who do not serialize metadata in the success response body should use the standard ASP.NET Core OpenAPI metadata APIs such as `Produces(...)`. Callers who do serialize metadata in the body should use `ProducesPortableSuccessResponse(...)`.
+
+Add the following exact failure-response helper signatures to `PortableResultsEndpointExtensions`:
+
+- `public static RouteHandlerBuilder ProducesPortableProblem(this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status500InternalServerError, string contentType = "application/problem+json")`
+- `public static RouteHandlerBuilder ProducesPortableProblem(this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status500InternalServerError, string contentType = "application/problem+json")`
+- `public static RouteHandlerBuilder ProducesPortableRichValidationProblem(this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status400BadRequest, string contentType = "application/problem+json")`
+- `public static RouteHandlerBuilder ProducesPortableRichValidationProblem(this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status400BadRequest, string contentType = "application/problem+json")`
+- `public static RouteHandlerBuilder ProducesPortableAspNetCoreValidationProblem(this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status400BadRequest, string contentType = "application/problem+json")`
+- `public static RouteHandlerBuilder ProducesPortableAspNetCoreValidationProblem(this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status400BadRequest, string contentType = "application/problem+json")`
+
+The non-generic overloads point to `PortableProblemDetails`, `PortableRichValidationProblemDetails`, and `PortableAspNetCoreValidationProblemDetails`. The two-generic overloads point to the corresponding `` or `` types. There are no one-generic middle-tier overloads; callers who want only typed problem metadata use the two-generic overload with `object` as the first type argument.
+
+The validation helpers default to `400 Bad Request` because that is the default validation response in the library today, but the `statusCode` parameter must also allow callers to document `422 Unprocessable Content` when they intentionally expose that contract.
+
+`ProducesPortableProblem` is also the correct helper for non-validation 4xx responses such as `401 Unauthorized`, `403 Forbidden`, and `404 Not Found`; callers simply pass the relevant status code. There are no dedicated per-status-code helpers for these cases.
+
+Keep the API explicit rather than accepting `ValidationProblemSerializationFormat` as a method parameter. OpenAPI must point to one concrete schema, and explicit helper names make it much harder for callers to document the wrong shape.
+
+### MVC Attributes
+
+Rename the existing success attribute to the following exact name and type shape:
+
+- `public sealed class ProducesPortableSuccessResponseAttribute : ProducesResponseTypeAttribute>`
+
+The success attribute should keep the constructor signature `public ... (int statusCode = StatusCodes.Status200OK, string contentType = "application/json")`.
+
+Remove the single-generic success attribute. Callers who do not serialize metadata in the success response body should use the standard ASP.NET Core response metadata attributes such as `ProducesResponseTypeAttribute`. Callers who do serialize metadata in the body should use `ProducesPortableSuccessResponseAttribute`.
+
+Add the following exact failure-response attribute families:
+
+- `ProducesPortableProblemAttribute`
+- `ProducesPortableProblemAttribute`
+- `ProducesPortableRichValidationProblemAttribute`
+- `ProducesPortableRichValidationProblemAttribute`
+- `ProducesPortableAspNetCoreValidationProblemAttribute`
+- `ProducesPortableAspNetCoreValidationProblemAttribute`
+
+Each attribute should derive from `ProducesResponseTypeAttribute` with the corresponding schema-only type from `Light.PortableResults.AspNetCore.Shared`. There are no one-generic middle-tier attributes.
+
+The exact constructor signatures should be:
+
+- problem attributes: `public ... (int statusCode = StatusCodes.Status500InternalServerError, string contentType = "application/problem+json")`
+- rich validation attributes: `public ... (int statusCode = StatusCodes.Status400BadRequest, string contentType = "application/problem+json")`
+- ASP.NET Core-compatible validation attributes: `public ... (int statusCode = StatusCodes.Status400BadRequest, string contentType = "application/problem+json")`
+
+As with the Minimal API helpers, the validation attributes must allow callers to override the status code to `422` when needed.
+
+### Scope Boundaries
+
+This feature should remain documentation-only:
+
+- do not change the runtime JSON serialization code in `Light.PortableResults`
+- do not modify `LightResult` or `LightActionResult` to infer response metadata automatically
+- do not introduce `Microsoft.AspNetCore.OpenApi` transformers or a separate OpenAPI integration package
+- do not attempt to infer validation schema shape from `PortableResultsHttpWriteOptions`
+
+Instead, the caller explicitly chooses the documented schema that matches the endpoint contract and the configured `ValidationProblemSerializationFormat`.
+
+### Automated Tests
+
+Add unit tests in the Minimal API and MVC test projects that verify the exact renamed and newly added helper surfaces from this plan.
+
+The MinimalApis test project already has `PortableResultsEndpointExtensionsTests` as a template. The MVC test project currently has no unit test class for attribute metadata; add one following the same pattern.
+
+The test coverage should include:
+
+- the renamed success-side Minimal API helper registers `PortableSuccessResponse` metadata entries
+- the renamed success-side MVC attribute points to `PortableSuccessResponse`
+- `ProducesPortableProblem...` and `ProducesPortableProblemAttribute...` register the correct `PortableProblemDetails...` schema types
+- `ProducesPortableRichValidationProblem...` and `ProducesPortableRichValidationProblemAttribute...` register the correct `PortableRichValidationProblemDetails...` schema types
+- `ProducesPortableAspNetCoreValidationProblem...` and `ProducesPortableAspNetCoreValidationProblemAttribute...` register the correct `PortableAspNetCoreValidationProblemDetails...` schema types
+- non-generic and two-generic overloads both point to the expected schema-only CLR types
+- the removed single-generic success-side helpers are no longer present
+- default content types and default status codes match the signatures defined in this plan
+
+### README Documentation
+
+Extend `README.md` in the HTTP / ASP.NET Core section rather than creating a separate documentation chapter.
+
+The README changes should include:
+
+- a note about the breaking rename: `WrappedResponse` becomes `PortableSuccessResponse`, `ProducesPortableResult` becomes `ProducesPortableSuccessResponse`, and `ProducesPortableResultAttribute` becomes `ProducesPortableSuccessResponseAttribute`
+- a short explanation of the success-side rule: use standard ASP.NET Core OpenAPI helpers when the success body is just `TValue`, and use `ProducesPortableSuccessResponse` or `ProducesPortableSuccessResponseAttribute` only when the success body is `{ value, metadata }`
+- two Minimal APIs examples that each combine `ProducesPortableSuccessResponse(...)` with `ProducesPortableProblem(...)` and a validation helper; one example should use `ProducesPortableRichValidationProblem(...)` and the other should use `ProducesPortableAspNetCoreValidationProblem(...)`
+- an MVC example that combines `ProducesPortableSuccessResponseAttribute` with one problem attribute and one validation attribute
+- a concise explanation of the two validation problem formats: `AspNetCoreCompatible` documents `errors` as `Dictionary` plus an optional `errorDetails` array, while `Rich` documents `errors` as an array of Light.PortableResults-style error objects
+- an explanation of the `Index` property on `PortableValidationErrorDetail`: it is the zero-based position of the corresponding error message within the `errors[target]` array for the same target, allowing `errorDetails` entries to be correlated back to the matching message
+- a note that callers must choose the OpenAPI helper that matches the actual configured `ValidationProblemSerializationFormat`
+- a note that the typed metadata CLR types are schema-only helpers for OpenAPI; the runtime still serializes `MetadataObject`, so callers are responsible for keeping the documented schema aligned with the metadata they actually produce
+
+Keep the README focused on practical endpoint examples and avoid going deep into internal implementation details.
diff --git a/ai-plans/0040-1-openapi-redesign.md b/ai-plans/0040-1-openapi-redesign.md
new file mode 100644
index 0000000..8ca5ca5
--- /dev/null
+++ b/ai-plans/0040-1-openapi-redesign.md
@@ -0,0 +1,248 @@
+# OpenAPI Support Redesign
+
+## Rationale
+
+Plan `0040-0-openapi-support.md` added OpenAPI support through schema-only CLR surrogate types (`PortableError`, `PortableProblemDetails`, and so on). The generic type parameters pollute the emitted OpenAPI schema names, which forced a workaround (`PortableResultsOpenApiNamingConventions.TryCreateSchemaReferenceId`) and a parallel `` alias hierarchy purely for naming. The workaround only handles the `` case; strongly typed metadata still produces names such as `PortableProblemDetailsOfMyErrorMetaAndMyProblemMeta`. The non-generic `PortableError` and `PortableValidationErrorDetail` classes are not reachable from any helper and duplicate the generic surface without adding value. Finally, the typed metadata generics promise a schema shape the runtime cannot honor: the runtime always serializes `MetadataObject` via `Utf8JsonWriter.WriteMetadataObject` (see `src/Light.PortableResults/SharedJsonSerialization/Writing/MetadataExtensions.cs`), not the caller's CLR type.
+
+This redesign replaces the CLR-surrogate approach with a library-authored OpenAPI schema catalog and an `IOpenApiDocumentTransformer`. The library owns the envelope schemas directly (five canonical envelope components plus a shared `ErrorCategory` enum component) and injects them into the `OpenApiDocument`. Endpoint helpers and MVC attributes become thin markers that the transformer reads to emit operation responses. Per-error-code metadata contracts are registered once in DI and opted into per endpoint, with an inline escape hatch.
+
+The entire OpenAPI-facing surface ships in a new dedicated package `Light.PortableResults.AspNetCore.OpenApi`. It depends on `Microsoft.AspNetCore.OpenApi` (which is not part of the `Microsoft.AspNetCore.App` shared framework and therefore must be referenced as a NuGet package) and project-references `Light.PortableResults.AspNetCore.Shared`. The runtime packages `Light.PortableResults.AspNetCore.MinimalApis` and `Light.PortableResults.AspNetCore.Mvc` do **not** take on a dependency on `Microsoft.AspNetCore.OpenApi` — consumers who want OpenAPI support opt in by also referencing the new package, so applications that never touch OpenAPI do not pay the transitive cost.
+
+The redesign explicitly targets `Microsoft.AspNetCore.OpenApi` only. Swashbuckle / NSwag interop is a non-goal.
+
+This plan supersedes the OpenAPI portions of `0040-0-openapi-support.md`. The breaking rename of `WrappedResponse` to `PortableSuccessResponse<...>` that plan already landed is not reverted; the type is simply removed along with the rest of the schema-only surface. This is intentionally a breaking change to the OpenAPI-facing public surface of the ASP.NET Core packages; the root `AGENTS.md` explicitly permits breaking changes while the library is pre-stable.
+
+## Acceptance Criteria
+
+- [x] All schema-only CLR types introduced by `0040-0-openapi-support.md` are deleted: `PortableError`, `PortableError`, `PortableValidationErrorDetail`, `PortableValidationErrorDetail`, `PortableProblemDetails`, `PortableProblemDetails`, `PortableRichValidationProblemDetails`, `PortableRichValidationProblemDetails`, `PortableAspNetCoreValidationProblemDetails`, `PortableAspNetCoreValidationProblemDetails`, `PortableSuccessResponse`.
+- [x] `PortableResultsOpenApiNamingConventions` is deleted together with its tests.
+- [x] All two-generic endpoint helpers on `PortableResultsEndpointExtensions` and all two-generic MVC attributes are deleted. The helper/attribute split between `Rich` and `AspNetCoreCompatible` validation problems is collapsed into a single helper/attribute.
+- [x] The runtime packages `Light.PortableResults.AspNetCore.MinimalApis` and `Light.PortableResults.AspNetCore.Mvc` no longer expose any OpenAPI helper or attribute surface at all. Concretely, the entire `PortableResultsEndpointExtensions` class is deleted from `Light.PortableResults.AspNetCore.MinimalApis` (including every non-generic helper such as `ProducesPortableProblem`, `ProducesPortableRichValidationProblem`, and `ProducesPortableAspNetCoreValidationProblem`, not only the two-generic overloads), and `ProducesPortableSuccessResponseAttribute`, `ProducesPortableProblemAttribute`, `ProducesPortableRichValidationProblemAttribute`, and `ProducesPortableAspNetCoreValidationProblemAttribute` are deleted from `Light.PortableResults.AspNetCore.Mvc`. The replacements live exclusively in the new `Light.PortableResults.AspNetCore.OpenApi` package so there is a single public OpenAPI surface across the solution.
+- [x] A new project `Light.PortableResults.AspNetCore.OpenApi` is added to the solution. It targets .NET 10, sets `true `, project-references `Light.PortableResults.AspNetCore.Shared`, carries a ` `, and takes on the NuGet ` ` at the version already pinned in `Directory.Packages.props`. The runtime packages `Light.PortableResults.AspNetCore.MinimalApis` and `Light.PortableResults.AspNetCore.Mvc` do not gain this package reference.
+- [x] `Light.PortableResults.AspNetCore.OpenApi` contains a library-authored OpenAPI schema catalog class named `PortableResultsOpenApiSchemas` that writes exactly five canonical envelope components into `OpenApiDocument.Components.Schemas` under the exact ids `PortableError`, `PortableValidationErrorDetail`, `PortableProblemDetails`, `PortableRichValidationProblemDetails`, and `PortableAspNetCoreValidationProblemDetails`, plus a supporting `ErrorCategory` enum component (six schema components total). The `metadata`, `errorDetails[*].metadata`, and `errors[*].metadata` slots are declared as open objects (`type: object, additionalProperties: true`) to match what `MetadataExtensions.WriteMetadataObject` actually emits. Success envelopes are not part of the canonical catalog; they are synthesized per operation by the transformer because they only take a stable shape in the context of a specific `TValue`.
+- [x] `Light.PortableResults.AspNetCore.OpenApi` contains an `IOpenApiDocumentTransformer` implementation named `PortableResultsOpenApiDocumentTransformer` that (a) installs the canonical catalog once per document, (b) resolves the effective validation format from `PortableResultsHttpWriteOptions.ValidationProblemSerializationFormat` or the per-endpoint override, and (c) synthesizes any operation-specific derived schemas required by the markers attached to each endpoint.
+- [x] `Light.PortableResults.AspNetCore.OpenApi` exposes a single opt-in entry point `AddPortableResultsOpenApi(this IServiceCollection services)` that registers `PortableResultsOpenApiDocumentTransformer` and its `ConfigureAll` hook idempotently (using `TryAddSingleton` for the transformer plus a private gate service so repeated calls register the configure-options callback exactly once). Consumers who want OpenAPI support call this alongside `AddPortableResultsForMinimalApis` and/or `AddPortableResultsForMvc`. `AddPortableResultsForMinimalApis` and `AddPortableResultsForMvc` do **not** transitively call `AddPortableResultsOpenApi`, so applications that never touch OpenAPI are unaffected. Callers do not need to configure `OpenApiOptions.CreateSchemaReferenceId`.
+- [x] `Light.PortableResults.AspNetCore.OpenApi` exposes exactly the following `RouteHandlerBuilder` extension methods on `PortableResultsOpenApiRouteHandlerBuilderExtensions`, where `TValue` is the only generic on the public helper surface:
+ - `ProducesPortableSuccessResponse(this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status200OK, string contentType = "application/json", Action? configure = null)`
+ - `ProducesPortableProblem(this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status500InternalServerError, string contentType = "application/problem+json", Action? configure = null)`
+ - `ProducesPortableValidationProblem(this RouteHandlerBuilder builder, int statusCode = StatusCodes.Status400BadRequest, string contentType = "application/problem+json", Action? configure = null)`
+- [x] `PortableSuccessResponseOpenApiBuilder` exposes `UseMetadataSerializationMode(MetadataSerializationMode mode)` as a per-endpoint static override, mirroring the existing `UseFormat(ValidationProblemSerializationFormat)` override on `PortableValidationProblemOpenApiBuilder`. The documented schema is selected by the transformer from the resolved mode, so callers can either take the DI default or override it per endpoint for documentation purposes. It remains the caller's responsibility to align any runtime `overrideOptions` passed to `LightResult` / `LightActionResult` with the documented mode.
+- [x] `Light.PortableResults.AspNetCore.OpenApi` exposes exactly three attributes: `ProducesPortableSuccessResponseAttribute`, `ProducesPortableProblemAttribute`, and `ProducesPortableValidationProblemAttribute`. Each is `sealed` and works for both MVC controllers (applied to an action method) and Minimal APIs (the corresponding helper constructs and attaches an instance via `RouteHandlerBuilder.WithMetadata`). They sit in a three-level hierarchy designed so that every public knob is applicable to the type it is declared on (no silent ignores): a public abstract base `PortableOpenApiResponseAttributeBase : Attribute` carries only the truly shared knobs (`Kind`, `StatusCode`, `ContentType`, `TopLevelMetadataType`); a public abstract intermediate `PortableOpenApiErrorResponseAttributeBase : PortableOpenApiResponseAttributeBase` adds the error-list knobs (`ErrorCodes`, `InlineErrorMetadataCodes`, `InlineErrorMetadataTypes`); the three sealed attributes add kind-specific knobs directly — the success attribute adds `ValueType` (set in its constructor from `typeof(TValue)`) and `MetadataSerializationMode`, the problem attribute adds nothing beyond the error base, the validation attribute adds `Format`. Each sealed attribute exposes a constructor accepting `(int statusCode, string contentType)` with defaults matching its Minimal APIs counterpart (`200` / `application/json` for the success attribute, `500` / `application/problem+json` for the problem attribute, `400` / `application/problem+json` for the validation attribute), so call sites read naturally as `[ProducesPortableProblem(404)]` rather than `[ProducesPortableProblem(StatusCode = 404)]`. The base is intentionally not derived from `ProducesResponseTypeAttribute` because the transformer owns schema selection end-to-end; a consequence is that MVC filters and analyzers that enumerate `ProducesResponseTypeAttribute` (for example the default `ApiExplorer` content-negotiation behavior) will not see Light.PortableResults responses, and the document transformer is the single source of truth for these operations. MVC attribute instances enter endpoint metadata through the standard MVC endpoint-routing pipeline (attributes on a controller action are added to `ActionDescriptor.EndpointMetadata` automatically), so the attributes do not need to implement `IEndpointMetadataProvider`.
+- [x] A global error-code metadata registry is exposed through the extension method `ConfigureErrorMetadataContracts(this IServiceCollection services, Action configure)` declared in `Light.PortableResults.AspNetCore.OpenApi`. `PortableErrorMetadataContractsBuilder` exposes `ForCode(string code)` and `ForCode(string code, Type metadataType)` registration methods. The registrations are stored in a singleton service `IPortableErrorMetadataContractRegistry` with an immutable `IReadOnlyDictionary Contracts` property. Registered codes are synthesized into `PortableError__` and `PortableValidationErrorDetail__` schema components once per document (see the sanitization criterion below); endpoints opt into specific codes via `WithErrorCodes(params string[])`. When `WithErrorCodes` references a code that is not present in `IPortableErrorMetadataContractRegistry.Contracts`, the transformer throws `InvalidOperationException` at document generation with a message that names the unregistered code and suggests either registering it through `ConfigureErrorMetadataContracts` or using the inline `WithErrorMetadata` escape hatch. Inline escape hatches `WithErrorMetadata(string code, Type metadataType)` and `WithErrorMetadata(string code)` are available on the problem and validation-problem endpoint builders for codes that are not globally registered.
+- [x] `ConfigureErrorMetadataContracts` is implemented on top of the standard .NET options pipeline: it wraps the caller's `Action` in a `services.Configure(...)` registration (where `PortableErrorMetadataContractsOptions` is a small public options type owning a single `Builder` property), and registers `IPortableErrorMetadataContractRegistry` via `TryAddSingleton` with a factory that materializes the immutable registry from `IOptions.Value.Builder`. This gives additive composition for free: multiple invocations (for example from separate feature modules during composition-root setup) each register another `IConfigureOptions` that runs in registration order against the same lazily-created options instance. Registering the same raw code twice with the same `Type` is an idempotent no-op. Registering the same raw code twice with two different `Type`s throws `InvalidOperationException` with a message naming the raw code and both conflicting types; the throw fires either inside `PortableErrorMetadataContractsBuilder.ForCode` (when the conflict is observable to the builder at configure time) or at registry materialization (when two independent configure callbacks contribute conflicting entries).
+- [x] Per-endpoint metadata narrowing is expressed in the emitted OpenAPI document using `allOf` to extend a canonical envelope and `anyOf + discriminator` on the error `code` property to narrow `errors[*]` (rich format) or `errorDetails[*]` (asp.net-core-compatible format). The transformer emits an explicit `discriminator.mapping` whose keys are the raw code strings as they appear on the wire and whose values are JSON-Pointer-escaped `$ref`s to the synthesized variants (for example `VersionMismatch: '#/components/schemas/PortableError__VersionMismatch'`), because implicit discriminator resolution matches on bare component name and our synthesized component ids are `PortableError__`. A fallback `$ref` to the base `PortableError` / `PortableValidationErrorDetail` schema is always included as the last branch of the `anyOf` so that undocumented codes remain valid; `anyOf` is used instead of `oneOf` because every narrowed variant is an `allOf` restriction of the base schema and would therefore also match the base, which violates `oneOf` semantics.
+- [x] The transformer applies a deterministic sanitization scheme to every error code used in a component id: characters outside `[A-Za-z0-9_]` are replaced with `_`. Collisions after sanitization are rejected at the earliest possible moment: `ConfigureErrorMetadataContracts` throws `InvalidOperationException` at registration time when two distinct globally registered codes sanitize to the same id, and the transformer throws `InvalidOperationException` at document generation time when two distinct inline `WithErrorMetadata` codes on the same `(operation, StatusCode, ContentType)` triple sanitize to the same suffix; both messages name the conflicting raw codes. The discriminator `mapping` keys use the unsanitized raw code (matching what the runtime writes to the `code` property), and the discriminator `mapping` values and operation-level `$ref`s apply JSON Pointer escaping per RFC 6901 (`~` → `~0`, `/` → `~1`) defensively. Sanitization applies identically to `PortableError__`, `PortableValidationErrorDetail__`, and operation-scoped inline variants.
+- [x] The runtime HTTP serialization behavior of `LightResult`, `LightResult`, `LightActionResult`, `LightActionResult`, and the JSON writers in `Light.PortableResults` is unchanged.
+- [x] The transformer resolves the target OpenAPI spec version from a single source of truth: `OpenApiOptions.OpenApiVersion` for the current document, obtained via `context.ApplicationServices.GetRequiredService>().Get(context.DocumentName)`. It emits discriminator narrowing using schema-level `const` when the resolved version is OpenAPI 3.1 or later, and falls back to `enum: []` for OpenAPI 3.0. Generated schemas are spec-valid against both versions.
+- [x] When multiple `PortableOpenApiResponseAttributeBase` instances share the same `(StatusCode, ContentType)` key on the same operation, the transformer treats them as distinct contributing schemas for the same HTTP response and merges them into a single `OpenApiResponse` whose media-type schema is an `anyOf` over the contributing envelopes, so common designs such as documenting both a `ProducesPortableProblemAttribute(400)` and a `ProducesPortableValidationProblemAttribute(400)` on the same endpoint at `application/problem+json` produce one response entry with a unioned schema. The transformer still throws `InvalidOperationException` at document generation time when more than one marker of the same `Kind` is attached to the same operation for the same `(StatusCode, ContentType)` key (for example two `ProducesPortableProblemAttribute`s with identical status and content type), because that is a genuine ambiguity about which narrowing to emit. It also throws `InvalidOperationException` when an attribute instance has both `InlineErrorMetadataCodes` and `InlineErrorMetadataTypes` set to non-null arrays of different lengths; the exception message includes both observed lengths so the caller can realign them.
+- [x] The `PackageReleaseNotes` section of `Light.PortableResults.AspNetCore.MinimalApis.csproj` and `Light.PortableResults.AspNetCore.Mvc.csproj` is updated to call out the removal of the schema-only CLR types and the helper/attribute collapse, and to point consumers at the new `Light.PortableResults.AspNetCore.OpenApi` package for OpenAPI integration. `Light.PortableResults.AspNetCore.OpenApi.csproj` carries its own `PackageReleaseNotes` introducing the package, its opt-in `AddPortableResultsOpenApi` entry point, the canonical schema catalog, the three helpers, the three attributes, and the error-metadata registry.
+- [x] Automated tests cover the document transformer end-to-end: canonical catalog emission, each helper/attribute's effect on the generated document, global error-code registry integration, inline escape hatch, per-endpoint format override, success-response metadata narrowing, and the fallback `$ref` for undocumented codes.
+- [x] The `NativeAotMovieRating` sample is updated to the new public API (adds a `ProjectReference` to `Light.PortableResults.AspNetCore.OpenApi`, calls `AddPortableResultsOpenApi()`, uses the new helpers) and no longer wires `OpenApiOptions.CreateSchemaReferenceId`.
+- [x] The new OpenAPI surface remains NativeAOT-compatible. The `NativeAotMovieRating` sample continues to build and run under `PublishAot=true`, and the document transformer uses only APIs compatible with the trimmer and AOT analyzer (no `Type.MakeGenericType`, no dynamic assembly emit, no reflection over handler parameters; all generic dispatch happens through the existing `GetOrCreateSchemaAsync` API and through attribute instances supplied at compile time).
+- [x] `README.md` is updated: the OpenAPI section reflects the new public surface (new `Light.PortableResults.AspNetCore.OpenApi` package, opt-in `AddPortableResultsOpenApi()`, three helpers, three attributes, DI-level `ConfigureErrorMetadataContracts`, per-endpoint format override), and all references to the deleted schema-only CLR types, the naming convention, and the `Rich` vs `AspNetCoreCompatible` helper split are removed.
+
+## Technical Details
+
+### Ownership Model
+
+- **Envelope schemas are library-owned.** They are authored once, in OpenAPI notation, in `Light.PortableResults.AspNetCore.OpenApi`. They do not exist as CLR types.
+- **Metadata content is caller-owned.** By default metadata slots are declared as open objects. The endpoint builder is the single place where narrowing is expressed per endpoint: it narrows the top-level `metadata` schema via `WithMetadata`, opts into globally registered per-code contracts via `WithErrorCodes`, and overrides one-off codes inline via `WithErrorMetadata`. The global `ConfigureErrorMetadataContracts` registry is a complementary mechanism that stores the per-code metadata contracts the builder references, not an alternative to it. Typical apps use both: register each stable error code once in DI, then opt the relevant codes into each failure response on the endpoint.
+- **Validation format is per-endpoint with a DI default.** The runtime already supports this through `PortableResultsHttpWriteOptions.ValidationProblemSerializationFormat` plus the per-call override path in `HttpExtensions.ResolvePortableResultsHttpWriteOptions`. The OpenAPI helper mirrors this: if no `format` is passed, the configured app default is used.
+- **Documentation overrides are declarative and static, runtime overrides are per-call.** Both `UseMetadataSerializationMode(...)` and `UseFormat(...)` attach endpoint metadata that the transformer reads at document generation time. Runtime handlers use a separate override path (`LightResult` / `LightActionResult` constructors accept `PortableResultsHttpWriteOptions? overrideOptions`). The library cannot observe runtime overrides from a static transformer, so callers who override runtime options per endpoint must pass a matching declarative override to the OpenAPI helper/attribute to keep the documented shape aligned with the wire. A future plan may unify these two paths by having `HttpContext.ResolvePortableResultsHttpWriteOptions` consult endpoint metadata as an additional fallback step; that runtime change is explicitly out of scope here.
+- **Success-response shape is mode-aware.** The runtime produces two distinct body shapes for `LightResult` (see the `IsValid` branch in `HttpResultForWritingJsonConverter`): a bare `T` under `MetadataSerializationMode.ErrorsOnly`, and a wrapped `{ value: T, metadata?: object }` under `MetadataSerializationMode.Always` via `SerializeValueAndMetadata`, where `metadata` is only written when any metadata value is annotated `SerializeInHttpResponseBody`. `ProducesPortableSuccessResponse` is faithful to both modes: the transformer resolves the effective mode from `attr.MetadataSerializationMode ?? options.Value.MetadataSerializationMode`, calls `context.GetOrCreateSchemaAsync(attr.ValueType!)` to obtain the value-type schema (which may be inline for primitives and collections, or a reference for complex types), and either installs that schema directly on the response (under `ErrorsOnly` with no narrowing) or wraps it in a per-operation envelope component registered via `document.AddComponent` (under `Always` or whenever `TopLevelMetadataType` is set). Non-generic `LightResult` / `LightActionResult` success responses are out of scope for this helper; callers document them with plain ASP.NET Core helpers (`Produces()`, `ProducesResponseType()`, or status-only responses).
+
+### Canonical Schema Catalog
+
+A single static class `PortableResultsOpenApiSchemas` in `Light.PortableResults.AspNetCore.OpenApi` produces the canonical schemas and installs them into `OpenApiDocument.Components.Schemas`. Its only public method is `InstallInto(OpenApiDocument document)`, which is idempotent (keyed by schema component id) and initializes `document.Components` and `document.Components.Schemas` if either is null. Tests assert on the installed components by calling `InstallInto` on a fresh `OpenApiDocument` and inspecting `document.Components.Schemas`. Each schema is authored directly as `OpenApiSchema` objects from `Microsoft.OpenApi.Models`.
+
+Schema shapes mirror what the runtime actually writes:
+
+- `PortableError`: `message` (string, required), `code` (string, nullable), `target` (string, nullable), `category` (`$ref: ErrorCategory`), `metadata` (open object, nullable).
+- `PortableValidationErrorDetail`: `target` (string, required), `index` (integer, required), `code` (string, nullable), `category` (`$ref: ErrorCategory`, nullable), `metadata` (open object, nullable).
+- `PortableProblemDetails`: extends RFC 9457 Problem Details with `errors` (array of `PortableError`) and `metadata` (open object, nullable).
+- `PortableRichValidationProblemDetails`: same shape as `PortableProblemDetails` but a distinct schema component so generated client code can distinguish validation failures.
+- `PortableAspNetCoreValidationProblemDetails`: extends `HttpValidationProblemDetails` with optional `errorDetails` (array of `PortableValidationErrorDetail`) and `metadata` (open object, nullable).
+
+Success responses are intentionally absent from the catalog. They only take a stable shape in the context of a specific `TValue`, and the transformer synthesizes each one per operation via `document.AddComponent` (where `document` is the `OpenApiDocument` parameter passed to `TransformAsync`). The shape of every synthesized success envelope is `{ value: , metadata: open object (nullable) }`.
+
+The `ErrorCategory` enum is also emitted as a schema component once under the id `ErrorCategory`, reused by all envelopes.
+
+### Endpoint Metadata Attributes
+
+There is no separate marker POCO. The three sealed attribute types are themselves the endpoint-metadata entries the transformer reads from `apiDescription.ActionDescriptor.EndpointMetadata`. They sit in a three-level hierarchy chosen so that every public knob is valid on the type it is declared on — there are no silent-ignore properties on any public attribute.
+
+```csharp
+public abstract class PortableOpenApiResponseAttributeBase : Attribute
+{
+ protected PortableOpenApiResponseAttributeBase(
+ PortableOpenApiResponseKind kind,
+ int statusCode,
+ string contentType)
+ {
+ Kind = kind;
+ StatusCode = statusCode;
+ ContentType = contentType;
+ }
+
+ public PortableOpenApiResponseKind Kind { get; }
+ public int StatusCode { get; set; }
+ public string ContentType { get; set; }
+ public Type? TopLevelMetadataType { get; set; }
+}
+
+public abstract class PortableOpenApiErrorResponseAttributeBase : PortableOpenApiResponseAttributeBase
+{
+ protected PortableOpenApiErrorResponseAttributeBase(
+ PortableOpenApiResponseKind kind,
+ int statusCode,
+ string contentType)
+ : base(kind, statusCode, contentType) { }
+
+ public string[]? ErrorCodes { get; set; }
+ public string[]? InlineErrorMetadataCodes { get; set; }
+ public Type[]? InlineErrorMetadataTypes { get; set; }
+}
+
+[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
+public sealed class ProducesPortableSuccessResponseAttribute : PortableOpenApiResponseAttributeBase
+{
+ public ProducesPortableSuccessResponseAttribute(
+ int statusCode = StatusCodes.Status200OK,
+ string contentType = "application/json")
+ : base(PortableOpenApiResponseKind.SuccessResponse, statusCode, contentType)
+ {
+ ValueType = typeof(TValue);
+ }
+
+ public Type ValueType { get; }
+ public MetadataSerializationMode? MetadataSerializationMode { get; set; }
+}
+
+[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
+public sealed class ProducesPortableProblemAttribute : PortableOpenApiErrorResponseAttributeBase
+{
+ public ProducesPortableProblemAttribute(
+ int statusCode = StatusCodes.Status500InternalServerError,
+ string contentType = "application/problem+json")
+ : base(PortableOpenApiResponseKind.Problem, statusCode, contentType) { }
+}
+
+[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
+public sealed class ProducesPortableValidationProblemAttribute : PortableOpenApiErrorResponseAttributeBase
+{
+ public ProducesPortableValidationProblemAttribute(
+ int statusCode = StatusCodes.Status400BadRequest,
+ string contentType = "application/problem+json")
+ : base(PortableOpenApiResponseKind.ValidationProblem, statusCode, contentType) { }
+
+ public ValidationProblemSerializationFormat? Format { get; set; }
+}
+```
+
+`PortableOpenApiResponseKind` is an enum with values `SuccessResponse`, `Problem`, `ValidationProblem`. `AllowMultiple = true` lets a single operation declare several response contracts per kind (for example two distinct `[ProducesPortableProblem]` status codes, or a problem plus a validation problem at the same status code — see the merge rule in the *Document Transformer* section).
+
+Minimal APIs helpers construct a concrete attribute instance, pass it into the corresponding configuration builder (which only exposes members that map onto settable properties actually present on that concrete attribute), and then call `RouteHandlerBuilder.WithMetadata(attributeInstance)`. MVC attribute instances flow into `ActionDescriptor.EndpointMetadata` through the standard MVC endpoint-routing pipeline. Both paths converge on the same metadata hierarchy, and the transformer reads them uniformly via `apiDescription.ActionDescriptor.EndpointMetadata.OfType()`, then branches on concrete type for kind-specific logic. No reflection over handler parameters is needed, and the design is AOT-friendly.
+
+### Document Transformer
+
+`PortableResultsOpenApiDocumentTransformer` (in `Light.PortableResults.AspNetCore.OpenApi`) is a singleton service implementing `IOpenApiDocumentTransformer`. Its constructor takes `IOptions` and `IPortableErrorMetadataContractRegistry`. The transformer holds no mutable instance state between invocations: all per-document state lives on the passed `OpenApiDocument` and transformer context, so it is safe to register as a singleton and to run concurrently across multiple OpenAPI documents. Its `TransformAsync` signature is `TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken)`: every component registration writes to the `document` parameter directly (for example `document.AddComponent(name, schema)`), because `OpenApiDocumentTransformerContext` does not expose a `Document` property — it provides only `DocumentName`, `DescriptionGroups`, `ApplicationServices`, and the `GetOrCreateSchemaAsync` method. The target OpenAPI spec version is resolved per invocation from `context.ApplicationServices.GetRequiredService>().Get(context.DocumentName).OpenApiVersion`, so a single source of truth (`OpenApiOptions.OpenApiVersion`) drives all spec-version-dependent branches. `TransformAsync`:
+
+1. On first invocation for a given document, calls `PortableResultsOpenApiSchemas.InstallInto(document)` (idempotent: checked by schema component id).
+2. For each registered entry in `IPortableErrorMetadataContractRegistry.Contracts`, synthesizes the global `PortableError__` and `PortableValidationErrorDetail__` schema components once, applying the error-code sanitization rule described in the Acceptance Criteria.
+3. Iterates the `ApiDescription` instances exposed through `context.DescriptionGroups`, reads `PortableOpenApiResponseAttributeBase` entries via `apiDescription.ActionDescriptor.EndpointMetadata.OfType()` (working uniformly for Minimal APIs and MVC), and locates the matching `OpenApiOperation` by translating the `ApiDescription` into `OpenApiDocument.Paths` keys: the path key is `"/" + apiDescription.RelativePath` when `RelativePath` does not already start with `/`, and the operation key is obtained by parsing `apiDescription.HttpMethod` into `Microsoft.OpenApi.Models.OperationType` (case-insensitive `Enum.Parse`). The resolved `OpenApiOperation` has its `Responses` collection mutated in place. The transformer groups the attributes by `(StatusCode, ContentType)`: if any group contains more than one attribute of the same `Kind`, it throws `InvalidOperationException` with a message naming the status, content type, and kind. Groups that contain multiple attributes of different `Kind`s are accepted and merged in step 4 so that documenting both a problem and a validation problem at the same `(StatusCode, ContentType)` — a common cloud-API shape — produces one `OpenApiResponse` with a unioned schema.
+4. For each `(StatusCode, ContentType)` group on each operation, builds a list of contributing schemas — one per attribute in the group — and then attaches them to the `OpenApiResponse` for that status/media type:
+ - If the group has one contributing schema, it is used as the response content schema directly.
+ - If the group has more than one contributing schema (necessarily of different `Kind`s per the rule in step 3), the response content schema is `anyOf` over the contributing schemas in the order the attributes were discovered. `anyOf` is used instead of `oneOf` for the same reason as in the error-narrowing design: the contributing envelopes can overlap structurally (both problem variants extend RFC 9457 Problem Details), so `oneOf`'s exclusivity rule would be violated.
+
+ Each contributing schema is built by dispatching on the attribute's concrete type:
+
+ - **`ProducesPortableSuccessResponseAttribute`** resolves the effective mode as `attr.MetadataSerializationMode ?? options.Value.MetadataSerializationMode` and obtains the value-type schema via `await context.GetOrCreateSchemaAsync(attr.ValueType, parameterDescription: null, cancellationToken)` (the second argument is always null for response types because `ApiParameterDescription` carries request-parameter context only). The returned `OpenApiSchema` may be inline (primitives, collections) or a reference to an existing component (complex types) — the transformer uses it as-is wherever a value schema is required.
+ - Under `ErrorsOnly` with no `TopLevelMetadataType`, the contributing schema is the returned `OpenApiSchema` directly. No envelope component is registered.
+ - Under `Always` (or whenever `TopLevelMetadataType` is set), the transformer synthesizes an operation-scoped envelope component whose `value` property's schema is the returned `OpenApiSchema` and whose `metadata` property is either the open-object canonical or — when `TopLevelMetadataType` is set — the schema produced by `GetOrCreateSchemaAsync(attr.TopLevelMetadataType)`. The envelope is registered via `document.AddComponent(name, envelopeSchema)` and referenced from the contributing schema. Per-operation synthesis (rather than reuse by `TValue`) is mandatory because ASP.NET Core leaves primitive and collection schemas inline — there is no stable `TValueSchemaId` to key reuse on for those payloads.
+ - If `TopLevelMetadataType` is set and the resolved mode is `ErrorsOnly`, the transformer throws `InvalidOperationException` at document generation because metadata is not part of the wire in that mode.
+ - **`ProducesPortableProblemAttribute`** and **`ProducesPortableValidationProblemAttribute`** (via the shared `PortableOpenApiErrorResponseAttributeBase`) reference the canonical schema directly by `$ref` when unconfigured, or produce a derived `allOf` envelope registered via `document.AddComponent` when any of `TopLevelMetadataType`, `ErrorCodes`, or `InlineErrorMetadataCodes` is set. Before synthesizing the operation-scoped inline variants, the transformer sanitizes each inline code and throws `InvalidOperationException` if two distinct inline codes on this `(operation, StatusCode, ContentType)` triple collide after sanitization; the exception names both raw codes so the caller can rename or globally register one of them.
+ - **Synthesized schema names** follow `______` (for example `PortableProblemDetails__GetMovies__404__application_problem_json`) and fall back to `________` when the operation has no `OperationId`. Segments are always separated by `__` (double underscore) so that component ids are visually and programmatically parseable into their constituent parts; characters within each segment are restricted to `[A-Za-z0-9_]` by sanitization so `__` is unambiguous as a segment delimiter. `SanitizedContentType` replaces `/`, `+`, `.`, `-`, and any other non-`[A-Za-z0-9_]` character with `_`. `SanitizedRoutePattern` applies the same rule to the raw route template and collapses adjacent replacement characters to a single `_`, so `/api/movies/{id}` becomes `api_movies_id`. Including the content-type token is required because one operation may legitimately document different narrowings for the same status code under different content types, and the transformer's duplicate-attribute check is keyed by `(StatusCode, ContentType)`.
+5. Resolves the effective validation format for `Kind == ValidationProblem` attributes as `attr.Format ?? options.Value.ValidationProblemSerializationFormat`. The chosen format selects `PortableRichValidationProblemDetails` or `PortableAspNetCoreValidationProblemDetails` as the base schema in the `allOf`.
+6. Emits discriminator narrowing using schema-level `const` when the resolved `OpenApiVersion` is OpenAPI 3.1 or later, and falls back to `enum: []` for OpenAPI 3.0.
+7. Emits metadata DTO schemas (for `TopLevelMetadataType`, registry entries, and `InlineErrorMetadata` values) by calling `context.GetOrCreateSchemaAsync` on the CLR type and, when the transformer needs a stable reference, explicitly registering the returned schema via `document.AddComponent`. This keeps serializer configuration and polymorphism intact while ensuring every `$ref` the transformer emits points at a component it has actually registered.
+
+### Per-Error-Code Metadata Registry
+
+`IPortableErrorMetadataContractRegistry` (declared in `Light.PortableResults.AspNetCore.OpenApi`) is a singleton service with one property, `IReadOnlyDictionary Contracts`. Its default implementation, `PortableErrorMetadataContractRegistry`, is materialized from a `PortableErrorMetadataContractsBuilder` that callers populate through the DI extension method `ConfigureErrorMetadataContracts(this IServiceCollection services, Action configure)`. The builder exposes `ForCode(string code)` and `ForCode(string code, Type metadataType)` and is internally a `Dictionary`.
+
+Additive composition is delegated to the standard .NET options pipeline rather than hand-rolled. A small public options type `PortableErrorMetadataContractsOptions` owns a single `PortableErrorMetadataContractsBuilder Builder { get; } = new();` property. `ConfigureErrorMetadataContracts` is implemented as:
+
+```csharp
+public static IServiceCollection ConfigureErrorMetadataContracts(
+ this IServiceCollection services,
+ Action configure)
+{
+ services.AddOptions();
+ services.Configure(opts => configure(opts.Builder));
+ services.TryAddSingleton(sp =>
+ new PortableErrorMetadataContractRegistry(
+ sp.GetRequiredService>().Value.Builder));
+ return services;
+}
+```
+
+Each call to `ConfigureErrorMetadataContracts` registers another `IConfigureOptions` that runs in registration order against the same lazily-created options instance, so multiple calls from separate feature modules compose additively without any shared mutable service. `PortableErrorMetadataContractRegistry`'s constructor copies the builder's dictionary into an immutable snapshot, so the registry is frozen for the lifetime of the singleton.
+
+`PortableErrorMetadataContractsBuilder.ForCode` enforces the duplicate rule directly where the conflict is observable: registering a raw code whose existing entry already has the same `Type` is a no-op; registering it with a different `Type` throws `InvalidOperationException` naming the raw code and both types. The `PortableErrorMetadataContractRegistry` constructor repeats the same check while snapshotting the builder, so a conflict introduced by a late-running `IConfigureOptions` callback is still caught deterministically at materialization time with the same exception message.
+
+On first document generation the transformer synthesizes one `PortableError__` schema per registered code using the `allOf` pattern (the `code` constraint is encoded as `const` on OpenAPI 3.1+ and as `enum: []` on OpenAPI 3.0, and the component id applies the sanitization rule — any character outside `[A-Za-z0-9_]` replaced with `_`, collisions rejected at registration time):
+
+```text
+allOf:
+ - $ref: PortableError
+ - properties:
+ code: { type: string, const: }
+ metadata: { $ref: }
+ required: [code]
+```
+
+And one `PortableValidationErrorDetail__` companion using the same pattern against `PortableValidationErrorDetail`.
+
+Endpoints that call `WithErrorCodes(...)` cause the transformer to synthesize a derived envelope whose `errors[*]` (rich + generic problem) or `errorDetails[*]` (asp.net-core-compatible validation) array item is an `anyOf` over the narrowed code schemas plus a trailing `$ref` to the baseline for undocumented codes, with a `discriminator` on `code` carrying an explicit `mapping` entry for every documented code:
+
+```text
+anyOf:
+ - $ref: '#/components/schemas/PortableError__VersionMismatch'
+ - $ref: '#/components/schemas/PortableError__InsufficientFunds'
+ - $ref: '#/components/schemas/PortableError' # fallback for undocumented codes
+discriminator:
+ propertyName: code
+ mapping:
+ VersionMismatch: '#/components/schemas/PortableError__VersionMismatch'
+ InsufficientFunds: '#/components/schemas/PortableError__InsufficientFunds'
+```
+
+`anyOf` is used instead of `oneOf` because every narrowed variant is an `allOf` restriction of the base `PortableError`, so any narrowed instance also validates against the base; that would violate `oneOf`'s exclusivity rule. Under `anyOf`, validators accept the instance against at least one branch, and discriminator-aware tooling uses the explicit `mapping` to pick the precise narrowed variant by code. Explicit `mapping` is required because implicit discriminator resolution matches on bare component names and our synthesized component ids are `PortableError__` rather than ``.
+
+The discriminator `mapping` keys are the raw wire codes (matching what the runtime writes to the `code` property), and the mapping values are JSON-Pointer-escaped `$ref`s per RFC 6901. Because the component id is always pre-sanitized to `[A-Za-z0-9_]` the `$ref` value rarely needs escaping in practice, but the transformer applies the escape unconditionally so that any change to the sanitization rule remains spec-valid.
+
+Inline `WithErrorMetadata(code, type)` follows the same mechanism but emits the synthesized narrowing schema scoped to the operation (for example `PortableError__GetMovies__409__application_problem_json__VersionMismatch`) so it does not pollute the global `PortableError__` namespace. The per-operation name includes the sanitized content type to prevent collisions when the same operation documents different narrowings per media type, and is also registered in the discriminator mapping for the operation's envelope.
+
+### Public API Shape
+
+The Minimal APIs helpers return a builder from the configuration callback. Three sealed builder classes cover the three response kinds. They do not share a public base: each one exposes exactly the members that are applicable to the corresponding concrete attribute, so there are no silent-ignore builder methods.
+
+- `PortableSuccessResponseOpenApiBuilder` — `WithMetadata()`, `WithMetadata(Type metadataType)`, `UseMetadataSerializationMode(MetadataSerializationMode mode)`.
+- `PortableProblemOpenApiBuilder` — `WithMetadata()`, `WithMetadata(Type metadataType)`, `WithErrorCodes(params string[] codes)`, `WithErrorMetadata(string code, Type metadataType)`, `WithErrorMetadata(string code)`.
+- `PortableValidationProblemOpenApiBuilder` — the same surface as `PortableProblemOpenApiBuilder` plus `UseFormat(ValidationProblemSerializationFormat format)`.
+
+All three builders are `sealed`. Each builder returns `this` from every method for chaining. Each method mutates settable properties on the paired concrete attribute instance the helper created up front (`PortableSuccessResponseOpenApiBuilder` mutates a `ProducesPortableSuccessResponseAttribute`, and so on); after the configure callback returns, the helper calls `RouteHandlerBuilder.WithMetadata(attributeInstance)` to attach the attribute as endpoint metadata.
+
+MVC attributes are the same sealed types described in the *Endpoint Metadata Attributes* section. Their settable properties use attribute-argument-compatible types per ECMA-335 §II.23.3 (primitives, `string`, `System.Type`, enums, or single-dimension arrays of those) — for example `string[]` instead of `IReadOnlyList`. Each attribute only exposes properties that are meaningful for its kind: the success attribute has no `ErrorCodes`, the problem and validation attributes have no `MetadataSerializationMode`, and only the validation attribute has `Format`. This is enforced by the type hierarchy rather than by runtime validation.
+
+Attribute instances reach `ActionDescriptor.EndpointMetadata` through the standard MVC endpoint-routing pipeline (attributes declared on a controller action are added automatically), so no `IEndpointMetadataProvider` implementation is needed. The transformer reads the same attribute instances that the Minimal APIs helpers attach via `RouteHandlerBuilder.WithMetadata(...)`, which gives both stacks a single metadata hierarchy rooted at `PortableOpenApiResponseAttributeBase`.
+
+The Minimal APIs helper and MVC attribute for success responses keep `TValue` as the only generic parameter and do not expose error-code members (success responses do not carry errors).
+
+### Scope Boundaries
+
+- This feature does not change runtime JSON serialization.
+- This feature does not infer schemas from `PortableResultsHttpWriteOptions` or handler signatures beyond what the markers explicitly declare.
+- This feature does not support Swashbuckle / NSwag. Consumers of those stacks continue to receive the runtime wire format but no Light.PortableResults-specific OpenAPI helpers.
+- This feature does not attempt to represent `MetadataValueAnnotation` filtering in OpenAPI. The schema documents the broadest possible metadata shape; runtime filtering by annotation remains a runtime concern.
+- This feature does not ship built-in error-code contracts for the validation package. The built-in `ValidationErrorDefinition` classes (in `Light.PortableResults.Validation`) already define a stable code-plus-metadata taxonomy via `ValidationErrorMetadataKeys`, and a follow-up plan (`0040-2-validation-error-contracts.md`) will wire them into `IPortableErrorMetadataContractRegistry` through an opt-in `RegisterBuiltInValidationErrors()` extension. This redesign keeps the registry surface minimal (type-based contracts only) and is forward-compatible: the follow-up widens the contract value from `Type` to a discriminated union that also accepts pre-authored `OpenApiSchema` instances without breaking existing registrations.
diff --git a/ai-plans/0040-2-validation-error-contracts.md b/ai-plans/0040-2-validation-error-contracts.md
new file mode 100644
index 0000000..dadf9de
--- /dev/null
+++ b/ai-plans/0040-2-validation-error-contracts.md
@@ -0,0 +1,188 @@
+# Built-In Validation Error Contracts for OpenAPI
+
+## Rationale
+
+Plan `0040-1-openapi-redesign.md` introduces `IPortableErrorMetadataContractRegistry` in `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts`, which maps error code strings to CLR metadata types so the OpenAPI document transformer can narrow `errors[*].metadata` and `errorDetails[*].metadata` to accurate schemas per code.
+
+The `Light.PortableResults.Validation` package already defines a stable code-plus-metadata taxonomy through its built-in `ValidationErrorDefinition` subclasses (`CountValidationErrorDefinition`, `MinCountValidationErrorDefinition`, `GreaterThanValidationErrorDefinition`, `PatternValidationErrorDefinition`, `EnumNameValidationErrorDefinition`, `PrecisionScaleValidationErrorDefinition`, etc.). The metadata keys are centralized in `ValidationErrorMetadataKeys`. Without this follow-up, every caller who uses built-in validation error definitions has to redeclare contracts the library already owns.
+
+Three aspects of the built-in contracts make a pure CLR-type registration awkward:
+
+1. **Code-level polymorphism.** Built-in comparison and range codes are shared across many `T`s, but the global registry is keyed only by error code. `CreateMetadataValue` in `BuiltInValidationErrorDefinitions.Shared.cs` projects any `T` down to one of `null | boolean | int64 | double | decimal | string` for primitives, so the global code-level contract for a code like `GreaterThan` must document a broad JSON-primitive shape. Endpoint-specific typed helpers introduced by this plan then narrow that broad fallback to the concrete `T` when the application can provide it.
+2. **Layering.** `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts` (where `IPortableErrorMetadataContractRegistry` lives, per `0040-1`) does not and should not depend on `Light.PortableResults.Validation`. Conversely, `Light.PortableResults.Validation` is an OpenAPI-agnostic foundation used from messaging, gRPC, and console hosts; it must not take on a `Microsoft.OpenApi` package reference or any direct knowledge of the OpenAPI registry. The built-in contract catalog therefore lives in neither package.
+3. **Spec-version dependence.** The polymorphic primitive schema (`null | string | number | integer | boolean`) cannot be authored once for every OpenAPI version. OpenAPI 3.0 has no `null` type and instead expresses nullability via `nullable: true`; OpenAPI 3.1+ uses `{ type: "null" }`. The transformer already branches on `OpenApiSpecVersion` for `const` vs `enum` narrowing and must do the same here.
+
+This plan widens the registry to also accept pre-authored `OpenApiSchema` values produced by a per-code factory, introduces a new bridge package `Light.PortableResults.Validation.OpenApi` that owns the catalog and the opt-in extension, and exposes the built-in error codes as compile-time constants on the validation package itself so callers get IntelliSense and refactor safety even when the OpenAPI package is not in scope.
+
+## Acceptance Criteria
+
+- [x] `PortableErrorMetadataContract` is introduced as a public abstract base class with a library-owned closed set of sealed subclasses in `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts` (alongside `IPortableErrorMetadataContractRegistry`), representing a discriminated union of (a) a CLR `Type` (to be run through the ASP.NET Core schema generator), (b) a per-code `Func` factory, or (c) the absence of metadata. The base type is `public abstract class PortableErrorMetadataContract` exposing `static FromType(Type metadataType)` and `static FromSchema(Func schemaFactory)` factory methods and a `static PortableErrorMetadataContract NoMetadata { get; }` singleton. No public `Kind` enum is exposed; the concrete subclass is the discriminator and the transformer dispatches via pattern matching. Three sealed subclasses `PortableErrorMetadataTypeContract`, `PortableErrorMetadataSchemaContract`, and `PortableErrorMetadataNoMetadataContract` carry the respective payloads; the type and factory subclasses expose their payloads as public read-only properties. The factory shape (rather than a static `OpenApiSchema` instance) is mandatory because `OpenApiSchema` is a mutable POCO; storing a single instance in a static catalog leaks mutations across consumer hosts. The factory accepts the resolved `OpenApiSpecVersion` so spec-version-dependent shapes can author themselves correctly; callers that do not need the version simply ignore the parameter. OpenAPI document generation runs only during application startup, so neither the factory invocation nor the abstract-class allocation is perf-sensitive.
+- [x] `IPortableErrorMetadataContractRegistry.Contracts` is widened from `IReadOnlyDictionary` to `IReadOnlyDictionary`. The default implementation and its tests are updated accordingly.
+- [x] `PortableErrorMetadataContractsBuilder` gains two new overloads: `ForCode(string code, Func metadataSchemaFactory)` (storing `PortableErrorMetadataContract.FromSchema(...)`) and `ForCode(string code)` (storing `PortableErrorMetadataContract.NoMetadata` for codes that the runtime never decorates with metadata). The existing `ForCode(string code)` and `ForCode(string code, Type metadataType)` overloads continue to work unchanged and internally store `PortableErrorMetadataContract.FromType(...)`. Re-registering the same code with an equivalent contract is idempotent; registering the same code with a different contract throws a clear duplicate-contract exception instead of using last-writer-wins.
+- [x] `PortableResultsOpenApiDocumentTransformer` in `Light.PortableResults.AspNetCore.OpenApi.Generation` is updated to dispatch on the concrete subclass when materializing registry entries: `PortableErrorMetadataTypeContract` entries go through the ASP.NET Core schema generator as before; `PortableErrorMetadataSchemaContract` entries invoke the factory once per generated metadata component (passing the resolved `OpenApiSpecVersion`), install the produced schema, and `$ref` it from the narrowed code schema; `PortableErrorMetadataNoMetadataContract` entries emit the narrowed envelope without a `metadata` reference at all. Concretely, for type and schema contracts the synthesized extension schema continues to be `{ properties: { code: const, metadata: $ref }, required: [code] }`; for no-metadata contracts the extension schema is `{ properties: { code: const }, required: [code] }`, which leaves `metadata` to inherit from the base schema (open object, nullable). This is faithful to the wire — the runtime simply does not write a `metadata` property for these codes — and matches the canonical envelope's nullability. Schema-based contracts therefore only replace the `metadata` reference target, no-metadata contracts remove it entirely, and the narrowed-envelope `allOf [base, extension]` construction in `CreateCodeSpecificSchema` is otherwise unchanged. All three contract kinds share one component-id namespace.
+- [x] Schema-based metadata components are stored under the same naming convention used for type-based metadata: `____Metadata` (for example `PortableError__Count__Metadata`), produced by the existing `PortableResultsOpenApiSchemaNaming.CreateMetadataSchemaId(...)` helper. There is no flat `Metadata` namespace; both contract kinds live in the same component-id space so tools that walk `Components.Schemas` see one rule rather than two.
+- [x] A new project `Light.PortableResults.Validation.OpenApi` is added to the solution. It targets .NET 10, sets `true `, and project-references both `Light.PortableResults.Validation` and `Light.PortableResults.AspNetCore.OpenApi`. `Light.PortableResults.Validation` itself does **not** gain a `Microsoft.OpenApi` reference and remains OpenAPI-agnostic so non-ASP.NET-Core hosts (messaging, gRPC, console) carry no transitive OpenAPI dependency.
+- [x] A public static class `BuiltInValidationErrorContracts` is added to `Light.PortableResults.Validation.OpenApi` with the property `public static IReadOnlyDictionary Contracts { get; }`. The dictionary contains one entry per built-in validation error code that has a stable framework-level shape:
+ - Codes that carry metadata (`Count`, `MinCount`, `MaxCount`, `MinLength`, `MaxLength`, `LengthInRange`, `EqualTo`, `NotEqualTo`, `GreaterThan`, `GreaterThanOrEqualTo`, `LessThan`, `LessThanOrEqualTo`, `InRange`, `NotInRange`, `ExclusiveRange`, `Pattern`, `Enum`, `EnumName`, `PrecisionScale`) are stored as `PortableErrorMetadataSchemaContract` instances whose factory returns a fresh `OpenApiSchema` on each invocation, using the exact JSON property names defined in `ValidationErrorMetadataKeys`.
+ - Codes that the framework guarantees emit no metadata (`NotNull`, `Null`, `NotEmpty`, `Empty`, `NotNullOrWhiteSpace`, `Email`, `DigitsOnly`, `LettersAndDigitsOnly`) are stored as `PortableErrorMetadataContract.NoMetadata`. These are included so consumers can opt them into endpoints via `WithErrorCodes` without falling back to the inline `WithErrorMetadata` escape hatch with a synthetic empty type.
+ - `Predicate` is intentionally excluded because it is the default code emitted by `Must(...)` overloads (`Checks.Predicate.cs`), which routinely accept caller-supplied `ValidationErrorDefinition` instances with bespoke metadata shapes. A globally registered no-metadata contract for `Predicate` would lock the schema for those flows and conflict with what consumers actually want to document.
+- [x] Built-in contract schemas that reference a typed value (`comparativeValue`, `lowerBoundary`, `upperBoundary`) declare that property as a `oneOf` over JSON primitives. The exact branches depend on the resolved `OpenApiSpecVersion` of the document, mirroring the existing `const` vs `enum` branch in the transformer:
+ - OpenAPI 3.1+: `oneOf: [{ type: string }, { type: number }, { type: integer }, { type: boolean }, { type: "null" }]`.
+ - OpenAPI 3.0: `oneOf: [{ type: string }, { type: number }, { type: integer }, { type: boolean }]` plus `nullable: true` on the parent property; the `null` branch is omitted because OpenAPI 3.0 does not support `type: "null"`.
+- [x] A public static class `ValidationErrorCodes` is added to `Light.PortableResults.Validation` exposing `public const string` fields for every built-in code (`Count`, `MinCount`, `MaxCount`, `MinLength`, `MaxLength`, `LengthInRange`, `EqualTo`, `NotEqualTo`, `GreaterThan`, `GreaterThanOrEqualTo`, `LessThan`, `LessThanOrEqualTo`, `InRange`, `NotInRange`, `ExclusiveRange`, `Pattern`, `Enum`, `EnumName`, `PrecisionScale`, `NotNull`, `Null`, `NotEmpty`, `Empty`, `NotNullOrWhiteSpace`, `Email`, `DigitsOnly`, `LettersAndDigitsOnly`, `Predicate`). The existing `BuiltInValidationErrorDefinitions.*` constructors are updated to reference these constants instead of string literals. Because the library is pre-stable, this plan also improves the current runtime code strings for developer experience: `LengthIn` becomes `LengthInRange`, `Matches` becomes `Pattern`, `IsInBetween` becomes `InRange`, and `NotInBetween` becomes `NotInRange`. `ValidationErrorCodes` stays in `Light.PortableResults.Validation` (not the new bridge package) because the constants are independently useful in switch arms, message templates, and inline error metadata even when the OpenAPI package is not referenced.
+- [x] A public extension method `RegisterBuiltInValidationErrors(this PortableErrorMetadataContractsBuilder builder)` is added in `Light.PortableResults.Validation.OpenApi`. It iterates `BuiltInValidationErrorContracts.Contracts` and registers each entry into the builder by dispatching on the contract subclass: schema entries call the factory overload, no-metadata entries call `ForCode(string)`, and any future type entries would call the existing `ForCode(string, Type)` overload. `Predicate` is intentionally not registered for the reason described above; consumers who want to document a `Predicate` flow either supply their own `ValidationErrorDefinition` with a custom code and register that, or use the inline `WithErrorMetadata` escape hatch on the relevant endpoint.
+- [x] Nine generic CLR record types are added to `Light.PortableResults.Validation.OpenApi` to back per-endpoint narrowing of the polymorphic comparison and range codes: `EqualToMetadata(T ComparativeValue)`, `NotEqualToMetadata(T ComparativeValue)`, `GreaterThanMetadata(T ComparativeValue)`, `GreaterThanOrEqualToMetadata(T ComparativeValue)`, `LessThanMetadata(T ComparativeValue)`, `LessThanOrEqualToMetadata(T ComparativeValue)`, `InRangeMetadata(T LowerBoundary, T UpperBoundary)`, `NotInRangeMetadata(T LowerBoundary, T UpperBoundary)`, and `ExclusiveRangeMetadata(T LowerBoundary, T UpperBoundary)`. These are the only built-in codes whose metadata genuinely varies in `T` across call sites (every other built-in code is shape-fixed: lengths/counts → integer, regex → string + integer, enum → string + boolean, precision/scale → integer + integer + boolean). Property names match `ValidationErrorMetadataKeys` exactly so the schema generator's casing convention produces the wire-correct keys (`comparativeValue`, `lowerBoundary`, `upperBoundary`).
+- [x] A static class `BuiltInValidationErrorBuilderExtensions` in `Light.PortableResults.Validation.OpenApi` exposes typed extension methods on both `PortableProblemOpenApiBuilder` and `PortableValidationProblemOpenApiBuilder`: `WithEqualToError()`, `WithNotEqualToError()`, `WithGreaterThanError()`, `WithGreaterThanOrEqualToError()`, `WithLessThanError()`, `WithLessThanOrEqualToError()`, `WithInRangeError()`, `WithNotInRangeError()`, and `WithExclusiveRangeError()`. Each helper is a thin wrapper over the existing inline escape hatch — for example `WithInRangeError()` calls `WithErrorMetadata>(ValidationErrorCodes.InRange)` — so the transformer needs no new code path, the endpoint-scoped component id naming (`PortableError________InRange`) is reused, and the resulting schema for the typed bound (`integer` for `T = int`, `string` with `format: date-time` for `T = DateTime`, etc.) comes out of the standard ASP.NET Core schema generator. Endpoints that mix global and narrowed contracts (e.g. `WithErrorCodes(ValidationErrorCodes.NotEmpty, ValidationErrorCodes.LengthInRange).WithInRangeError()`) get the global-component reuse for the polymorphism-free codes and the operation-scoped narrowed component for `InRange`, in the same discriminated `anyOf`.
+- [x] `Light.PortableResults.Validation.OpenApi.csproj` adds a package reference to `Microsoft.OpenApi` if not already supplied transitively through `Light.PortableResults.AspNetCore.OpenApi`, and a corresponding `` entry is added to `Directory.Packages.props` if missing. `Light.PortableResults.Validation.csproj` is unchanged on the dependency front.
+- [x] The `NativeAotMovieRating` sample is updated to reference `Light.PortableResults.Validation.OpenApi`, call `.RegisterBuiltInValidationErrors()` inside `ConfigureErrorMetadataContracts`, and opt its endpoints into the relevant built-in codes via `WithErrorCodes(ValidationErrorCodes.Count, ...)`. At least one endpoint demonstrates a narrowed comparison helper (e.g. `.WithInRangeError()` for the rating endpoint that uses `IsInBetween(1, 5)`) so the sample documents the recommended path for site-specific narrowing of polymorphic comparison codes.
+- [x] Automated tests cover:
+ - The discriminated-union behavior of `PortableErrorMetadataContract` (factory methods, `NoMetadata` singleton, sealed-subclass payloads, pattern-match scenarios, and no public `Kind` enum).
+ - The schema output for every metadata-bearing built-in code (round-tripped against the taxonomy in `ValidationErrorMetadataKeys`).
+ - Duplicate-registration behavior: equivalent repeated registrations are idempotent, while conflicting registrations for the same raw code throw a clear error and do not silently use last-writer-wins.
+ - The validation-code rename: runtime errors emitted by the renamed built-ins use `LengthInRange`, `Pattern`, `InRange`, and `NotInRange`.
+ - The narrowed envelope for no-metadata codes (`NotNull`, `Null`, `NotEmpty`, `Empty`, `NotNullOrWhiteSpace`, `Email`, `DigitsOnly`, `LettersAndDigitsOnly`): the synthesized extension schema constrains `code` only and contains no `metadata` reference.
+ - The `oneOf`-over-primitives shape for typed-value codes, asserted separately for OpenAPI 3.0 and OpenAPI 3.1+.
+ - A full document-validation pass that generates an OpenAPI 3.0 document containing the built-in catalog and validates it against an OpenAPI 3.0 validator, so any spec violation introduced by the catalog (such as a stray `type: "null"` branch leaking into a 3.0 document) is caught at test time rather than by consumers.
+ - A round-trip test that registers the same code via `ForCode(string)` and via `ForCode(string, Func)` and asserts the two transformers produce structurally equivalent narrowed envelopes (modulo schema source), so future refactors cannot silently diverge the type-based and schema-based code paths.
+ - The `RegisterBuiltInValidationErrors` extension registering the expected set of codes (metadata-bearing plus no-metadata codes; `Predicate` excluded).
+ - The typed comparison and range helpers: `WithInRangeError()` produces an endpoint-scoped variant whose metadata schema is `{ lowerBoundary: integer, upperBoundary: integer, required: [lowerBoundary, upperBoundary] }`, and `WithInRangeError()` produces `{ type: string, format: date-time }` for both bounds. Equivalent assertions for the equality, greater/less, not-in-range, and exclusive-range helpers.
+ - A mixed-contract endpoint scenario that mirrors the validator example in the design discussion (`IsNotEmpty`, `HasLengthIn(10, 1000)`, `IsInBetween(1, 5)`): registering the validator endpoint with `.WithErrorCodes(ValidationErrorCodes.NotEmpty, ValidationErrorCodes.LengthInRange).WithInRangeError()` produces a discriminated `anyOf` whose `NotEmpty` and `LengthInRange` branches reference the global `PortableError__` components and whose `InRange` branch references the endpoint-scoped narrowed component.
+ - An end-to-end scenario where an endpoint opts into a metadata-bearing built-in code and a no-metadata built-in code (e.g. `Count` and `NotNull`) and the generated OpenAPI document contains the expected narrowed schemas in the discriminated `anyOf`.
+- [x] `README.md` is updated to describe the new `Light.PortableResults.Validation.OpenApi` package and its opt-in one-liner, the built-in taxonomy surfaced by `ValidationErrorCodes`, the typed comparison/range helpers (`WithInRangeError`, `WithGreaterThanError`, `WithEqualToError`, etc.) for site-specific narrowing of polymorphic codes, and the fact that user-defined codes continue to register through the existing type-based overloads on the OpenAPI package.
+
+## Technical Details
+
+### Contract Widening
+
+`PortableErrorMetadataContract` is an abstract base class with a library-owned closed set of sealed subclasses rather than a struct. The original draft of this plan used a struct for allocation reasons, but OpenAPI document generation runs only during application startup and is not on any hot path; the closed class hierarchy reads cleanly under pattern matching, makes the concrete runtime type the only discriminator, and avoids the boxing pitfalls of an `enum + nullable payload` struct. It intentionally does not expose a `Kind` enum: a public enum would create a second discriminator that can drift from the payload, and it would imply user extensibility that the transformer cannot honor without a behavior-based custom contract API. Future library-owned variants can still be added as new sealed subclasses. The base type, the three sealed subclasses, the builder overloads, and the registry abstractions stay together in `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts`. The default implementation of `IPortableErrorMetadataContractRegistry` stores entries in a `Dictionary`. Existing call sites that wrote `Type` directly are updated to wrap with `PortableErrorMetadataContract.FromType(...)`. The public `ForCode(string)` and `ForCode(string, Type)` overloads are unchanged.
+
+The new `ForCode(string code, Func metadataSchemaFactory)` overload stores the supplied factory directly in a `PortableErrorMetadataSchemaContract`. The factory shape avoids the cloning question entirely: callers (including the built-in catalog) construct a fresh `OpenApiSchema` per invocation, so no two consumer hosts ever share a mutable schema instance and the registry never has to defensively clone. The spec-version parameter is passed through unchanged from the transformer's per-document resolution.
+
+The new `ForCode(string code)` overload stores `PortableErrorMetadataContract.NoMetadata`, a singleton `PortableErrorMetadataNoMetadataContract` instance. This variant exists for codes whose framework-level definitions guarantee no metadata is ever attached at runtime (`NotNull`, `Null`, `NotEmpty`, `Empty`); registering them lets consumers opt those codes into endpoints via `WithErrorCodes` without falling back to an inline escape hatch.
+
+Duplicate registrations are intentionally fail-fast instead of last-writer-wins. A repeated registration is idempotent only when it represents the same contract: the same CLR metadata `Type`, the shared `NoMetadata` singleton, or the same schema factory delegate instance. Any other second registration for the same raw code throws. This keeps option composition predictable: a library can call `RegisterBuiltInValidationErrors()`, an application can add its own codes, and accidental collisions surface at startup instead of silently changing the generated OpenAPI contract based on registration order. If a future consumer needs deliberate global replacement, add an explicit API such as `ReplaceCode(...)` rather than making all `ForCode(...)` calls overwrite by default.
+
+### Transformer Dispatch
+
+When the transformer in `Light.PortableResults.AspNetCore.OpenApi.Generation` synthesizes the canonical `PortableError__` and `PortableValidationErrorDetail__` schemas, it pattern-matches on the contract:
+
+- For `PortableErrorMetadataTypeContract`, runs the CLR type through the ASP.NET Core schema generator exposed by `OpenApiDocumentTransformerContext` (unchanged behavior). Synthesized extension schema: `{ properties: { code: const, metadata: $ref }, required: [code] }`.
+- For `PortableErrorMetadataSchemaContract`, invokes the factory once per generated metadata component (passing the resolved `OpenApiSpecVersion`), installs the produced schema under the existing metadata-component naming convention (`PortableResultsOpenApiSchemaNaming.CreateMetadataSchemaId(...)`, yielding ids like `PortableError__Count__Metadata` and `PortableValidationErrorDetail__Count__Metadata`), and `$ref`s it from the narrowed code schema. Synthesized extension schema: identical to the type-contract case.
+- For `PortableErrorMetadataNoMetadataContract`, emits the narrowed envelope without a `metadata` property at all. Synthesized extension schema: `{ properties: { code: const }, required: [code] }`. The `metadata` slot inherits from the base schema (open object, nullable), which is faithful to the wire — the runtime simply does not write a `metadata` property for these codes.
+
+The `allOf [base, extension]` envelope construction in `CreateCodeSpecificSchema` is otherwise unchanged across all three contract kinds. They share one component-id namespace, and tools that walk `Components.Schemas` see one rule rather than three.
+
+### Project Structure
+
+The follow-up should respect the current OpenAPI project slices and adds one new project:
+
+- `Light.PortableResults.AspNetCore.OpenApi.ErrorContracts` contains the contract-registration model (`PortableErrorMetadataContract`, its sealed subclasses, builder overloads, options, and registry implementation).
+- `Light.PortableResults.AspNetCore.OpenApi.Generation` contains transformer changes and any internal message helpers needed to materialize schema-based contracts into the document.
+- `Light.PortableResults.AspNetCore.OpenApi.Schemas` continues to hold general reusable schema catalog and naming helpers only; the built-in validation contract catalog itself is **not** placed here because it depends on validation-package types.
+- `Light.PortableResults.Validation` continues to hold validation primitives. This plan adds `ValidationErrorCodes` (compile-time constants only) so the constants are available even when the OpenAPI package is not referenced.
+- `Light.PortableResults.Validation.OpenApi` is a new bridge package that depends on both `Light.PortableResults.Validation` and `Light.PortableResults.AspNetCore.OpenApi`. It hosts `BuiltInValidationErrorContracts` and the `RegisterBuiltInValidationErrors(this PortableErrorMetadataContractsBuilder)` extension. This split keeps `Light.PortableResults.Validation` free of any `Microsoft.OpenApi` reference and respects the layering of OpenAPI as a higher-level concern than core validation.
+
+### Built-In Contract Catalog
+
+`BuiltInValidationErrorContracts.Contracts` is a static readonly `IReadOnlyDictionary` — one entry per built-in code that has a stable framework-level shape. Metadata-bearing codes are stored as `PortableErrorMetadataSchemaContract` instances whose factory authors a fresh `OpenApiSchema` (with `Type = JsonSchemaType.Object`, the exact property keys from `ValidationErrorMetadataKeys`, and `Required` populated to match) on each invocation. No-metadata codes (`NotNull`, `Null`, `NotEmpty`, `Empty`, `NotNullOrWhiteSpace`, `Email`, `DigitsOnly`, `LettersAndDigitsOnly`) are stored as the shared `PortableErrorMetadataContract.NoMetadata` singleton. Examples of the authored shapes for the metadata-bearing codes:
+
+- `Count` → `{ expectedCount: integer }`.
+- `MinCount` → `{ minCount: integer }`.
+- `MaxCount` → `{ maxCount: integer }`.
+- `MinLength` / `MaxLength` → analogous integer properties.
+- `LengthInRange` → `{ minLength: integer, maxLength: integer }`.
+- `EqualTo` / `NotEqualTo` / `GreaterThan` / `GreaterThanOrEqualTo` / `LessThan` / `LessThanOrEqualTo` → `{ comparativeValue: }`.
+- `InRange` / `NotInRange` / `ExclusiveRange` → `{ lowerBoundary: , upperBoundary: }`.
+- `Pattern` → `{ pattern: string, regexOptions: integer }`.
+- `Enum` → `{ enumType: string }`.
+- `EnumName` → `{ enumType: string, ignoreCase: boolean }`.
+- `PrecisionScale` → `{ expectedPrecision: integer, expectedScale: integer, ignoreTrailingZeros: boolean }`.
+
+The `` shape is spec-version-dependent and is produced by a small helper inside `BuiltInValidationErrorContracts`:
+
+- OpenAPI 3.1+:
+ ```text
+ oneOf:
+ - { type: string }
+ - { type: number }
+ - { type: integer }
+ - { type: boolean }
+ - { type: "null" }
+ ```
+- OpenAPI 3.0:
+ ```text
+ oneOf:
+ - { type: string }
+ - { type: number }
+ - { type: integer }
+ - { type: boolean }
+ ```
+ with `nullable: true` on the parent property (`comparativeValue`, `lowerBoundary`, `upperBoundary`). The `null` branch is omitted because OpenAPI 3.0 does not support `type: "null"`.
+
+Although `CreateMetadataValue` distinguishes between `int64`, `double`, and `decimal` at the wire encoding level, OpenAPI collapses the latter two into `number`, so the catalog deliberately does not author a separate `decimal` branch. Tests assert this collapse explicitly so a future contributor does not "fix" it.
+
+### Typed Helpers for Polymorphic Codes
+
+The catalog's polymorphic `oneOf` is the only honest documentation for a code-level contract that is genuinely polymorphic across call sites — but for a given endpoint, the call site usually pins down a concrete `T` (e.g. `IsInBetween(1, 5)` makes both bounds `int`). To let consumers declare that concrete `T` in one line without writing CLR DTO scaffolding by hand, the bridge package ships nine generic record types and a matching set of typed builder extensions.
+
+The records are pre-defined exactly so consumers do not redeclare them per project. The property names match `ValidationErrorMetadataKeys` (`comparativeValue`, `lowerBoundary`, `upperBoundary`) so the schema generator's casing convention emits the wire-correct keys with no further configuration:
+
+```csharp
+namespace Light.PortableResults.Validation.OpenApi;
+
+public sealed record GreaterThanMetadata(T ComparativeValue);
+public sealed record GreaterThanOrEqualToMetadata(T ComparativeValue);
+public sealed record LessThanMetadata(T ComparativeValue);
+public sealed record LessThanOrEqualToMetadata(T ComparativeValue);
+public sealed record EqualToMetadata(T ComparativeValue);
+public sealed record NotEqualToMetadata(T ComparativeValue);
+public sealed record InRangeMetadata(T LowerBoundary, T UpperBoundary);
+public sealed record NotInRangeMetadata(T LowerBoundary, T UpperBoundary);
+public sealed record ExclusiveRangeMetadata(T LowerBoundary, T UpperBoundary);
+```
+
+The builder extensions wrap the existing inline `WithErrorMetadata(string code)` escape hatch so the transformer needs no new code path:
+
+```csharp
+public static class BuiltInValidationErrorBuilderExtensions
+{
+ public static PortableValidationProblemOpenApiBuilder WithInRangeError(
+ this PortableValidationProblemOpenApiBuilder builder) =>
+ builder.WithErrorMetadata>(ValidationErrorCodes.InRange);
+
+ public static PortableProblemOpenApiBuilder WithInRangeError(
+ this PortableProblemOpenApiBuilder builder) =>
+ builder.WithErrorMetadata>(ValidationErrorCodes.InRange);
+
+ // ...the equality, greater/less, not-in-range, and exclusive-range variants follow the same shape.
+}
+```
+
+Each helper is shipped on both the problem builder and the validation-problem builder so the same narrowing works whether the endpoint emits `application/problem+json` as a generic problem or as a validation problem.
+
+The endpoint from the design discussion becomes:
+
+```csharp
+app.MapPost("/movies/{id}/ratings", ...)
+ .WithName("CreateRating")
+ .ProducesPortableValidationProblem(b => b
+ .WithErrorCodes(ValidationErrorCodes.NotEmpty, ValidationErrorCodes.LengthInRange)
+ .WithInRangeError