Skip to content

MetadataKind Restructuring - #56

Merged
feO2x merged 15 commits into
mainfrom
54-metadatakind-restructuring
Jul 29, 2026
Merged

MetadataKind Restructuring#56
feO2x merged 15 commits into
mainfrom
54-metadatakind-restructuring

Conversation

@feO2x

@feO2x feO2x commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Closes #55

This document compares ai-plans/0055-metadata-restructuring.md with the implementation of the typed metadata vocabulary.

ai-plans/0054-1-metadata-round-trip-envelope.md is not part of this comparison. It is a follow-up plan that has not been implemented yet, and I'm not sure if this is useful.

Summary

The plan was implemented as specified. Every kind in the vocabulary table exists, the shape and canonical-text derivations are in place, the exhaustive switches fail the Release build when a kind is added, and the public API differs between netstandard2.0 and net10.0 only in the DateOnly/TimeOnly members.

Four decisions in the plan were revised during the review that followed implementation, because they specified behavior that turned out to be wrong rather than merely inconvenient. Three of them (1, 3, 4) were reached through the plan's own reasoning and are corrections to it; the fourth was a pre-existing defect the plan would have frozen into a criterion.

Deviations From The Original Plan

1. DateTime accepts DateTimeKind.Unspecified and stores ToBinary() instead of UTC ticks

Original plan:
The vocabulary table specified the DateTime payload as inline UTC ticks and its encoding as "RFC 3339 date-time, Z suffix". The Vocabulary notes stated: "FromDateTime converts Local to UTC and throws for DateTimeKind.Unspecified."

Implemented:
FromDateTime converts Local to UTC and accepts Unspecified as it is. The payload stores DateTime.ToBinary(), which packs the kind into the two spare high bits of the tick count — the same 8-byte Int64 slot raw ticks occupied, with no loss of precision and no growth of the struct. Only Utc and Unspecified are ever stored, so decoding is deterministic; a Local result from FromBinary is treated as a corrupt payload, alongside the out-of-range case, because FromBinary would otherwise resolve it against the reading machine's time zone.

An Unspecified value renders without a designator (2026-07-26T13:45:30).

Why:
Unspecified is what every new DateTime(...) literal and every zone-less DateTime.Parse produces, so it is the common case for a validation boundary rather than an edge case. Combined with the criterion routing the ten BCL types to the typed factories, throwing turned the kind of an ordinary literal into an exception in CreateMetadataValue, ValidationErrorMessageFormatting, and OpenAPI example generation alike: a validation comparison against an Unspecified boundary threw instead of producing a validation error, and the failure surfaced as a 500 on the documentation endpoint with no compile-time signal to the author.

Impact:
The suffix-less rendering is valid ISO 8601 local time but not RFC 3339, which makes the offset mandatory, so an Unspecified value does not satisfy the format: date-time the schema mapper emits for System.DateTime. This is accepted deliberately: the alternative is inventing a Z the caller never asserted, which is the silent-wrong-data failure mode the plan exists to remove. The one encoding is used at every site — JSON bodies, headers, CloudEvents attributes, message text, OpenAPI examples — so the document never disagrees with the payload.

Two consequences worth knowing: an Unspecified and a Utc value with the same wall clock are not equal, because the kind is part of the payload; and steering callers toward Utc at API boundaries is left to a future analyzer diagnostic rather than a runtime throw.

2. CloudEvents core string attributes treat Null as absent

Original plan:
An acceptance criterion required that "a value of any primitive kind resolves as a core string attribute."

Implemented:
GetStringAttribute resolves a value of any primitive kind except Null, which resolves to no attribute at all.

Why:
Null is a primitive kind whose canonical text is "null", so the letter of the criterion made an explicitly null attribute resolve to the four-character string. A source of "null" passed the required-attribute check and shipped an invalid event; a time of "null" failed RFC 3339 parsing instead of falling back to the current timestamp.

Impact:
This is a defect fix, not a design change. The behavior before this plan was already to treat Null as absent — the pre-plan implementation walked TryGetString → TryGetBoolean → TryGetInt64 → TryGetDouble → Kind == Decimal, all of which miss Null — and the criterion would have frozen the regression that the unified canonical-text path introduced. A test asserting (FromNull, "null") was removed with it.

Note that DefaultHttpHeaderConversionService still emits "null" for MetadataValue.Null. That is pre-existing behavior tracked separately in #51 and was deliberately left alone here.

3. TryGetDateTimeOffset accepts both zero-offset designators

Original plan:
The Accessors section specified that each TryGet* "converts from MetadataKind.String when the string holds the kind's canonical encoding and rejects any other text", where the canonical encoding for DateTimeOffset is the offset form its own writer produces (+00:00 for a zero offset).

Implemented:
TryGetDateTimeOffset additionally accepts the Z designator for a zero offset, although the writer never produces it. Text carrying no designator at all stays rejected.

Why:
Strictly matching the writer defeats the purpose of the lenient string path. Z is what RFC 3339 emitters produce almost everywhere, including this library's own DateTime kind, so it is the form a wire-degraded value most often arrives in. Rejecting it meant the accessor failed on the majority of real-world timestamps.

Impact:
The relaxation is bounded to the zero-offset case and does not admit ambiguity: the two accepted forms denote the same instant. Text without any designator remains rejected, because it is not a point in time — resolving it against the reader's local offset would make the same text mean different instants on different hosts. The symmetric rule on the other side is that TryGetDateTime rejects text carrying a numeric offset, for the same reason.

4. Int64 and Decimal are written with the JSON writer's own number formatting

Original plan:
The Dispatch safety section specified: "The JSON writers switch on the six shapes; the Number arm dispatches over Int64/Double/Single/Decimal only."

Implemented:
Only Double and Single take the canonical-text WriteRawValue path. Int64 and Decimal use Utf8JsonWriter's native WriteNumberValue overloads. The dispatch is driven by a new public MetadataNumberEncoding enum (None, Int64, Double, Single, Decimal) and a GetNumberEncoding() extension method sitting next to GetJsonShape().

Why:
Two reasons, one performance and one structural.

Routing all four kinds through the canonical text cost a string allocation and a JSON validation pass per value on Int64 and Decimal, where the writer's own formatting is byte-identical anyway. Only Double and Single need the raw path, because only they carry the trailing .0 that keeps a whole-number token from reading back as an Int64. Measured: 48 B → 0 B per value for Int64 and Decimal; byte-identity against the native overloads was verified across 19 edge cases.

Structurally, the kind list guarding that arm was the last dispatch in the chain that was not compiler-checked, which contradicts the section it lives in. Re-deriving the shape inside the arm would be circular — the check is already inside the Number arm and can never fail — and a switch statement over the kind gets no exhaustiveness diagnostic, so the classification has to be a value-returning switch to be checked at all. That is what earns the enum rather than a private helper: both classifications now fail the Release build together when a kind is added, and a test pins them against each other so a Number shape without an encoding cannot ship.

Impact:
MetadataNumberEncoding is public API that the plan did not anticipate. It is documented as the companion to MetadataJsonShape for serializers of protocols other than JSON, which need the same "which primitive is this written from" answer.

Known Gaps

These were identified in the same review and deliberately not addressed on this branch:

  • TryFormatCanonical still allocates. It calls ToCanonicalString() and copies into the destination, so the span overload the plan describes does not yet deliver the allocation-free path its shape implies. An honest fix needs per-kind span formatting, a hand-rolled ISO 8601 duration formatter (XmlConvert.ToString(TimeSpan) has no span overload), and a netstandard2.0 fork (double.TryFormat does not exist there).
  • Temporal validation boundaries produce degraded OpenAPI examples. A DateTime boundary cannot be folded by SemanticModel.GetConstantValue, so the generated error example carries neither a message nor a comparativeValue. This is pre-existing and is tracked in Validation boundaries of non-constant types produce OpenAPI error examples without a message or comparativeValue #57. The DateTime/DateTimeOffset/TimeSpan/Guid/Uri arms added to ValidatorOpenApiEmitter.ToLiteral are unreachable through the analyzer until that issue is fixed, and are kept deliberately as its landing site.

feO2x added 5 commits July 27, 2026 07:42
Signed-off-by: Kenny Pflug <kenny.pflug@live.de>
Signed-off-by: Kenny Pflug <kenny.pflug@live.de>
Signed-off-by: Kenny Pflug <kenny.pflug@live.de>
…ted plan

Signed-off-by: Kenny Pflug <kenny.pflug@live.de>
Add canonical typed metadata support across serialization, validation, CloudEvents, HTTP headers, OpenAPI, and source generation. Multi-target the core library and cover the new vocabulary with conformance tests.
@feO2x feO2x self-assigned this Jul 28, 2026
@feO2x feO2x added the enhancement New feature or request label Jul 28, 2026
feO2x and others added 10 commits July 28, 2026 19:51
Signed-off-by: Kenny Pflug <kenny.pflug@live.de>
Signed-off-by: Kenny Pflug <kenny.pflug@live.de>
…ata system

Signed-off-by: Kenny Pflug <kenny.pflug@live.de>
Signed-off-by: Kenny Pflug <kenny.pflug@live.de>
The JSON number arm routed all four numeric kinds through the canonical
text and WriteRawValue, costing a string allocation and a JSON validation
pass per value on the most common metadata kinds. Int64 and Decimal now
use Utf8JsonWriter's own number formatting, which is byte-identical and
allocation-free; only Double and Single keep the raw path, because only
they carry the trailing ".0" that stops a whole number from reading back
as an Int64. That path now skips input validation, since the text comes
from our own formatter and non-finite values are rejected at construction.

The kind list guarding that arm was also the last dispatch in the chain
that was not compiler-checked, and re-deriving the JSON shape inside the
arm would be circular. MetadataKindExtensions.GetNumberEncoding replaces
the list with an exhaustive switch expression over MetadataKind, so a kind
added with the Number shape but no encoding fails the Release build next
to GetJsonShape instead of throwing at runtime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RtfndM4yhWgCtShY4trph5
Signed-off-by: Kenny Pflug <kenny.pflug@live.de>
The accessor only accepted the +00:00 designator that its own writer
produces, so every Z-suffixed RFC 3339 timestamp failed to read back -
including the canonical encoding of this library's own DateTime kind,
which is the form a wire-degraded value most often arrives in. Both
zero-offset designators are now accepted.

Text without any offset stays rejected: it is not a point in time, and
resolving it against the reading machine's local offset would make the
same text mean different instants on different hosts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RtfndM4yhWgCtShY4trph5
Signed-off-by: Kenny Pflug <kenny.pflug@live.de>
The mapper test only covered the types that have a MetadataKind of their
own, leaving int, short, byte, sbyte, uint and ushort unpinned - the side
of the ulong split that stayed on the integer schema. It also never
exercised Map<T>(), which is the overload generated validator code calls,
and which the validation source generator relies on instead of keeping a
schema table of its own. That makes this test the only guard on the
normative schema column, so the gaps mattered more than their size
suggested.

The expectations are now keyed on MetadataKind rather than a hand-kept
list of types, and a test asserts that every declared kind has either a
schema row or a deliberate exclusion. Declaring a kind therefore fails a
test that names it, alongside the switch expressions that already fail the
Release build. The smaller integer types assert against the Int64 row
rather than a literal, so they cannot drift from the kind they widen into.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RtfndM4yhWgCtShY4trph5
Signed-off-by: Kenny Pflug <kenny.pflug@live.de>
Four decisions in plan 0055 were revised during the post-implementation
review: DateTime accepting DateTimeKind.Unspecified via ToBinary storage,
CloudEvents core string attributes treating Null as absent, the relaxation
of TryGetDateTimeOffset to both zero-offset designators, and writing Int64
and Decimal through the JSON writer's native number overloads.

These were previously carried as an amendment note and inline edits in the
plan itself, which left it unclear what had originally been specified. Move
them into 0055-plan-deviations.md in the format ai-plans/AGENTS.md
prescribes, and restore the plan to its original text so it stays the record
of what was planned rather than a mix of both.

The restored plan therefore contains the acceptance criterion requiring a
value of any primitive kind to resolve as a CloudEvents core string
attribute, which the code deliberately violates for Null; deviation 2
records that.

Also note the two known gaps: TryFormatCanonical still allocates, and
temporal validation boundaries produce degraded OpenAPI examples (#57).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RtfndM4yhWgCtShY4trph5
Signed-off-by: Kenny Pflug <kenny.pflug@live.de>
…tions

XmlConvert.ToTimeSpan throws OverflowException rather than FormatException
for a well-formed XSD duration that exceeds the TimeSpan range, such as
"P10675200D", so the exception escaped the accessor on wire-degraded
string input. The catch now handles both exception types.

Also corrects the exception contract of MetadataValueAnnotationHelper:
the ArgumentOutOfRangeException arm was dead code because JsonShape
already throws SwitchExpressionException for undeclared kinds, and the
XML docs still promised the unreachable exception.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RtfndM4yhWgCtShY4trph5
…vents timestamps

Applies the remaining findings of the 0055 review.

CloudEvents 'time' attributes without a UTC designator are now rejected
with an ArgumentException that points towards DateTimeKind.Utc or
DateTimeOffset. Such text was previously parsed leniently and resolved
against the local time zone of the serializing machine, so identical
metadata produced different instants on different hosts.

TryGetDecimal now converts from Single, narrowing back to float first so
that 0.1f reads as 0.1m rather than the widened 0.10000000149011612m.
Before, an in-process Single failed the accessor while the same value
degraded to the string "0.1" on the wire succeeded.

Validation messages render finite Double parameters with the canonical
encoding, so a Double boundary no longer renders "0,5" next to a Single
rendered "0.1" under a non-invariant culture. Non-finite values keep the
culture path, which also removes a latent throw: the Single arm called
FromSingle unconditionally, and that factory rejects NaN.

FormatGuid drops a redundant ToLowerInvariant, since the "D" format is
specified to produce lowercase digits.

BREAKING CHANGE: A CloudEvents 'time' attribute whose text carries no UTC
offset now throws instead of being resolved against the local time zone.

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

Copy link
Copy Markdown

Code Coverage

Package Line Rate Branch Rate Complexity Health
Light.PortableResults 97% 94% 2582
Light.PortableResults.AspNetCore.MinimalApis 89% 75% 25
Light.PortableResults.AspNetCore.Mvc 89% 75% 25
Light.PortableResults.AspNetCore.OpenApi 94% 83% 505
Light.PortableResults.AspNetCore.Shared 100% 100% 26
Light.PortableResults.Validation 97% 89% 2954
Light.PortableResults.Validation.OpenApi 98% 91% 146
Light.PortableResults.Validation.OpenApi.SourceGeneration 88% 83% 786
Summary 96% (12413 / 12975) 90% (5364 / 5974) 7049

Minimum allowed line rate is 60%

@feO2x
feO2x merged commit a8d3b86 into main Jul 29, 2026
2 checks passed
@feO2x
feO2x deleted the 54-metadatakind-restructuring branch July 29, 2026 18:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Typed Metadata Kinds

1 participant