MetadataKind Restructuring - #56
Merged
Merged
Conversation
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.
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
Minimum allowed line rate is |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #55
This document compares
ai-plans/0055-metadata-restructuring.mdwith the implementation of the typed metadata vocabulary.ai-plans/0054-1-metadata-round-trip-envelope.mdis 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.0andnet10.0only in theDateOnly/TimeOnlymembers.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.
DateTimeacceptsDateTimeKind.Unspecifiedand storesToBinary()instead of UTC ticksOriginal plan:
The vocabulary table specified the
DateTimepayload asinline UTC ticksand its encoding as "RFC 3339 date-time,Zsuffix". The Vocabulary notes stated: "FromDateTimeconvertsLocalto UTC and throws forDateTimeKind.Unspecified."Implemented:
FromDateTimeconvertsLocalto UTC and acceptsUnspecifiedas it is. The payload storesDateTime.ToBinary(), which packs the kind into the two spare high bits of the tick count — the same 8-byteInt64slot raw ticks occupied, with no loss of precision and no growth of the struct. OnlyUtcandUnspecifiedare ever stored, so decoding is deterministic; aLocalresult fromFromBinaryis treated as a corrupt payload, alongside the out-of-range case, becauseFromBinarywould otherwise resolve it against the reading machine's time zone.An
Unspecifiedvalue renders without a designator (2026-07-26T13:45:30).Why:
Unspecifiedis what everynew DateTime(...)literal and every zone-lessDateTime.Parseproduces, 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 inCreateMetadataValue,ValidationErrorMessageFormatting, and OpenAPI example generation alike: a validation comparison against anUnspecifiedboundary 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
Unspecifiedvalue does not satisfy theformat: date-timethe schema mapper emits forSystem.DateTime. This is accepted deliberately: the alternative is inventing aZthe 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
Unspecifiedand aUtcvalue with the same wall clock are not equal, because the kind is part of the payload; and steering callers towardUtcat API boundaries is left to a future analyzer diagnostic rather than a runtime throw.2. CloudEvents core string attributes treat
Nullas absentOriginal plan:
An acceptance criterion required that "a value of any primitive kind resolves as a core string attribute."
Implemented:
GetStringAttributeresolves a value of any primitive kind exceptNull, which resolves to no attribute at all.Why:
Nullis 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. Asourceof"null"passed the required-attribute check and shipped an invalid event; atimeof"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
Nullas absent — the pre-plan implementation walkedTryGetString → TryGetBoolean → TryGetInt64 → TryGetDouble → Kind == Decimal, all of which missNull— 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
DefaultHttpHeaderConversionServicestill emits"null"forMetadataValue.Null. That is pre-existing behavior tracked separately in #51 and was deliberately left alone here.3.
TryGetDateTimeOffsetaccepts both zero-offset designatorsOriginal plan:
The Accessors section specified that each
TryGet*"converts fromMetadataKind.Stringwhen the string holds the kind's canonical encoding and rejects any other text", where the canonical encoding forDateTimeOffsetis the offset form its own writer produces (+00:00for a zero offset).Implemented:
TryGetDateTimeOffsetadditionally accepts theZdesignator 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.
Zis what RFC 3339 emitters produce almost everywhere, including this library's ownDateTimekind, 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
TryGetDateTimerejects text carrying a numeric offset, for the same reason.4.
Int64andDecimalare written with the JSON writer's own number formattingOriginal plan:
The Dispatch safety section specified: "The JSON writers switch on the six shapes; the
Numberarm dispatches overInt64/Double/Single/Decimalonly."Implemented:
Only
DoubleandSingletake the canonical-textWriteRawValuepath.Int64andDecimaluseUtf8JsonWriter's nativeWriteNumberValueoverloads. The dispatch is driven by a new publicMetadataNumberEncodingenum (None,Int64,Double,Single,Decimal) and aGetNumberEncoding()extension method sitting next toGetJsonShape().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
Int64andDecimal, where the writer's own formatting is byte-identical anyway. OnlyDoubleandSingleneed the raw path, because only they carry the trailing.0that keeps a whole-number token from reading back as anInt64. Measured: 48 B → 0 B per value forInt64andDecimal; 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
Numberarm and can never fail — and aswitchstatement 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 aNumbershape without an encoding cannot ship.Impact:
MetadataNumberEncodingis public API that the plan did not anticipate. It is documented as the companion toMetadataJsonShapefor 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:
TryFormatCanonicalstill allocates. It callsToCanonicalString()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 anetstandard2.0fork (double.TryFormatdoes not exist there).DateTimeboundary cannot be folded bySemanticModel.GetConstantValue, so the generated error example carries neither a message nor acomparativeValue. This is pre-existing and is tracked in Validation boundaries of non-constant types produce OpenAPI error examples without a message or comparativeValue #57. TheDateTime/DateTimeOffset/TimeSpan/Guid/Uriarms added toValidatorOpenApiEmitter.ToLiteralare unreachable through the analyzer until that issue is fixed, and are kept deliberately as its landing site.