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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@
<Authors>Kenny Pflug</Authors>
<Company>Kenny Pflug</Company>
<Copyright>Copyright (c) 2026 Kenny Pflug</Copyright>
<Version>0.6.0</Version>
<Version>0.7.0</Version>
</PropertyGroup>
</Project>
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*The Result Pattern for .NET that travels. Every `Result<T>` serializes reliably over HTTP (with RFC-9457 Problem Details support), CloudEvents, and back — with a validation framework that is at least 5x faster and uses less than 9% of the memory of FluentValidation.*

[![License](https://img.shields.io/badge/License-MIT-green.svg?style=for-the-badge)](https://github.com/feO2x/Light.PortableResults/blob/main/LICENSE)
[![NuGet](https://img.shields.io/badge/NuGet-0.6.0-blue.svg?style=for-the-badge)](https://www.nuget.org/packages?q=Light.PortableResults)
[![NuGet](https://img.shields.io/badge/NuGet-0.7.0-blue.svg?style=for-the-badge)](https://www.nuget.org/packages?q=Light.PortableResults)
[![Documentation](https://img.shields.io/badge/Docs-Changelog-yellowgreen.svg?style=for-the-badge)](https://github.com/feO2x/Light.PortableResults/releases)

Most Result Pattern libraries stop at the application boundary. Light.PortableResults does not: a `Result<T>` can be written as an HTTP response (including RFC-9457 Problem Details support), published as a CloudEvents JSON message, read back from both protocols on the other side, and arrive as a fully-typed `Result<T>` — without losing errors, metadata, or structure. If you also need validation, the built-in framework lets you write FluentValidation-style rules with a fraction of the allocations. Plus: Roslyn Source Generators write OpenAPI error schemas and examples for you.
Expand Down
113 changes: 113 additions & 0 deletions ai-plans/0052-decimal-metadata-kind.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Decimal Metadata Kind

## Rationale

`MetadataValue.FromDecimal` converts its input to invariant text and stores `MetadataKind.String`. Decimals therefore serialize into JSON bodies as quoted strings, while `PortableOpenApiSchemaTypeMapper` maps `decimal` to `JsonSchemaType.Number` and `PortableResultsOpenApiDocumentTransformer` emits decimal examples as unquoted numbers. The published contract and the runtime payload disagree. The mismatch is reachable through the built-in validation error definitions: `CreateMetadataValue<T>` routes every `TypeCode.Decimal` parameter through `FromDecimal`, and it backs the `ComparativeValue`, `LowerBoundary`, and `UpperBoundary` metadata keys. Any comparison or range check on a decimal-typed value — `IsGreaterThan(19.99m)`, `IsInRange(9.99m, 99.99m)` — therefore produces a `problem+json` body that violates the OpenAPI document generated for the same endpoint.

Storing decimals as text also loses type information (a decimal is indistinguishable from a numeric-looking string), forces `TryGetDecimal` to run `decimal.TryParse` on every call, and allocates more than necessary. This plan introduces a dedicated `MetadataKind.Decimal` backed by a boxed `decimal`.

## Acceptance Criteria

- [x] `MetadataValue.FromDecimal` produces a value whose `Kind` is `MetadataKind.Decimal`.
- [x] A decimal metadata value is written into JSON bodies as an unquoted JSON number that preserves all significant digits and the original scale.
- [x] A `problem+json` body produced by a comparison or range validation rule on a decimal-typed value conforms to the OpenAPI document generated for the same endpoint, asserted by an integration test that inspects the raw response body.
- [x] `MetadataKind.Decimal` is classified as primitive, so decimals remain valid inside arrays annotated for header serialization and valid as CloudEvents extension attributes.
- [x] Every declared `MetadataKind` member is asserted to be classified correctly as primitive or complex by a test that enumerates the enum, so a member declared outside the reserved primitive range fails the build.
- [x] `TryGetDecimal` returns the stored value for `MetadataKind.Decimal` without parsing text, and continues to convert from `Int64`, `Double`, and numeric strings.
- [x] `TryGetString` returns `false` for a decimal metadata value.
- [x] `MetadataValue.ToString()` renders decimals as unquoted invariant-culture numeric text.
- [x] A decimal metadata value resolves correctly when used as a CloudEvents core string attribute instead of silently becoming `null`.
- [x] HTTP header formatting emits decimals without quote characters.
- [x] The JSON reader's treatment of numeric tokens is explicitly specified and covered by tests, including the documented cases where a decimal does not read back as `MetadataKind.Decimal`.
- [x] A test pins `Unsafe.SizeOf<MetadataValue>()` to the value it has before this plan, so any future change to the payload layout has to be deliberate.
- [x] Test code coverage stays above 95%.

## Technical Details

### Storage

`MetadataKind.Decimal` stores a **boxed** `decimal` in the existing `Reference` slot of `MetadataPayload` at offset 8. An inline 128-bit field would push `Reference` to offset 16 and grow every `MetadataValue` by 8 bytes, including array elements stored inline in `MetadataArrayData` — an unacceptable cost in a library whose primary claim is reduced allocation. Boxing costs 24 bytes on x64, less than the 32–56 bytes the current string representation typically occupies, and removes the parse on every read.

`double` remains the representation for general floating-point numbers. It covers the full JSON numeric range, where `decimal` is limited to ±7.9e28 and could not represent legitimate inbound values such as `1e100`.

### Enum ordering is a hard constraint

`MetadataKindExtensions.IsPrimitive` is implemented as `kind < MetadataKind.Array`, so membership of the primitive set is decided purely by ordering. Adding `Decimal` after `Object` compiles cleanly and silently classifies decimals as complex values: they would be rejected as CloudEvents extension attributes, rejected inside arrays annotated for header serialization, and would flip `HasOnlyPrimitiveChildren` to `false` on any containing array or object. No compiler diagnostic catches this.

`Decimal` is therefore appended to the primitive block as `5`, and the complex kinds move to a reserved range:

```csharp
public enum MetadataKind : byte
{
Null = 0, Boolean = 1, Int64 = 2, Double = 3, String = 4, Decimal = 5,
// 6-199 are reserved for future primitive kinds
Array = 200,
Object = 201
}
```

The gap exists so that later primitives can be added without renumbering the complex kinds a second time. This is not speculative: CloudEvents defines `Binary`, `URI`, `URI-reference`, and `Timestamp` as attribute types, all of which are currently flattened into `String`. The values are renumbered in this change because `Array` and `Object` move regardless, making the reservation free now and a separate breaking change later. It also settles the numbering before any gRPC mapping can make it wire-visible.

The reserved range must be documented on the enum itself, and `IsPrimitive` must keep the boundary comparison rather than switching to an enumerated list — the comparison is a single instruction on a path used by every array and object construction.

A gap alone does not enforce the invariant. `IsPrimitive` currently has no direct test coverage at all, so the ordering constraint is unguarded. A test must iterate `Enum.GetValues<MetadataKind>()` and assert each member against an explicit expected classification, so that a future member declared on the wrong side of the boundary fails immediately rather than degrading silently.

### Read side: an explicit non-guarantee

A JSON number carries no discriminator between a decimal and a double, so the reader must choose. Two candidate behaviours exist, and the choice must be recorded rather than implied:

- **Default:** `SharedJsonSerialization.Reading.MetadataJsonReader.ReadNumber` keeps its current `Int64`-then-`Double` behaviour and never produces `MetadataKind.Decimal`. `Http.Reading.Json.MetadataJsonReader` delegates to it, so this is a single site.
- **Opt-in:** a reader option, following the precedent of `HeaderValueParsingMode`, that prefers `Decimal` for numeric tokens outside `Int64` range that fit in `decimal`.

The default is chosen. Preferring `Decimal` unconditionally would make the resulting kind depend on the *magnitude* of the value and would break round-tripping in the opposite direction, with `FromDouble(0.1)` reading back as `Decimal`.

This plan therefore improves **outbound** fidelity. It deliberately does not claim that a decimal round-trips as a decimal; that limitation is inherent to untyped numeric wire formats and is the same constraint recorded for HTTP headers in `0051`. Acceptance criteria must not assert round-trip symmetry for decimals.

### Affected components: there is no compile-time safety net

Every site that dispatches on `MetadataKind` has a `default` arm or a silent fall-through, so **adding the enum member breaks nothing at compile time**. A partial implementation ships silent data corruption rather than a build error. Each of these must be updated deliberately:

| Site | Behaviour if left unhandled | Detected |
| --- | --- | --- |
| `SharedJsonSerialization/Writing/MetadataExtensions.WriteMetadataValue` | `default:` writes `WriteNullValue()` — a decimal serializes as JSON `null` | Silent |
| `MetadataValue.Equals` | `_ => false` — a decimal never equals another decimal | Silent |
| `MetadataValue.GetHashCode` | no `default` and no case — every decimal hashes to the kind alone | Silent |
| `CloudEventsResultExtensions.GetStringAttribute` | `if`-chain falls through to `null` — a decimal-valued `subject`, `type`, or `source` becomes `null` | Silent |
| `CloudEvents/MetadataValueAnnotationHelper.WithAnnotation` | `default:` throws `ArgumentOutOfRangeException` | Runtime |
| `MetadataValue.ToString()` | `_ =>` throws `InvalidOperationException` | Runtime |

Two of these are worse than the defect being fixed. `WriteNullValue()` replaces a quoted string with `null`, losing the value entirely. `Equals` returning `false` alongside a kind-only hash breaks `MetadataObject` and dictionary lookups for decimals without any error surfacing.

Everything else reaches decimals through `IsPrimitive` or the `TryGet*` accessors and needs no change.

### Equality and hashing

`MetadataKind.Decimal` joins the existing `String or Array or Object` group in `GetHashCode`, since a boxed `decimal` hashes correctly through `Reference.GetHashCode()`. `Equals` needs a branch that unboxes and compares as `decimal` rather than comparing references.

This changes observable behaviour: `decimal.Equals` and `decimal.GetHashCode` are scale-insensitive and mutually consistent, so `19.50m` and `19.5m` become equal metadata values. Under the current string storage they compare as `"19.50"` and `"19.5"` and are *not* equal. Scale is still preserved for serialization — only equality changes.

### Breaking changes

The library is pre-1.0 and breaking changes are permitted, but these are silent at compile time for downstream callers and must be listed in the package release notes:

- `TryGetString` no longer returns `true` for decimals.
- `Kind` for a decimal is no longer `MetadataKind.String`.
- The numeric values of `MetadataKind.Array` and `MetadataKind.Object` change to `200` and `201`. No code in the solution casts `MetadataKind` to a numeric type and the enum is not exposed by the OpenAPI, Validation, or source-generation packages, so this is invisible today — but any consumer that persisted or transmitted the numeric value is affected.
- Decimal metadata appears as a JSON number rather than a JSON string in serialized bodies.
- Decimals differing only in trailing zeros now compare equal.

### Sequencing with #51

This plan should land before `0051-http-header-value-formatting`. That plan's kind table currently folds decimals into the `String` row; landing it first would require amending both the table and its test matrix immediately afterwards. With this plan first, `0051` gains a `Decimal` row from the outset — invariant numeric text, unquoted, identical in shape to the `Double` row.

### Tests

`MetadataValueTests.FromDecimal_ShouldStoreAsString` inverts and must be renamed alongside its assertions. `MetadataObjectTests` and `MetadataValueAnnotationTests` also construct decimal values and need review.

Because no dispatch site fails to compile, the kind matrix must be exercised explicitly for every site in the table above rather than relying on the build. Equality and hashing in particular need a test that puts decimals into a `MetadataObject` and reads them back, since the failure there is silent in both directions.

Beyond the unit-level matrix, the criterion that carries the actual defect is the OpenAPI conformance test: trigger a comparison or range validation failure on a decimal-typed value through an integration test app and assert that the raw `problem+json` body carries an unquoted number for the `ComparativeValue` or boundary metadata.

### Out of scope

Making decimals round-trip as decimals, and the opt-in reader mode described above. Both are follow-up work once the outbound representation is correct.
20 changes: 10 additions & 10 deletions samples/NativeAotMovieRating/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,33 +142,33 @@
"light.portableresults.aspnetcore.minimalapis": {
"type": "Project",
"dependencies": {
"Light.PortableResults.AspNetCore.Shared": "[0.6.0, )"
"Light.PortableResults.AspNetCore.Shared": "[0.7.0, )"
}
},
"light.portableresults.aspnetcore.openapi": {
"type": "Project",
"dependencies": {
"Light.PortableResults.AspNetCore.Shared": "[0.6.0, )",
"Light.PortableResults.AspNetCore.Shared": "[0.7.0, )",
"Microsoft.AspNetCore.OpenApi": "[10.0.10, )"
}
},
"light.portableresults.aspnetcore.shared": {
"type": "Project",
"dependencies": {
"Light.PortableResults": "[0.6.0, )"
"Light.PortableResults": "[0.7.0, )"
}
},
"light.portableresults.validation": {
"type": "Project",
"dependencies": {
"Light.PortableResults": "[0.6.0, )"
"Light.PortableResults": "[0.7.0, )"
}
},
"light.portableresults.validation.openapi": {
"type": "Project",
"dependencies": {
"Light.PortableResults.AspNetCore.OpenApi": "[0.6.0, )",
"Light.PortableResults.Validation": "[0.6.0, )"
"Light.PortableResults.AspNetCore.OpenApi": "[0.7.0, )",
"Light.PortableResults.Validation": "[0.7.0, )"
}
},
"Microsoft.Bcl.HashCode": {
Expand All @@ -190,20 +190,20 @@
"contentHash": "V6crLJ8a29raWeNwxYGfH9RTKA3H0nR0D9LAGzN3KtEsbiiaWkUjDor6OT5Oz7pxCK+NaY2hu2FLoYEOa8oCkA=="
}
},
"net10.0/linux-x64": {
"net10.0/osx-arm64": {
"Microsoft.DotNet.ILCompiler": {
"type": "Direct",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "tnG8ntt/Bk6odvHREnGLMo3PEiihy5iSlIFVp0JbIo00GKtNRt2k73eKZbPqR5yaJNIa3z8R86YLwbxfqpb17g==",
"dependencies": {
"runtime.linux-x64.Microsoft.DotNet.ILCompiler": "10.0.10"
"runtime.osx-arm64.Microsoft.DotNet.ILCompiler": "10.0.10"
}
},
"runtime.linux-x64.Microsoft.DotNet.ILCompiler": {
"runtime.osx-arm64.Microsoft.DotNet.ILCompiler": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "WRjSRBfv6A6UjgjO8EQuLe9xqdICpkQx1hACUziCw4B2uGL+2jVhkFLq/G7rxRr3MGvqLo9B+nNdfIJ/5CYN7A=="
"contentHash": "cY7edFqVviQMiSvPodJeLZhF6k56grh0QsvcR+2foAUgO0xXsMsVBQiHcZkeiIbhr+SPIklq90OZiJ7JVrc+Dg=="
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<IsAotCompatible>true</IsAotCompatible>
<Description>Integration package for turning result instances into Minimal API's IResult instances. Compatible with Native AOT. Compatible with RFC 9457 (and RFC 7807) Problem Details responses.</Description>
<PackageReleaseNotes>
Light.PortableResults.AspNetCore.MinimalApis 0.6.0
Light.PortableResults.AspNetCore.MinimalApis 0.7.0
---------------------------------

- LightResult and LightResult&lt;T> and corresponding extension methods to turn result instances into HTTP success responses or RFC 9457 (and RFC 7807) compatible Problem Details responses.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<PropertyGroup>
<Description>Integration package for turning result instances into MVC's IActionResult instances. Compatible with RFC 9457 (and RFC 7807) Problem Details responses.</Description>
<PackageReleaseNotes>
Light.PortableResults.AspNetCore.MVC 0.6.0
Light.PortableResults.AspNetCore.MVC 0.7.0
---------------------------------

- LightActionResult and LightActionResult&lt;T> and corresponding extension methods to turn result instances into HTTP success responses or RFC 9457 (and RFC 7807) compatible Problem Details responses.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<IsAotCompatible>true</IsAotCompatible>
<Description>Opt-in OpenAPI integration package for Light.PortableResults ASP.NET Core applications. Provides a library-authored schema catalog, endpoint metadata attributes, Minimal API helpers, and a document transformer.</Description>
<PackageReleaseNotes>
Light.PortableResults.AspNetCore.OpenApi 0.6.0
Light.PortableResults.AspNetCore.OpenApi 0.7.0
-------------------------------------

- Opt-in OpenAPI integration via IServiceCollection.AddPortableResultsOpenApi.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<IsAotCompatible>true</IsAotCompatible>
<Description>The Light.PortableResults.AspNetCore.Shared package contains shared functionality for writing ASP.NET Core HTTP responses. Compatible with Native AOT. Check out the integration packages Light.PortableResults.AspNetCore.MinimalApis or Light.PortableResults.AspNetCore.Mvc.</Description>
<PackageReleaseNotes>
Light.PortableResults.AspNetCore.Shared 0.6.0
Light.PortableResults.AspNetCore.Shared 0.7.0
---------------------------------

- Result enrichment with ASP.NET Core's HttpContext.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<IsAotCompatible>true</IsAotCompatible>
<Description>OpenAPI bridge package for Light.PortableResults.Validation. Provides built-in validation error metadata contracts and typed helpers for endpoint-specific validation error narrowing.</Description>
<PackageReleaseNotes>
Light.PortableResults.Validation.OpenApi 0.6.0
Light.PortableResults.Validation.OpenApi 0.7.0
----------------------------------------

- Built-in OpenAPI metadata contracts for Light.PortableResults.Validation error codes.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<PublishAot>false</PublishAot>
<Description>Framework-agnostic validation foundations for Light.PortableResults, including validation contexts, low-allocation checks, validator base classes, and validated value pipelines.</Description>
<PackageReleaseNotes>
Light.PortableResults.Validation 0.6.0
Light.PortableResults.Validation 0.7.0
---------------------------------

- Validation foundations with validation contexts, checks, validated value pipelines, and sync/async validator base classes.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ public static MetadataValue WithAnnotation(MetadataValue value, MetadataValueAnn
case MetadataKind.String:
value.TryGetString(out var stringValue);
return MetadataValue.FromString(stringValue, annotation);
case MetadataKind.Decimal:
value.TryGetDecimal(out var decimalValue);
return MetadataValue.FromDecimal(decimalValue, annotation);
case MetadataKind.Array:
value.TryGetArray(out var arrayValue);
return MetadataValue.FromArray(WithAnnotation(arrayValue, annotation), annotation);
Expand Down
Loading
Loading