Skip to content

Decimal Metadata Kind - #54

Merged
feO2x merged 8 commits into
mainfrom
52-decimal-metadata-kind
Jul 25, 2026
Merged

Decimal Metadata Kind#54
feO2x merged 8 commits into
mainfrom
52-decimal-metadata-kind

Conversation

@feO2x

@feO2x feO2x commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Closes #52.

Implemented as stated in the corresponding issue, following ai-plans/0052-decimal-metadata-kind.md.

MetadataValue.FromDecimal used to convert its input to invariant text and store MetadataKind.String, so decimals serialized into JSON bodies as quoted strings while the OpenAPI document generated for the same endpoint described them as numbers. Any IsGreaterThan(19.99m) or IsInRange(9.99m, 99.99m) check therefore produced a problem+json body that violated its own published contract. This introduces a dedicated MetadataKind.Decimal backed by a boxed decimal.

What changed

  • MetadataKind.Decimal = 5, appended to the primitive block. IsPrimitive is kind < MetadataKind.Array, so membership is decided purely by ordering — declaring Decimal after Object would have compiled cleanly and silently classified decimals as complex. Array and Object move to 200/201, reserving 6-199 for future primitive kinds (CloudEvents' Binary, URI, URI-reference, and Timestamp are all flattened into String today).
  • Boxed storage in the existing Reference slot. An inline 128-bit field would push Reference to offset 16 and grow every MetadataValue by 8 bytes, including elements stored inline in arrays and objects. Unsafe.SizeOf<MetadataValue>() is pinned at its pre-existing 24 bytes so a future layout change has to be deliberate.
  • Every MetadataKind dispatch site updated. All six have a default arm or a silent fall-through, so adding the member broke nothing at compile time: WriteMetadataValue would have written JSON null, Equals would have made decimals unequal to themselves, GetHashCode would have hashed the kind alone, and a decimal-valued CloudEvents subject would have become null. Each is now handled and covered by a test.
  • Equality delegates to decimal, which is scale-insensitive and consistent with decimal.GetHashCode.
  • Reader behaviour is specified rather than implied. A JSON number carries no discriminator between a decimal and a double, so the reader keeps its Int64-then-Double behaviour and never produces MetadataKind.Decimal. This is documented on both MetadataJsonReader types and pinned by tests. Preferring Decimal would make the resulting kind depend on the magnitude of the value and would break round-tripping in the opposite direction.

Breaking changes

Pre-1.0, and all of these are silent at compile time for downstream callers. They are listed in PackageReleaseNotes:

  • TryGetString no longer returns true for decimals; Kind is no longer MetadataKind.String.
  • Decimal metadata appears as a JSON number rather than a JSON string in serialized bodies.
  • Decimals differing only in trailing zeros (19.50m, 19.5m) now compare equal. Scale is still preserved for serialization.
  • The numeric values of MetadataKind.Array and MetadataKind.Object change to 200 and 201. Nothing in the solution casts MetadataKind to a numeric type and it is not exposed by the OpenAPI, Validation, or source-generation packages, but any consumer that persisted or transmitted the numeric value is affected.

Verification

  • Release build clean, 0 warnings with TreatWarningsAsErrors.
  • 2235 tests pass.
  • Merged line coverage 95.4% (branch 89.3%), measured through the same reportgenerator path CI uses.
  • The criterion that carries the actual defect is an integration test that triggers comparison and range failures on decimal-typed values, then asserts the raw problem+json body carries unquoted numbers and that the generated OpenAPI document types the same properties as number.

The last commit applies review findings: the decimal arms of Equals and ToString now match the payload with a pattern instead of an unchecked cast, two MetadataKind tests that could not fail were replaced by a pin on every member's numeric value, and the size pin asserts process bitness before its 64-bit literal.

Follow-up

#53 — writing extension attributes as JSON numbers is outside the CloudEvents type system, which is closed and has no fractional type. This predates the PR (Double and out-of-int32-range Int64 behave the same way); it is not a regression from this change, but Decimal did move from the accidentally conformant column into the same bucket.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DNvxUqG5x5wF3d1vfzx3kj

feO2x and others added 6 commits July 25, 2026 12:02
Addresses #52.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAYimZ1A2VK5zvEbK26kQa
Move Array and Object to 100/101 so later primitive kinds can be added
without renumbering the complex kinds again, and require a test that
enumerates MetadataKind to guard the IsPrimitive ordering invariant.

Addresses #52.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAYimZ1A2VK5zvEbK26kQa
Addresses #52.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAYimZ1A2VK5zvEbK26kQa
Every MetadataKind dispatch site has a default arm or silent
fall-through, so adding the enum member breaks nothing at compile time.
Two sites degrade worse than the defect being fixed. Replace the
"fails to compile" claim with a failure-mode table.

Precision and scale metadata are ints, not decimals. The decimal path
is CreateMetadataValue<T> behind ComparativeValue, LowerBoundary and
UpperBoundary, so retarget the reachability example and the
integration test at comparison and range checks.

Addresses #52.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAYimZ1A2VK5zvEbK26kQa
Signed-off-by: Kenny Pflug <kenny.pflug@live.de>
Equals and ToString unboxed the decimal payload with an unchecked cast
while every neighbouring arm uses a safe cast. Match the reference with
a pattern instead, so a decimal kind whose payload carries no boxed
decimal is unequal and throws InvalidOperationException from ToString
rather than NullReferenceException. The fallback message now covers
both an unknown kind and an invalid payload.

Two of the four MetadataKind tests could not fail: IsPrimitive is
kind < Array and Array is 200, so asserting that primitives are below
200 was true by construction. Replace them with a pin on the numeric
value of every declared member, which guards the reserved 6-199 range
and the wire-visible numbering in one place.

Assert the process bitness before the pinned Unsafe.SizeOf value, so a
32-bit run reports the assumption instead of an unexplained size.

Addresses #52.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DNvxUqG5x5wF3d1vfzx3kj
@feO2x feO2x self-assigned this Jul 25, 2026
@feO2x feO2x added the bug Something isn't working label Jul 25, 2026
feO2x and others added 2 commits July 26, 2026 00:10
Signed-off-by: Kenny Pflug <kenny.pflug@live.de>
The package release notes carried the breaking changes of this branch
under the heading of 0.6.0, which was tagged and published in May.
Move all package headings, the Version property, and the README badge
to 0.7.0 - pre-1.0, a breaking change bumps the minor version.

Also correct the comment on the decimal branch of GetStringAttribute.
The checks above it are strict kind checks that a decimal cannot reach,
so the explicit kind check guards against the branch being moved up
rather than against the current order.

Addresses #52.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DNvxUqG5x5wF3d1vfzx3kj
@github-actions

Copy link
Copy Markdown

Code Coverage

Package Line Rate Branch Rate Complexity Health
Light.PortableResults 97% 94% 2317
Light.PortableResults.AspNetCore.MinimalApis 89% 75% 25
Light.PortableResults.AspNetCore.Mvc 89% 75% 25
Light.PortableResults.AspNetCore.OpenApi 93% 80% 494
Light.PortableResults.AspNetCore.Shared 100% 100% 26
Light.PortableResults.Validation 97% 89% 2886
Light.PortableResults.Validation.OpenApi 98% 91% 146
Light.PortableResults.Validation.OpenApi.SourceGeneration 88% 83% 778
Summary 95% (11940 / 12505) 89% (5058 / 5660) 6697

Minimum allowed line rate is 60%

@feO2x
feO2x merged commit 1dd1a77 into main Jul 25, 2026
2 checks passed
@feO2x
feO2x deleted the 52-decimal-metadata-kind branch July 25, 2026 22:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Decimal metadata values are stored as strings and serialize as JSON strings, contradicting the generated OpenAPI schema

1 participant