From a3f8654c1249863aa8aca8d6417b549654bb6906 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 07:35:31 +0200 Subject: [PATCH 01/12] docs(cloud-events): add plan for extension attribute type conformance CloudEvents closes its attribute type system to seven types and maps them onto three JSON forms, so Double, Single, Decimal, and out-of-range Int64 are currently written in a form no CloudEvents type maps to. The plan records the decision to map through the type system rather than deviate, using String as the spec's escape hatch. Also covers the null rule in both directions, the character contract that String imposes, and the value-dependent Int64 encoding that is kept for its round trip and documented as a deviation. Refs #53 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UrQRRJp3wRtHPgoKfngajd --- ...-cloud-events-extension-attribute-types.md | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 ai-plans/0053-cloud-events-extension-attribute-types.md diff --git a/ai-plans/0053-cloud-events-extension-attribute-types.md b/ai-plans/0053-cloud-events-extension-attribute-types.md new file mode 100644 index 0000000..d8eb562 --- /dev/null +++ b/ai-plans/0053-cloud-events-extension-attribute-types.md @@ -0,0 +1,161 @@ +# CloudEvents Extension Attributes Follow the CloudEvents Type System + +## Rationale + +`JsonCloudEventsExtensions.WriteExtensionAttributes` writes every converted attribute through `SharedJsonSerialization.Writing.MetadataExtensions.WriteMetadataValue`, which emits each `MetadataKind` in its natural JSON form. That is correct for the event `data` payload and for `problem+json` bodies, but context attributes are not free-form JSON: CloudEvents core §2.4 closes the attribute type system to `Boolean`, `Integer`, `String`, `Binary`, `URI`, `URI-reference`, and `Timestamp`, §2.3 binds extension attributes to that same set, and the JSON Event Format §2.2 maps it onto exactly three JSON forms — boolean, number (integer digits only), and string — with `null` reserved as the encoding of an attribute that is not set. A fractional JSON number is therefore not the valid serialization of any CloudEvents attribute type, and a JSON number outside the int32 range is outside the only type it could belong to. Today `Double`, `Single`, `Decimal`, and out-of-range `Int64` all violate this. + +This plan resolves the open decision in favor of mapping rather than deviating: extension attributes are written through the CloudEvents type system, with `String` used as the spec's escape hatch for values the type system cannot express — subject to the character rules that `String` itself imposes, which the library does not enforce today either. For every non-null value the framing on the wire changes but nothing is lost. Null attributes are the one exception, and in the other direction: the format defines a null attribute as unset, so the reader must stop materializing one as `MetadataKind.Null` — a `Null` entry annotated for extension attributes therefore no longer survives a round trip, by design. + +## Acceptance Criteria + +- [ ] The decision is recorded in the codebase: extension attributes are written through the JSON Event Format's type-system mapping instead of the metadata kind's natural JSON form, documented on the public encoding API and in the README's CloudEvents section. +- [ ] In extension attributes, `Boolean` writes a JSON boolean, `Int64` inside the inclusive int32 range writes a JSON number, and every other primitive kind — including `Double`, `Single`, `Decimal`, and `Int64` outside the int32 range — writes a JSON string carrying the value's canonical invariant text. +- [ ] A `Null` metadata value produces no extension attribute at all: the property name is not written, and the envelope is byte-identical to one whose metadata never contained the entry. +- [ ] An inbound extension attribute whose JSON value is `null` is treated as unset: it does not appear in the metadata produced by reading the envelope, and it does not replace a value of the same key coming from the payload. +- [ ] The kind-to-JSON-encoding decision is reachable through one public API, is named for the JSON encoding it selects rather than for the abstract CloudEvents type system, and is applied at the single extension-attribute write site. A `MetadataKind` declared later fails the Release build instead of silently picking a JSON form. +- [ ] A complex metadata value that reaches the extension-attribute writer is rejected with an exception naming the kind, rather than emitting a nested JSON array or object. +- [ ] Extension attribute text that core §2.4 excludes from `String` — C0 and C1 control characters, Unicode noncharacters, and surrogate code points outside a valid pair — is rejected with an exception identifying the attribute and the offending code point. Values are never normalized, and kinds whose canonical text is machine-generated are not scanned. +- [ ] The character rule is public API, so a custom `ICloudEventsAttributeConversionService` can apply the same check. Tests cover a C0 control character, a C1 control character, a lone high surrogate, a lone low surrogate, a noncharacter, a `Char` value holding one of these, and a valid surrogate pair plus non-ASCII text that must be accepted unchanged. +- [ ] `ToCloudEvent_ShouldWriteDecimalExtensionAttribute_AsUnquotedNumber` is replaced by a test pinning the quoted, canonical decimal text. +- [ ] A test matrix covers every primitive `MetadataKind` in an extension attribute, including all four `Int64` range boundaries (`int.MinValue`, `int.MaxValue`, and the first value outside the range on each side). +- [ ] The `Int64` encoding is documented as value-dependent on the public encoding API and in the README: one attribute name can appear as a JSON number in one event and a JSON string in the next, which deviates from the stable-type expectation in core §2.3. The documentation names the supported way to obtain a stable `String` attribute — a `CloudEventsAttributeConverter` that converts the value before it reaches the writer — and a test pins both the instability across two events and the converter that removes it. +- [ ] Reading a written envelope back is covered for each JSON encoding: string-mapped values return as `MetadataKind.String` with the canonical text, a null attribute is absent from the metadata rather than returned as `MetadataKind.Null`, the kind change is documented on the encoding API the way numeric-token behavior is documented on `MetadataJsonReader`, and a registered `CloudEventsAttributeParser` restores the original kind. +- [ ] Standard attributes resolved from metadata (`type`, `source`, `subject`, `dataschema`, `time`, `id`) keep their current string rendering and are unaffected by the mapping. +- [ ] Writing a `Double` or `Single` extension attribute allocates nothing after warm-up, asserted the same way as in `CanonicalFloatingPointFormatterTests`. The remaining string-mapped kinds allocate at most the one canonical string `MetadataValue.TryFormatCanonical` materializes for them today; removing that is separate work. +- [ ] `` records the new encoding and every behavior change it causes, and the existing decimal entry is corrected so that it no longer claims decimals serialize as JSON numbers everywhere. +- [ ] Test code coverage stays above 95%. + +## Technical Details + +### Decision and rejected alternatives + +Mapping wins over documenting a deviation: portability across CloudEvents implementations is the reason the feature exists, and a receiver validating attribute types against the spec is entitled to reject a fractional number. Two alternatives were considered and rejected: + +- **An opt-out on `PortableResultsCloudEventsWriteOptions`.** It would double the write matrix and enshrine a mode whose only purpose is producing invalid events. Pre-1.0 the break is cheap; a publisher who needs a specific wire shape already has `CloudEventsAttributeConverter`. +- **Throwing for an out-of-range `Int64`.** Large integers — snowflake IDs, Unix timestamps in nanoseconds — are exactly what people put in attributes, and they are perfectly expressible as `String`. Turning a working publish into a runtime exception is a worse outcome than reframing the value. +- **Mapping every `Int64` to `String`.** This buys a kind-stable encoding and would be the right answer if the cost were only cosmetic, but `MetadataValue.TryGetInt64` matches on `Kind` alone and does not parse text (`MetadataValue.cs:385`). Every integer extension attribute would then read back as `MetadataKind.String` and `TryGetInt64` would return `false`, so every consumer of every integer attribute — the most common case by far — would need a registered `CloudEventsAttributeParser` or a manual parse to recover what it has today. The next section explains why the resulting shape instability is the better trade. + +### The mapping + +| `MetadataKind` | Abstract CloudEvents type (core §2.4) | JSON encoding (JSON format §2.2) | Change | +| --- | --- | --- | --- | +| `Null` | — (unset) | attribute omitted | **new** | +| `Boolean` | `Boolean` | boolean | — | +| `Int64`, `-2147483648..2147483647` | `Integer` | number | — | +| `Int64`, outside that range | `String` | string | **new** | +| `Double`, `Single`, `Decimal` | `String` | string | **new** | +| `UInt64`, `String`, `Char`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, `TimeSpan`, `Guid`, `Uri` | `String` | string | — | +| `Array`, `Object` | — | throws | **new** | + +The string text is `MetadataValue.ToCanonicalString()`, unchanged from what HTTP headers and `MetadataValue.ToString()` already produce, so the same metadata yields the same text on every transport. + +### The `Int64` arm is value-dependent, and that is part of the contract + +`Integer` is the only encoding chosen from the value rather than the kind, so one extension attribute name can appear as a JSON number in one event and a JSON string in the next — `2147483647` versus `"2147483648"`. Core §2.3 expects an extension definition to fix one type, so this is a real deviation and it is documented rather than hidden. + +It is accepted for three reasons: + +- **Exactly one stable mapping exists, and its cost is the one rejected above.** Stability requires the encoding to be a function of the kind alone, which for `Int64` means `String` for every value — the option that costs every consumer its `TryGetInt64`. Keeping `Integer` for in-range values necessarily makes the boundary observable. What is *not* available at any price is a stable `Integer`: for a value beyond int32 that would mean emitting an out-of-range number, the bug this plan fixes. +- **A stable type is guidance to whoever defines the extension**, and this library cannot see that definition. It can guarantee that every event it emits is valid; it cannot guarantee that a caller's values fit a type the caller never declared. +- **Only a straddling key varies.** A key that consistently holds small values, or consistently holds large ones, has a stable encoding in practice — and a key that does straddle could never have carried a valid `Integer` definition to begin with. + +The governing rule is that a value-dependent encoding is accepted **only where it buys a kind-preserving round trip**. `Int64` qualifies: `MetadataJsonReader` turns a JSON number back into `MetadataKind.Int64`, so the common case survives the round trip intact. `UInt64` does not qualify — nothing reads back as `MetadataKind.UInt64` — so promoting small `UInt64` values to numbers would add a second unstable shape and buy nothing. That is the asymmetry between the two rows, and it is the principle rather than an exception. + +A publisher who needs one fixed type for a key has a supported way to get it: a `CloudEventsAttributeConverter` that converts the value to `MetadataKind.String` before it reaches the writer produces a stable `String` attribute for every value, matching a `String`-typed extension definition. Document that alongside the rule. + +The two right-hand columns are distinct things, and only the last one is modeled in code. The abstract type column records why each row lands where it does; the JSON column is the decision the writer makes. `Binary`, `URI`, `URI-reference`, and `Timestamp` are deliberately not modeled, because the library never has enough information to promise the stricter type: a `DateTime` with `DateTimeKind.Unspecified` has no offset and is therefore not a valid RFC 3339 `Timestamp`, and a `Uri` metadata value may be relative. Claiming the narrower type would be a claim the canonical text cannot back — so the code names the JSON encoding it actually decides and stays silent about the abstract type. + +### `String` is a character contract, not just a JSON string + +Choosing the `String` type is necessary but not sufficient. Core §2.4 defines `String` as a sequence of *allowable* Unicode characters and excludes three groups: the C0 and C1 control characters (U+0000–U+001F and U+007F–U+009F), the Unicode noncharacters (U+FDD0–U+FDEF and U+FFFE/U+FFFF in each of the 17 planes), and surrogate code points outside a valid pair. Escaping does not help — a `\u0001` escape in JSON still decodes to a control character in the attribute value, so the event is non-conformant however it is written. + +Most rows of the table are safe by construction: the canonical text of every numeric, boolean, date, time, `Guid`, and `TimeSpan` kind is ASCII digits, signs, and separators. Only `String`, `Char`, and `Uri` carry text the caller controls, and those three admit all of the disallowed groups today. + +These values are rejected, not normalized. Silently stripping or replacing a character changes data the caller deliberately put in an attribute and is discovered, if ever, on the consumer side. The check belongs in `DefaultCloudEventsAttributeConversionService.ValidateAttributeValue`, which is already the seam that rejects invalid attribute names and complex values, and it throws the same `ArgumentException` shape naming the attribute and the offending code point. The rule itself is public so a custom `ICloudEventsAttributeConversionService` can apply it; supplying such a service already opts out of the name and primitive checks, and it opts out of this one on the same terms. + +Scanning is limited to the three text-bearing kinds, and the per-character test is a single range comparison for the ASCII-printable majority, with the noncharacter and surrogate work reached only above U+D7FF. A conformant attribute therefore pays one linear pass over text the writer is about to walk again anyway. Attribute *names* need no new check — `IsValidExtensionAttributeName` already restricts them to lowercase alphanumerics. + +`Utf8JsonWriter`'s own behavior for ill-formed UTF-16 (replacement versus exception) is not this library's contract to define and must not be relied on: validation happens before the writer is reached, and a test should pin what the writer does with a lone surrogate so the interaction is known rather than assumed. + +Two gaps remain and are deliberate, not oversights: + +- **Standard attributes** resolved from metadata (`type`, `subject`, `id`, and the `source`/`dataschema` URI-references) are rendered on a different path in `ResolveAttributes` and are not scanned. They are usually developer-supplied constants rather than runtime data, and adding a second validation site belongs with a review of that path. Worth a follow-up issue. +- **Inbound events** are not validated. A producer that ships a control character in an attribute produces an event this library will still read; rejecting it would break consumers over a defect in someone else's encoder, which is the wrong trade for a consumer library. + +### Null attributes are unset, in both directions + +The JSON Event Format permits `null` for an attribute and requires a decoder to treat it as if the attribute were not present. That rule is normative and it settles both ends: + +- **Reading.** `CloudEventsEnvelopeJsonReader` currently adds every extension attribute to the builder, so `"ext": null` becomes a `MetadataKind.Null` entry. That contradicts the rule: the decoded event must look as though `ext` was never there. The null token is skipped before the builder sees it, which also means a null attribute can no longer replace a payload-metadata value of the same key under `CloudEventsAttributeConflictStrategy`/`MergeStrategy`. Inbound envelopes from other producers get the same treatment; this is not limited to what this library writes. +- **Writing.** Because a conformant decoder must ignore it, emitting `null` conveys nothing, so the attribute is omitted entirely rather than written as `null`. The two are semantically identical under the format and omission is the cheaper of the two. This changes the bytes on the wire for a `Null` metadata value annotated for extension attributes. + +Omission has to be decided before the property name is written, so `WriteExtensionAttributes` consults the encoding and skips the pair; `WriteCloudEventsExtensionAttributeValue` still writes JSON `null` when called directly, because by then the caller has already committed to a property name. Standard attributes already behave this way: `GetStringAttribute` excludes `MetadataKind.Null` explicitly, and `ReadOptionalStringValue` maps an inbound `null` to "not present". + +The consequence for the metadata model is that a `Null` extension attribute does not round-trip. That is the correct reading of the format rather than a defect: CloudEvents has no way to say "this attribute is present and empty". + +### Public API + +```csharp +namespace Light.PortableResults.CloudEvents; + +public enum CloudEventsAttributeJsonEncoding { Null, Boolean, Integer, String } + +public static class CloudEventsAttributeJsonEncodingExtensions +{ + public static CloudEventsAttributeJsonEncoding GetCloudEventsAttributeJsonEncoding(this MetadataValue value); +} + +public static class CloudEventsAttributeText +{ + // Returns the index of the first character core §2.4 excludes from a String, or -1 when the text conforms. + // A high surrogate followed by a low surrogate is one valid character; either one alone is not. + public static int IndexOfDisallowedCharacter(ReadOnlySpan text); +} +``` + +```csharp +namespace Light.PortableResults.CloudEvents.Writing.Json; + +public static class JsonCloudEventsExtensions +{ + public static void WriteCloudEventsExtensionAttributeValue(this Utf8JsonWriter writer, MetadataValue value); +} +``` + +The enum is named for the JSON Event Format encoding it selects, not for the abstract type system. Calling it `CloudEventsAttributeType` while omitting `Binary`, `URI`, `URI-reference`, and `Timestamp` — and adding `Null`, which core §2.4 does not define as a type at all — would misstate a normative distinction of the v1.0.2 type system. `Null`, `Boolean`, `Integer`, and `String` are exactly the four JSON encodings the format admits, and the enum is complete for that job. + +The enum lives in `Light.PortableResults.CloudEvents` rather than under `Writing.Json` because it describes the format in both directions; a reader-side conformance check would classify against the same four values. + +Only the value-level classification is public. A kind-level overload would answer differently from the writer for an out-of-range `Int64`, which is a footgun in a rule whose whole point is having one answer. `GetCloudEventsAttributeJsonEncoding` is implemented over an exhaustive `switch` expression on `MetadataKind` with no default arm, following `MetadataKindExtensions`: `CS8524` stays suppressed for unnamed values, while a newly declared named kind produces `CS8509`, which is an error under `TreatWarningsAsErrors` in Release. `Array` and `Object` throw `InvalidOperationException` from that switch — a branch a caller can reach directly, so it is testable without a contrived writer setup. + +`WriteCloudEventsExtensionAttributeValue` dispatches on the classification and is the only place `WriteExtensionAttributes` writes a value. It is public because a custom envelope writer needs the same rule. The `MetadataValueAnnotation` argument that `WriteMetadataValue` takes disappears: it only ever filtered complex children, which can no longer occur, and the top level was never filtered by it — attributes are already selected by annotation in `CloudEventsResultExtensions.ConvertMetadataToCloudEventsAttributes`. + +The throw for complex kinds is a behavior change only for a custom `ICloudEventsAttributeConversionService` that bypasses `DefaultCloudEventsAttributeConversionService.ValidateAttributeValue`; such a service currently produces a nested JSON value that no CloudEvents receiver can type. Failing fast is the better contract, and it matches the existing `MetadataNumberEncoding.None` arm in `MetadataExtensions.WriteNumberValue`. + +Standard attributes never reach this path. `WriteExtensionAttributes` skips `CloudEventsConstants.StandardAttributeNames`, and `ResolveAttributes` renders them from `ToCanonicalString()` into `WriteString` calls, which is already spec-conformant. + +### Release notes + +Everything here ships in the unreleased 0.7.0, so the entries go into that block of `` rather than a new one. Four behavior changes are visible to a consumer and belong under **Breaking changes**: the string encoding of `Double`, `Single`, `Decimal`, and out-of-range `Int64` in extension attributes; the omission of null extension attributes on write; their treatment as unset on read; and the rejection of text that CloudEvents excludes from a `String`. Each needs the scope stated — extension attributes only, with `data` payloads and `problem+json` bodies untouched — because the natural reading of "decimals are now strings" is that it applies everywhere. + +The existing decimal entry has to be corrected rather than merely supplemented. It currently states that decimal metadata values "are serialized as JSON numbers instead of quoted strings", which after this plan is true of `data` and `problem+json` but false of extension attributes — the same conflation that #52 introduced and that this plan resolves. Scope that sentence to the payload, and let the new entry carry the attribute rule. + +### Allocation + +The string arm formats through `MetadataValue.TryFormatCanonical` into a 32-character stack buffer and calls `Utf8JsonWriter.WriteStringValue(ReadOnlySpan)`, falling back to `WriteStringValue(value.ToCanonicalString())` when the destination is too small. Thirty-two characters cover every numeric kind: the widest decimal text is 31 characters — a negative value at scale 28, `-0.0000000000000000000000000001`, not `decimal.MinValue` at 30. + +That path is only genuinely allocation-free for `Double` and `Single` today. `TryFormatCanonical` span-formats those two kinds and materializes `ToCanonicalString()` for everything else (`MetadataValue.cs:848`), so `Int64`, `UInt64`, and `Decimal` still allocate a string that is then copied into the buffer. Plan `0058` deferred direct span formatting for the remaining kinds deliberately, and it stays deferred here: doing it properly needs `TryFormat` shims for `long`, `ulong`, and `decimal` on `netstandard2.0`, where those overloads do not exist — the `BitOperationsCompat` pattern or a UTF-8 `Utf8Formatter` path, either of which is its own change with its own differential tests. It is tracked as follow-up work on a separate branch. + +The net effect of this plan on allocations is therefore: `Double` and `Single` improve (today they allocate through `WriteRawValue(ToCanonicalString())`), `Decimal` and out-of-range `Int64` regress by one string each as the price of conformance, and every kind that already wrote as a string is unchanged. Writing through the buffer rather than passing the string straight to the writer costs a copy but keeps the call site correct for free once the deferred work lands. No new benchmark is required: the change is one branch plus a span format on a path `CloudEventsWritingBenchmarks` already exercises. + +### Reading + +Apart from the null rule above, the read path is unchanged. A value written as a string returns as `MetadataKind.String`, so `Double`, `Single`, `Decimal`, and out-of-range `Int64` change kind on the way in — the same class of asymmetry already documented on `MetadataJsonReader`, which never produces `Decimal` at all. State it on `CloudEventsAttributeJsonEncodingExtensions` and in the README, and point at the supported remedy: register a `CloudEventsAttributeParser` for the attribute name to parse the canonical text back into the intended kind. A test should demonstrate that remedy for one kind so the documented escape hatch is known to work. + +A binary content-mode writer would not reuse this enum: attributes become header text there, so the JSON encoding has nothing to say about them. What carries over is the canonical text and the decision recorded here that `String` is the fallback for anything the type system cannot express. If binary mode ever needs to distinguish `Binary` from `Timestamp` from `URI` in a header, that is the point at which modeling the seven abstract types earns its keep — and it will need input the writer does not have today, so it is a separate decision rather than a rename of this one. + +### Testing notes + +The matrix belongs on the public `ToCloudEvent` surface rather than on the writer helper, so it pins the shipped behavior: for each primitive kind, assert both `JsonValueKind` and the value's text. The four `Int64` boundaries are the only non-obvious cases — `int.MinValue` and `int.MaxValue` must be numbers, `(long) int.MinValue - 1` and `(long) int.MaxValue + 1` must be the corresponding quoted digits. + +The null rule needs three tests, because writing and reading are now separate statements: a `Null` metadata value yields an envelope with no such property; a hand-written envelope carrying `"ext": null` — the form other producers may send — reads back without an `ext` entry; and a null extension attribute does not displace a payload-metadata entry of the same key. From b27b9e03f99584808ff3a5c1d2b6477a2c3bf52e Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 07:42:25 +0200 Subject: [PATCH 02/12] docs(cloud-events): enforce the attribute character rule at the write boundary Validating only in DefaultCloudEventsAttributeConversionService left the rule advisory: a custom conversion service or a directly constructed CloudEventsEnvelopeForWriting reaches the writer without it, while the complex-kind rule is absolute at the same site. Move enforcement into WriteExtensionAttributes ahead of the property name, share a private core with the public value writer so no attribute is scanned twice, and keep the validator public for services that prefer to fail before serialization starts. Refs #53 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UrQRRJp3wRtHPgoKfngajd --- ...-cloud-events-extension-attribute-types.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/ai-plans/0053-cloud-events-extension-attribute-types.md b/ai-plans/0053-cloud-events-extension-attribute-types.md index d8eb562..5cbac3c 100644 --- a/ai-plans/0053-cloud-events-extension-attribute-types.md +++ b/ai-plans/0053-cloud-events-extension-attribute-types.md @@ -14,8 +14,9 @@ This plan resolves the open decision in favor of mapping rather than deviating: - [ ] An inbound extension attribute whose JSON value is `null` is treated as unset: it does not appear in the metadata produced by reading the envelope, and it does not replace a value of the same key coming from the payload. - [ ] The kind-to-JSON-encoding decision is reachable through one public API, is named for the JSON encoding it selects rather than for the abstract CloudEvents type system, and is applied at the single extension-attribute write site. A `MetadataKind` declared later fails the Release build instead of silently picking a JSON form. - [ ] A complex metadata value that reaches the extension-attribute writer is rejected with an exception naming the kind, rather than emitting a nested JSON array or object. -- [ ] Extension attribute text that core §2.4 excludes from `String` — C0 and C1 control characters, Unicode noncharacters, and surrogate code points outside a valid pair — is rejected with an exception identifying the attribute and the offending code point. Values are never normalized, and kinds whose canonical text is machine-generated are not scanned. -- [ ] The character rule is public API, so a custom `ICloudEventsAttributeConversionService` can apply the same check. Tests cover a C0 control character, a C1 control character, a lone high surrogate, a lone low surrogate, a noncharacter, a `Char` value holding one of these, and a valid surrogate pair plus non-ASCII text that must be accepted unchanged. +- [ ] Extension attribute text that core §2.4 excludes from `String` — C0 and C1 control characters, Unicode noncharacters, and surrogate code points outside a valid pair — is rejected at the write boundary with an exception identifying the attribute and the offending code point. Values are never normalized, and kinds whose canonical text is machine-generated are not scanned. +- [ ] The rejection holds on every write path, including a custom `ICloudEventsAttributeConversionService` and a directly constructed `CloudEventsEnvelopeForWriting`, and the envelope carries no partially written property when it fails. No conformant attribute is scanned more than once per event. +- [ ] The character rule is public API, so a conversion service can apply the same check at its own seam and fail before serialization starts. Tests cover a C0 control character, a C1 control character, a lone high surrogate, a lone low surrogate, a noncharacter, a `Char` value holding one of these, and a valid surrogate pair plus non-ASCII text that must be accepted unchanged. - [ ] `ToCloudEvent_ShouldWriteDecimalExtensionAttribute_AsUnquotedNumber` is replaced by a test pinning the quoted, canonical decimal text. - [ ] A test matrix covers every primitive `MetadataKind` in an extension attribute, including all four `Int64` range boundaries (`int.MinValue`, `int.MaxValue`, and the first value outside the range on each side). - [ ] The `Int64` encoding is documented as value-dependent on the public encoding API and in the README: one attribute name can appear as a JSON number in one event and a JSON string in the next, which deviates from the stable-type expectation in core §2.3. The documentation names the supported way to obtain a stable `String` attribute — a `CloudEventsAttributeConverter` that converts the value before it reaches the writer — and a test pins both the instability across two events and the converter that removes it. @@ -71,11 +72,17 @@ Choosing the `String` type is necessary but not sufficient. Core §2.4 defines ` Most rows of the table are safe by construction: the canonical text of every numeric, boolean, date, time, `Guid`, and `TimeSpan` kind is ASCII digits, signs, and separators. Only `String`, `Char`, and `Uri` carry text the caller controls, and those three admit all of the disallowed groups today. -These values are rejected, not normalized. Silently stripping or replacing a character changes data the caller deliberately put in an attribute and is discovered, if ever, on the consumer side. The check belongs in `DefaultCloudEventsAttributeConversionService.ValidateAttributeValue`, which is already the seam that rejects invalid attribute names and complex values, and it throws the same `ArgumentException` shape naming the attribute and the offending code point. The rule itself is public so a custom `ICloudEventsAttributeConversionService` can apply it; supplying such a service already opts out of the name and primitive checks, and it opts out of this one on the same terms. +These values are rejected, not normalized. Silently stripping or replacing a character changes data the caller deliberately put in an attribute and is discovered, if ever, on the consumer side. + +Enforcement belongs at the write boundary, not at the conversion seam. `DefaultCloudEventsAttributeConversionService.ValidateAttributeValue` is bypassed by any custom `ICloudEventsAttributeConversionService` and by a directly constructed `CloudEventsEnvelopeForWriting`, whose constructor takes the attribute `MetadataObject` and is public. Validating only there would make the character rule advisory while the complex-kind rule is absolute — an inconsistency with no justification, since both protect the same invariant. `WriteExtensionAttributes` therefore performs the check itself, before `WritePropertyName`, so no path can emit prohibited text and no half-written property is left behind when it fails. That site also holds the attribute name the exception message needs; the value writer alone does not. + +To keep this to one pass, the two public entry points share a private core: `WriteExtensionAttributes` validates with the name in hand and calls the unvalidated core, while `WriteCloudEventsExtensionAttributeValue` validates without a name and calls the same core. Neither path scans twice, and a direct caller of the public value writer is protected too. The conversion service does not repeat the scan — a conformant attribute would then pay for it twice on every event — but `CloudEventsAttributeText` is public precisely so a custom service can run the check at its own seam when it prefers failing at `ToCloudEvent` time over failing mid-serialization. + +The exception is an `InvalidOperationException` naming the attribute and the offending code point, matching the complex-kind throw at the same site. A conversion service applying the rule earlier throws `ArgumentException` from its own seam, as `ValidateAttributeValue` already does for names and complex values. Scanning is limited to the three text-bearing kinds, and the per-character test is a single range comparison for the ASCII-printable majority, with the noncharacter and surrogate work reached only above U+D7FF. A conformant attribute therefore pays one linear pass over text the writer is about to walk again anyway. Attribute *names* need no new check — `IsValidExtensionAttributeName` already restricts them to lowercase alphanumerics. -`Utf8JsonWriter`'s own behavior for ill-formed UTF-16 (replacement versus exception) is not this library's contract to define and must not be relied on: validation happens before the writer is reached, and a test should pin what the writer does with a lone surrogate so the interaction is known rather than assumed. +`Utf8JsonWriter`'s own behavior for ill-formed UTF-16 (replacement versus exception) is not this library's contract to define and must not be relied on: validation runs before the text reaches the writer, and a test should pin what the writer does with a lone surrogate so the interaction is known rather than assumed. Two gaps remain and are deliberate, not oversights: @@ -128,9 +135,9 @@ The enum lives in `Light.PortableResults.CloudEvents` rather than under `Writing Only the value-level classification is public. A kind-level overload would answer differently from the writer for an out-of-range `Int64`, which is a footgun in a rule whose whole point is having one answer. `GetCloudEventsAttributeJsonEncoding` is implemented over an exhaustive `switch` expression on `MetadataKind` with no default arm, following `MetadataKindExtensions`: `CS8524` stays suppressed for unnamed values, while a newly declared named kind produces `CS8509`, which is an error under `TreatWarningsAsErrors` in Release. `Array` and `Object` throw `InvalidOperationException` from that switch — a branch a caller can reach directly, so it is testable without a contrived writer setup. -`WriteCloudEventsExtensionAttributeValue` dispatches on the classification and is the only place `WriteExtensionAttributes` writes a value. It is public because a custom envelope writer needs the same rule. The `MetadataValueAnnotation` argument that `WriteMetadataValue` takes disappears: it only ever filtered complex children, which can no longer occur, and the top level was never filtered by it — attributes are already selected by annotation in `CloudEventsResultExtensions.ConvertMetadataToCloudEventsAttributes`. +`WriteCloudEventsExtensionAttributeValue` dispatches on the classification and validates the text before delegating to the private core that `WriteExtensionAttributes` also uses, so both public entry points enforce the same rule and neither scans twice. It is public because a custom envelope writer needs the same rule — and because `CloudEventsEnvelopeForWriting` is a record struct whose primary constructor accepts `ExtensionAttributes` outright, a caller can reach the writer without passing through any conversion service at all. The `MetadataValueAnnotation` argument that `WriteMetadataValue` takes disappears: it only ever filtered complex children, which can no longer occur, and the top level was never filtered by it — attributes are already selected by annotation in `CloudEventsResultExtensions.ConvertMetadataToCloudEventsAttributes`. -The throw for complex kinds is a behavior change only for a custom `ICloudEventsAttributeConversionService` that bypasses `DefaultCloudEventsAttributeConversionService.ValidateAttributeValue`; such a service currently produces a nested JSON value that no CloudEvents receiver can type. Failing fast is the better contract, and it matches the existing `MetadataNumberEncoding.None` arm in `MetadataExtensions.WriteNumberValue`. +The throw for complex kinds is a behavior change only on the paths that bypass `DefaultCloudEventsAttributeConversionService.ValidateAttributeValue` — a custom conversion service, or an envelope constructed directly — which today produce a nested JSON value that no CloudEvents receiver can type. Failing fast is the better contract, and it matches the existing `MetadataNumberEncoding.None` arm in `MetadataExtensions.WriteNumberValue`. Standard attributes never reach this path. `WriteExtensionAttributes` skips `CloudEventsConstants.StandardAttributeNames`, and `ResolveAttributes` renders them from `ToCanonicalString()` into `WriteString` calls, which is already spec-conformant. From 382fdf146645bb858c16fa1cb7f1d1453f43fd79 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 07:49:10 +0200 Subject: [PATCH 03/12] docs(cloud-events): pair the Int64 threshold with canonical-text parsing The value-dependent encoding was justified against an alternative that was described as more costly than it is: TryGetInt64 could gain canonical-string parsing like TryGetUInt64, TryGetSingle, TryGetChar, and TryGetDecimal already have, which makes a stable String encoding cheap for consumers. Present both options honestly and decide for the threshold plus the parsing arm. The arm is what makes the threshold defensible: without it an out-of-range value writes as a string that TryGetInt64 refuses, so the accessor is now total across both encodings and only Kind stays observable. Refs #53 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UrQRRJp3wRtHPgoKfngajd --- ...-cloud-events-extension-attribute-types.md | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/ai-plans/0053-cloud-events-extension-attribute-types.md b/ai-plans/0053-cloud-events-extension-attribute-types.md index 5cbac3c..8e77094 100644 --- a/ai-plans/0053-cloud-events-extension-attribute-types.md +++ b/ai-plans/0053-cloud-events-extension-attribute-types.md @@ -19,6 +19,8 @@ This plan resolves the open decision in favor of mapping rather than deviating: - [ ] The character rule is public API, so a conversion service can apply the same check at its own seam and fail before serialization starts. Tests cover a C0 control character, a C1 control character, a lone high surrogate, a lone low surrogate, a noncharacter, a `Char` value holding one of these, and a valid surrogate pair plus non-ASCII text that must be accepted unchanged. - [ ] `ToCloudEvent_ShouldWriteDecimalExtensionAttribute_AsUnquotedNumber` is replaced by a test pinning the quoted, canonical decimal text. - [ ] A test matrix covers every primitive `MetadataKind` in an extension attribute, including all four `Int64` range boundaries (`int.MinValue`, `int.MaxValue`, and the first value outside the range on each side). +- [ ] `MetadataValue.TryGetInt64` returns the value for a `MetadataKind.String` holding its canonical text, guarded by a round trip so that `"+5"`, `"-0"`, `"01234"`, `" 5"`, and out-of-range text are rejected. `Int64` therefore matches `UInt64`, `Single`, `Char`, and `Decimal`, which already parse their canonical encodings. +- [ ] An extension attribute holding an `Int64` outside int32 range reads back through `TryGetInt64` with its original value, so the value-dependent encoding is not observable through the typed accessor on either side of the boundary. - [ ] The `Int64` encoding is documented as value-dependent on the public encoding API and in the README: one attribute name can appear as a JSON number in one event and a JSON string in the next, which deviates from the stable-type expectation in core §2.3. The documentation names the supported way to obtain a stable `String` attribute — a `CloudEventsAttributeConverter` that converts the value before it reaches the writer — and a test pins both the instability across two events and the converter that removes it. - [ ] Reading a written envelope back is covered for each JSON encoding: string-mapped values return as `MetadataKind.String` with the canonical text, a null attribute is absent from the metadata rather than returned as `MetadataKind.Null`, the kind change is documented on the encoding API the way numeric-token behavior is documented on `MetadataJsonReader`, and a registered `CloudEventsAttributeParser` restores the original kind. - [ ] Standard attributes resolved from metadata (`type`, `source`, `subject`, `dataschema`, `time`, `id`) keep their current string rendering and are unaffected by the mapping. @@ -34,7 +36,7 @@ Mapping wins over documenting a deviation: portability across CloudEvents implem - **An opt-out on `PortableResultsCloudEventsWriteOptions`.** It would double the write matrix and enshrine a mode whose only purpose is producing invalid events. Pre-1.0 the break is cheap; a publisher who needs a specific wire shape already has `CloudEventsAttributeConverter`. - **Throwing for an out-of-range `Int64`.** Large integers — snowflake IDs, Unix timestamps in nanoseconds — are exactly what people put in attributes, and they are perfectly expressible as `String`. Turning a working publish into a runtime exception is a worse outcome than reframing the value. -- **Mapping every `Int64` to `String`.** This buys a kind-stable encoding and would be the right answer if the cost were only cosmetic, but `MetadataValue.TryGetInt64` matches on `Kind` alone and does not parse text (`MetadataValue.cs:385`). Every integer extension attribute would then read back as `MetadataKind.String` and `TryGetInt64` would return `false`, so every consumer of every integer attribute — the most common case by far — would need a registered `CloudEventsAttributeParser` or a manual parse to recover what it has today. The next section explains why the resulting shape instability is the better trade. +- **Mapping every `Int64` to `String`, with `TryGetInt64` gaining canonical-string parsing.** This is the serious alternative and it is treated in its own section below. On its own, always-`String` would leave every integer attribute unreadable through `TryGetInt64`, which matches on `Kind` alone (`MetadataValue.cs:385`) — but that is a gap rather than a constraint: `TryGetUInt64`, `TryGetSingle`, `TryGetChar`, and `TryGetDecimal` all parse their canonical text behind a round-trip check (`MetadataValue.cs:475`), and `Int64` is the only numeric kind that does not. Closing it is a few lines against an established pattern, and it makes a stable `String` encoding cheap for consumers. ### The mapping @@ -54,16 +56,30 @@ The string text is `MetadataValue.ToCanonicalString()`, unchanged from what HTTP `Integer` is the only encoding chosen from the value rather than the kind, so one extension attribute name can appear as a JSON number in one event and a JSON string in the next — `2147483647` versus `"2147483648"`. Core §2.3 expects an extension definition to fix one type, so this is a real deviation and it is documented rather than hidden. -It is accepted for three reasons: +One thing is unavailable at any price: a stable `Integer`. For a value beyond int32 that would mean emitting an out-of-range number, the bug this plan fixes. The genuine choice is between the value-dependent encoding above and mapping every `Int64` to `String`, and the two trade against each other as follows. -- **Exactly one stable mapping exists, and its cost is the one rejected above.** Stability requires the encoding to be a function of the kind alone, which for `Int64` means `String` for every value — the option that costs every consumer its `TryGetInt64`. Keeping `Integer` for in-range values necessarily makes the boundary observable. What is *not* available at any price is a stable `Integer`: for a value beyond int32 that would mean emitting an out-of-range number, the bug this plan fixes. -- **A stable type is guidance to whoever defines the extension**, and this library cannot see that definition. It can guarantee that every event it emits is valid; it cannot guarantee that a caller's values fit a type the caller never declared. -- **Only a straddling key varies.** A key that consistently holds small values, or consistently holds large ones, has a stable encoding in practice — and a key that does straddle could never have carried a valid `Integer` definition to begin with. +**Always `String`** makes the encoding a pure function of the kind, which is the rule the rest of this plan follows and the only way to give an attribute name one stable type. Paired with canonical-string parsing in `TryGetInt64` — the gap noted above, where `Int64` is the only numeric kind that cannot read its own canonical text — consumer access survives: `TryGetInt64` keeps working, only `Kind` changes. It also removes the one exception to the kind-function rule, and it converts a latent, data-dependent interop failure into a single documented break: a consumer that types an attribute as `Integer` works until a value crosses 2^31 and then fails in production, which is a worse failure mode than a uniform change taken once, pre-1.0, in release notes. Its costs are that every integer attribute on the wire becomes a quoted number, `MetadataKind.Int64` no longer survives the round trip, and the `Integer` encoding has no producer until a kind exists whose domain fits int32. -The governing rule is that a value-dependent encoding is accepted **only where it buys a kind-preserving round trip**. `Int64` qualifies: `MetadataJsonReader` turns a JSON number back into `MetadataKind.Int64`, so the common case survives the round trip intact. `UInt64` does not qualify — nothing reads back as `MetadataKind.UInt64` — so promoting small `UInt64` values to numbers would add a second unstable shape and buy nothing. That is the asymmetry between the two rows, and it is the principle rather than an exception. +**Value-dependent** keeps the common case natural — small integers stay JSON numbers and read back as `MetadataKind.Int64` — and confines the instability to keys whose values straddle the boundary. Such a key could never have carried a valid `Integer` definition anyway, and a key that consistently holds small or consistently holds large values is stable in practice. Its cost is that one attribute name can change JSON type between events, and that the "encoding is a function of the kind" rule acquires an exception. + +A stable type is guidance to whoever *defines* an extension, and this library cannot see that definition: it can guarantee that every event it emits is valid, not that a caller's values fit a type the caller never declared. That argument supports validity in both designs and does not by itself decide between them. + +**The decision is value-dependent encoding together with canonical-string parsing in `TryGetInt64`.** The parsing arm is what makes the threshold defensible: without it, an out-of-range value writes as a string and then reads back as something `TryGetInt64` refuses, so the threshold would quietly break exactly the consumers it was meant to keep working. With it, the accessor is total across both encodings — whichever side of the boundary a value falls on, a consumer calling `TryGetInt64` gets its `long` back. What remains observable is `Kind` and the raw JSON, not the ability to read the value. + +That also settles the asymmetry with `UInt64` properly. `UInt64` was already safe as a string precisely because `TryGetUInt64` parses its canonical text; `Int64` was the outlier that could not. Once both parse, the rule reads cleanly in two layers: **every numeric kind can read its own canonical text, and on top of that the writer preserves `MetadataKind` wherever the CloudEvents type system permits.** `Int64` in int32 range is the only place it permits it, because a JSON number is what `MetadataJsonReader` turns back into `MetadataKind.Int64`. Promoting `UInt64` would add an unstable shape while preserving no kind, so it stays `String`. + +Two costs survive this and are not fixed by the parsing arm: `Kind` still differs between events for a straddling key, and a foreign consumer whose extension definition types the attribute as `Integer` still breaks when a value crosses the boundary. Only always-`String` would remove the second, and nothing removes it for a key that straddles. A publisher who needs one fixed type for a key has a supported way to get it: a `CloudEventsAttributeConverter` that converts the value to `MetadataKind.String` before it reaches the writer produces a stable `String` attribute for every value, matching a `String`-typed extension definition. Document that alongside the rule. +### `TryGetInt64` reads canonical text + +`MetadataValue.TryGetInt64` gains a string arm modeled on `TryGetUInt64` (`MetadataValue.cs:475`): parse with `CultureInfo.InvariantCulture`, then accept only when re-formatting the parsed value reproduces the input exactly. The round-trip guard is what keeps this from turning the accessor into a lenient parser — `"+5"`, `"-0"`, `"01234"`, `" 5"`, and text outside `long` range are all rejected, because none of them is the canonical encoding of the value they parse to. `NumberStyles.AllowLeadingSign` replaces `TryGetUInt64`'s `NumberStyles.None`, since `Int64` is signed. + +The change is confined to what a consumer can read. The only production caller outside `MetadataObject.TryGetInt64` is `MetadataExtensions.WriteNumberValue`, which reaches it after dispatching on `GetNumberEncoding() == Int64`, so `Kind` is already `Int64` there and the string arm is unreachable. The HTTP header reader does not need it either: `DefaultHttpHeaderParsingService` already sniffs integral header text into `MetadataValue.FromInt64` itself. + +It is still a visible behavior change for existing callers — a `MetadataKind.String` value holding `"5"` now returns `true` where it returned `false` — so it belongs in the release notes rather than passing as an internal fix. + The two right-hand columns are distinct things, and only the last one is modeled in code. The abstract type column records why each row lands where it does; the JSON column is the decision the writer makes. `Binary`, `URI`, `URI-reference`, and `Timestamp` are deliberately not modeled, because the library never has enough information to promise the stricter type: a `DateTime` with `DateTimeKind.Unspecified` has no offset and is therefore not a valid RFC 3339 `Timestamp`, and a `Uri` metadata value may be relative. Claiming the narrower type would be a claim the canonical text cannot back — so the code names the JSON encoding it actually decides and stays silent about the abstract type. ### `String` is a character contract, not just a JSON string @@ -143,7 +159,7 @@ Standard attributes never reach this path. `WriteExtensionAttributes` skips `Clo ### Release notes -Everything here ships in the unreleased 0.7.0, so the entries go into that block of `` rather than a new one. Four behavior changes are visible to a consumer and belong under **Breaking changes**: the string encoding of `Double`, `Single`, `Decimal`, and out-of-range `Int64` in extension attributes; the omission of null extension attributes on write; their treatment as unset on read; and the rejection of text that CloudEvents excludes from a `String`. Each needs the scope stated — extension attributes only, with `data` payloads and `problem+json` bodies untouched — because the natural reading of "decimals are now strings" is that it applies everywhere. +Everything here ships in the unreleased 0.7.0, so the entries go into that block of `` rather than a new one. Five behavior changes are visible to a consumer and belong under **Breaking changes**: the string encoding of `Double`, `Single`, `Decimal`, and out-of-range `Int64` in extension attributes; the omission of null extension attributes on write; their treatment as unset on read; the rejection of text that CloudEvents excludes from a `String`; and `TryGetInt64` now returning `true` for a string value holding canonical integer text. Each needs the scope stated — extension attributes only, with `data` payloads and `problem+json` bodies untouched — because the natural reading of "decimals are now strings" is that it applies everywhere. The existing decimal entry has to be corrected rather than merely supplemented. It currently states that decimal metadata values "are serialized as JSON numbers instead of quoted strings", which after this plan is true of `data` and `problem+json` but false of extension attributes — the same conflation that #52 introduced and that this plan resolves. Scope that sentence to the payload, and let the new entry carry the attribute rule. From d32123e093335076f5d488eeec2b5ce56187668c Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 07:53:11 +0200 Subject: [PATCH 04/12] docs(cloud-events): record the disposition of the deferred Bytes kind 0055-0 assigned the deferred Bytes kind to #53 on the premise that this issue would map MetadataKind onto the abstract CloudEvents types, with Binary as the first consumer. This plan models the JSON encoding instead, and Binary is not one: the JSON Event Format renders it as a base64 string, which is what a MetadataKind.String already produces. Re-defer Bytes explicitly to its own issue rather than leaving the earlier assignment orphaned, and note that its blockers - equality semantics and defensive copying - are metadata-system decisions that a conformance fix is the wrong venue to settle. Refs #53 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UrQRRJp3wRtHPgoKfngajd --- ai-plans/0053-cloud-events-extension-attribute-types.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ai-plans/0053-cloud-events-extension-attribute-types.md b/ai-plans/0053-cloud-events-extension-attribute-types.md index 8e77094..6890cdb 100644 --- a/ai-plans/0053-cloud-events-extension-attribute-types.md +++ b/ai-plans/0053-cloud-events-extension-attribute-types.md @@ -82,6 +82,14 @@ It is still a visible behavior change for existing callers — a `MetadataKind.S The two right-hand columns are distinct things, and only the last one is modeled in code. The abstract type column records why each row lands where it does; the JSON column is the decision the writer makes. `Binary`, `URI`, `URI-reference`, and `Timestamp` are deliberately not modeled, because the library never has enough information to promise the stricter type: a `DateTime` with `DateTimeKind.Unspecified` has no offset and is therefore not a valid RFC 3339 `Timestamp`, and a `Uri` metadata value may be relative. Claiming the narrower type would be a claim the canonical text cannot back — so the code names the JSON encoding it actually decides and stays silent about the abstract type. +### The deferred `Bytes` kind stays deferred + +`0055-0` assigned the deferred `Bytes` kind to this issue, "since CloudEvents `Binary` is the first concrete consumer of it", as part of a sequencing note that expected #53 to become "a lookup from `MetadataKind`" to CloudEvents *types*. This plan models the JSON *encoding* instead, for the reasons above. That change removes the premise: `Binary` is not a JSON encoding. The JSON Event Format renders it as a base64 string, so a `Bytes` kind would select the same `String` encoding that base64 text in a `MetadataKind.String` already selects today. It would change no byte on the wire, no acceptance criterion here, and nothing about the four encodings the writer chooses between. + +The blockers `0055-0` recorded are also untouched by anything in this plan, and they are metadata-system questions rather than CloudEvents ones: whether equality over a `byte[]` payload is structural (O(n) comparison and hashing for values used as `MetadataObject` entries) or by reference (inconsistent with every other kind), and whether the factory copies defensively to preserve immutability. Settling those inside a conformance fix would be the wrong venue, and adding the kind would touch every exhaustive `MetadataKind` switch in the library — `GetJsonShape`, `GetNumberEncoding`, `ToCanonicalString`, `Equals`, `GetHashCode`, `ToString`, `HttpHeaderValueFormatter`, the OpenAPI mapper, validation message formatting, and the new encoding switch — turning a scoped bug fix into a cross-cutting change. + +`Bytes` therefore remains deferred and moves to its own issue; the sequencing note in `0055-0` is superseded on this point, and that plan is not edited, per the convention that completed plans are left as written. Deferring stays cheap for the same reason `0055-0` gave: enum values never leave the process, and base64 inside a `String` is exactly what the kind would produce, so introducing it later is additive and not wire-visible. A caller holding `byte[]` today writes base64 into a `String` attribute, which this plan encodes correctly — and the base64 alphabet is ASCII-printable, so it never trips the character rule below. + ### `String` is a character contract, not just a JSON string Choosing the `String` type is necessary but not sufficient. Core §2.4 defines `String` as a sequence of *allowable* Unicode characters and excludes three groups: the C0 and C1 control characters (U+0000–U+001F and U+007F–U+009F), the Unicode noncharacters (U+FDD0–U+FDEF and U+FFFE/U+FFFF in each of the 17 planes), and surrogate code points outside a valid pair. Escaping does not help — a `\u0001` escape in JSON still decodes to a control character in the attribute value, so the event is non-conformant however it is written. From 027716e49fed935257929149f9a0c220af10786d Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 07:56:46 +0200 Subject: [PATCH 05/12] docs(cloud-events): assert rejection instead of framework behavior The plan said Utf8JsonWriter's handling of ill-formed UTF-16 is not this library's contract and then asked for a test pinning it. Such a test asserts an incidental and breaks on a System.Text.Json update that changes nothing here. Assert the rejection through the public serialization API instead, which is the behavior the library owns. Also scope the kind matrix to non-null primitives: an omitted null property has no JsonValueKind or text, and its coverage lives in the omission and unset criteria. Refs #53 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UrQRRJp3wRtHPgoKfngajd --- ai-plans/0053-cloud-events-extension-attribute-types.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ai-plans/0053-cloud-events-extension-attribute-types.md b/ai-plans/0053-cloud-events-extension-attribute-types.md index 6890cdb..8140e6c 100644 --- a/ai-plans/0053-cloud-events-extension-attribute-types.md +++ b/ai-plans/0053-cloud-events-extension-attribute-types.md @@ -18,7 +18,7 @@ This plan resolves the open decision in favor of mapping rather than deviating: - [ ] The rejection holds on every write path, including a custom `ICloudEventsAttributeConversionService` and a directly constructed `CloudEventsEnvelopeForWriting`, and the envelope carries no partially written property when it fails. No conformant attribute is scanned more than once per event. - [ ] The character rule is public API, so a conversion service can apply the same check at its own seam and fail before serialization starts. Tests cover a C0 control character, a C1 control character, a lone high surrogate, a lone low surrogate, a noncharacter, a `Char` value holding one of these, and a valid surrogate pair plus non-ASCII text that must be accepted unchanged. - [ ] `ToCloudEvent_ShouldWriteDecimalExtensionAttribute_AsUnquotedNumber` is replaced by a test pinning the quoted, canonical decimal text. -- [ ] A test matrix covers every primitive `MetadataKind` in an extension attribute, including all four `Int64` range boundaries (`int.MinValue`, `int.MaxValue`, and the first value outside the range on each side). +- [ ] A test matrix covers every non-null primitive `MetadataKind` in an extension attribute, including all four `Int64` range boundaries (`int.MinValue`, `int.MaxValue`, and the first value outside the range on each side). `Null` is covered by the omission and unset criteria above, having no JSON value to assert on. - [ ] `MetadataValue.TryGetInt64` returns the value for a `MetadataKind.String` holding its canonical text, guarded by a round trip so that `"+5"`, `"-0"`, `"01234"`, `" 5"`, and out-of-range text are rejected. `Int64` therefore matches `UInt64`, `Single`, `Char`, and `Decimal`, which already parse their canonical encodings. - [ ] An extension attribute holding an `Int64` outside int32 range reads back through `TryGetInt64` with its original value, so the value-dependent encoding is not observable through the typed accessor on either side of the boundary. - [ ] The `Int64` encoding is documented as value-dependent on the public encoding API and in the README: one attribute name can appear as a JSON number in one event and a JSON string in the next, which deviates from the stable-type expectation in core §2.3. The documentation names the supported way to obtain a stable `String` attribute — a `CloudEventsAttributeConverter` that converts the value before it reaches the writer — and a test pins both the instability across two events and the converter that removes it. @@ -106,7 +106,7 @@ The exception is an `InvalidOperationException` naming the attribute and the off Scanning is limited to the three text-bearing kinds, and the per-character test is a single range comparison for the ASCII-printable majority, with the noncharacter and surrogate work reached only above U+D7FF. A conformant attribute therefore pays one linear pass over text the writer is about to walk again anyway. Attribute *names* need no new check — `IsValidExtensionAttributeName` already restricts them to lowercase alphanumerics. -`Utf8JsonWriter`'s own behavior for ill-formed UTF-16 (replacement versus exception) is not this library's contract to define and must not be relied on: validation runs before the text reaches the writer, and a test should pin what the writer does with a lone surrogate so the interaction is known rather than assumed. +`Utf8JsonWriter`'s own behavior for ill-formed UTF-16 — replacement versus exception — is not this library's contract to define, and no test should pin it: it is incidental to the promise being made, and a test asserting it would break on a `System.Text.Json` update that changes nothing about this library. What matters is that the writer never sees such text. The lone-surrogate test therefore asserts the rejection through the public serialization API, which is the behavior the library actually owns. Two gaps remain and are deliberate, not oversights: @@ -187,6 +187,6 @@ A binary content-mode writer would not reuse this enum: attributes become header ### Testing notes -The matrix belongs on the public `ToCloudEvent` surface rather than on the writer helper, so it pins the shipped behavior: for each primitive kind, assert both `JsonValueKind` and the value's text. The four `Int64` boundaries are the only non-obvious cases — `int.MinValue` and `int.MaxValue` must be numbers, `(long) int.MinValue - 1` and `(long) int.MaxValue + 1` must be the corresponding quoted digits. +The matrix belongs on the public `ToCloudEvent` surface rather than on the writer helper, so it pins the shipped behavior: for every non-null primitive kind, assert both `JsonValueKind` and the value's text. `Null` is not in the matrix — an omitted property has neither — and is covered by the three null tests below instead. The four `Int64` boundaries are the only non-obvious cases — `int.MinValue` and `int.MaxValue` must be numbers, `(long) int.MinValue - 1` and `(long) int.MaxValue + 1` must be the corresponding quoted digits. The null rule needs three tests, because writing and reading are now separate statements: a `Null` metadata value yields an envelope with no such property; a hand-written envelope carrying `"ext": null` — the form other producers may send — reads back without an `ext` entry; and a null extension attribute does not displace a payload-metadata entry of the same key. From 9f26d8428eb8c55968445fc8c9abd2b3ea93d01b Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 08:02:49 +0200 Subject: [PATCH 06/12] docs(cloud-events): make the public writer take the attribute, not the value A value-only overload cannot meet the failure contract: the caller must write the property name first, so validation cannot name the attribute, a failure leaves an incomplete property behind, and null omission is already impossible by the time the value is reached. Take the name and value together. That method owns validation, omission, WritePropertyName, and emission, which removes both the shared private core and the carve-out that wrote null when the value writer was called directly. Name policy splits on legitimacy: never-valid names are rejected there, while skipping standard names stays with the envelope writer that knows they were already emitted. Refs #53 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UrQRRJp3wRtHPgoKfngajd --- ...-cloud-events-extension-attribute-types.md | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/ai-plans/0053-cloud-events-extension-attribute-types.md b/ai-plans/0053-cloud-events-extension-attribute-types.md index 8140e6c..ae64034 100644 --- a/ai-plans/0053-cloud-events-extension-attribute-types.md +++ b/ai-plans/0053-cloud-events-extension-attribute-types.md @@ -15,7 +15,7 @@ This plan resolves the open decision in favor of mapping rather than deviating: - [ ] The kind-to-JSON-encoding decision is reachable through one public API, is named for the JSON encoding it selects rather than for the abstract CloudEvents type system, and is applied at the single extension-attribute write site. A `MetadataKind` declared later fails the Release build instead of silently picking a JSON form. - [ ] A complex metadata value that reaches the extension-attribute writer is rejected with an exception naming the kind, rather than emitting a nested JSON array or object. - [ ] Extension attribute text that core §2.4 excludes from `String` — C0 and C1 control characters, Unicode noncharacters, and surrogate code points outside a valid pair — is rejected at the write boundary with an exception identifying the attribute and the offending code point. Values are never normalized, and kinds whose canonical text is machine-generated are not scanned. -- [ ] The rejection holds on every write path, including a custom `ICloudEventsAttributeConversionService` and a directly constructed `CloudEventsEnvelopeForWriting`, and the envelope carries no partially written property when it fails. No conformant attribute is scanned more than once per event. +- [ ] The rejection holds on every write path, including a custom `ICloudEventsAttributeConversionService` and a directly constructed `CloudEventsEnvelopeForWriting`, and the envelope carries no partially written property when it fails — the public writer takes the attribute name and value together, so it owns validation, null omission, and the property name as one unit. No conformant attribute is scanned more than once per event. - [ ] The character rule is public API, so a conversion service can apply the same check at its own seam and fail before serialization starts. Tests cover a C0 control character, a C1 control character, a lone high surrogate, a lone low surrogate, a noncharacter, a `Char` value holding one of these, and a valid surrogate pair plus non-ASCII text that must be accepted unchanged. - [ ] `ToCloudEvent_ShouldWriteDecimalExtensionAttribute_AsUnquotedNumber` is replaced by a test pinning the quoted, canonical decimal text. - [ ] A test matrix covers every non-null primitive `MetadataKind` in an extension attribute, including all four `Int64` range boundaries (`int.MinValue`, `int.MaxValue`, and the first value outside the range on each side). `Null` is covered by the omission and unset criteria above, having no JSON value to assert on. @@ -100,7 +100,7 @@ These values are rejected, not normalized. Silently stripping or replacing a cha Enforcement belongs at the write boundary, not at the conversion seam. `DefaultCloudEventsAttributeConversionService.ValidateAttributeValue` is bypassed by any custom `ICloudEventsAttributeConversionService` and by a directly constructed `CloudEventsEnvelopeForWriting`, whose constructor takes the attribute `MetadataObject` and is public. Validating only there would make the character rule advisory while the complex-kind rule is absolute — an inconsistency with no justification, since both protect the same invariant. `WriteExtensionAttributes` therefore performs the check itself, before `WritePropertyName`, so no path can emit prohibited text and no half-written property is left behind when it fails. That site also holds the attribute name the exception message needs; the value writer alone does not. -To keep this to one pass, the two public entry points share a private core: `WriteExtensionAttributes` validates with the name in hand and calls the unvalidated core, while `WriteCloudEventsExtensionAttributeValue` validates without a name and calls the same core. Neither path scans twice, and a direct caller of the public value writer is protected too. The conversion service does not repeat the scan — a conformant attribute would then pay for it twice on every event — but `CloudEventsAttributeText` is public precisely so a custom service can run the check at its own seam when it prefers failing at `ToCloudEvent` time over failing mid-serialization. +There is exactly one place that writes an attribute, so the text is scanned once and every caller is covered by construction. The conversion service does not repeat the scan — a conformant attribute would then pay for it twice on every event — but `CloudEventsAttributeText` is public precisely so a custom service can run the check at its own seam when it prefers failing at `ToCloudEvent` time over failing mid-serialization. The exception is an `InvalidOperationException` naming the attribute and the offending code point, matching the complex-kind throw at the same site. A conversion service applying the rule earlier throws `ArgumentException` from its own seam, as `ValidateAttributeValue` already does for names and complex values. @@ -120,7 +120,7 @@ The JSON Event Format permits `null` for an attribute and requires a decoder to - **Reading.** `CloudEventsEnvelopeJsonReader` currently adds every extension attribute to the builder, so `"ext": null` becomes a `MetadataKind.Null` entry. That contradicts the rule: the decoded event must look as though `ext` was never there. The null token is skipped before the builder sees it, which also means a null attribute can no longer replace a payload-metadata value of the same key under `CloudEventsAttributeConflictStrategy`/`MergeStrategy`. Inbound envelopes from other producers get the same treatment; this is not limited to what this library writes. - **Writing.** Because a conformant decoder must ignore it, emitting `null` conveys nothing, so the attribute is omitted entirely rather than written as `null`. The two are semantically identical under the format and omission is the cheaper of the two. This changes the bytes on the wire for a `Null` metadata value annotated for extension attributes. -Omission has to be decided before the property name is written, so `WriteExtensionAttributes` consults the encoding and skips the pair; `WriteCloudEventsExtensionAttributeValue` still writes JSON `null` when called directly, because by then the caller has already committed to a property name. Standard attributes already behave this way: `GetStringAttribute` excludes `MetadataKind.Null` explicitly, and `ReadOptionalStringValue` maps an inbound `null` to "not present". +Omission has to be decided before the property name is written, which is why the public writer takes the name and the value together: it consults the encoding first and returns without writing anything for a null. No entry point exists that can be reached after a property name is already committed, so there is no path on which a null attribute still has to be written as `null`. Standard attributes already behave this way: `GetStringAttribute` excludes `MetadataKind.Null` explicitly, and `ReadOptionalStringValue` maps an inbound `null` to "not present". The consequence for the metadata model is that a `Null` extension attribute does not round-trip. That is the correct reading of the format rather than a defect: CloudEvents has no way to say "this attribute is present and empty". @@ -149,7 +149,13 @@ namespace Light.PortableResults.CloudEvents.Writing.Json; public static class JsonCloudEventsExtensions { - public static void WriteCloudEventsExtensionAttributeValue(this Utf8JsonWriter writer, MetadataValue value); + // Writes one complete extension attribute: omits it entirely for a null value, otherwise validates + // the text, writes the property name, and emits the value in its CloudEvents JSON encoding. + public static void WriteCloudEventsExtensionAttribute( + this Utf8JsonWriter writer, + string attributeName, + MetadataValue value + ); } ``` @@ -159,7 +165,11 @@ The enum lives in `Light.PortableResults.CloudEvents` rather than under `Writing Only the value-level classification is public. A kind-level overload would answer differently from the writer for an out-of-range `Int64`, which is a footgun in a rule whose whole point is having one answer. `GetCloudEventsAttributeJsonEncoding` is implemented over an exhaustive `switch` expression on `MetadataKind` with no default arm, following `MetadataKindExtensions`: `CS8524` stays suppressed for unnamed values, while a newly declared named kind produces `CS8509`, which is an error under `TreatWarningsAsErrors` in Release. `Array` and `Object` throw `InvalidOperationException` from that switch — a branch a caller can reach directly, so it is testable without a contrived writer setup. -`WriteCloudEventsExtensionAttributeValue` dispatches on the classification and validates the text before delegating to the private core that `WriteExtensionAttributes` also uses, so both public entry points enforce the same rule and neither scans twice. It is public because a custom envelope writer needs the same rule — and because `CloudEventsEnvelopeForWriting` is a record struct whose primary constructor accepts `ExtensionAttributes` outright, a caller can reach the writer without passing through any conversion service at all. The `MetadataValueAnnotation` argument that `WriteMetadataValue` takes disappears: it only ever filtered complex children, which can no longer occur, and the top level was never filtered by it — attributes are already selected by annotation in `CloudEventsResultExtensions.ConvertMetadataToCloudEventsAttributes`. +The public method takes the name and the value, because the attribute — not the value — is the unit the rule applies to. Every part of the contract needs the pair: omission is a decision about whether the property exists at all, the exception message names the attribute, and "no partially written property" is only enforceable by whoever calls `WritePropertyName`. A value-only method would push all three onto the caller and could satisfy none of them. `WriteExtensionAttributes` therefore reduces to a filter plus a loop over this one method, and a custom writer that calls it gets the identical rule rather than a reimplementation of it — which matters because `CloudEventsEnvelopeForWriting` is a record struct whose primary constructor accepts `ExtensionAttributes` outright, so the writer is reachable without passing through any conversion service. + +The `MetadataValueAnnotation` argument that `WriteMetadataValue` takes disappears: it only ever filtered complex children, which can no longer occur, and the top level was never filtered by it — attributes are already selected by annotation in `CloudEventsResultExtensions.ConvertMetadataToCloudEventsAttributes`. + +Name policy splits along whether a name is illegitimate or merely written elsewhere. `data`, `data_base64`, and `lproutcome` are never valid as extension attributes, so the method rejects them exactly as `ValidateAttributeName` does at the conversion seam. The standard names are legitimate attributes that this integration renders from `ResolveAttributes`, and only the envelope writer knows they have already been emitted, so skipping them stays in `WriteExtensionAttributes` where that knowledge lives. A custom writer doing its own envelope assembly owns that decision, and the documentation says so. The throw for complex kinds is a behavior change only on the paths that bypass `DefaultCloudEventsAttributeConversionService.ValidateAttributeValue` — a custom conversion service, or an envelope constructed directly — which today produce a nested JSON value that no CloudEvents receiver can type. Failing fast is the better contract, and it matches the existing `MetadataNumberEncoding.None` arm in `MetadataExtensions.WriteNumberValue`. From 7dc6e6fb48db47e656eb77fb27246c254d1330c0 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 08:04:40 +0200 Subject: [PATCH 07/12] docs(cloud-events): scope the single-scan guarantee to the built-in path The criterion promised that no conformant attribute is scanned twice, while the design offers preflight validation in a custom conversion service. Since writer validation stays mandatory, preflighting necessarily adds a second pass, so the unscoped promise was unkeepable. Scope it to the built-in write path and state the trade at the seam: the second scan buys an ArgumentException at ToCloudEvent time, before any byte is serialized. Refs #53 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UrQRRJp3wRtHPgoKfngajd --- ai-plans/0053-cloud-events-extension-attribute-types.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ai-plans/0053-cloud-events-extension-attribute-types.md b/ai-plans/0053-cloud-events-extension-attribute-types.md index ae64034..80d56c8 100644 --- a/ai-plans/0053-cloud-events-extension-attribute-types.md +++ b/ai-plans/0053-cloud-events-extension-attribute-types.md @@ -15,7 +15,7 @@ This plan resolves the open decision in favor of mapping rather than deviating: - [ ] The kind-to-JSON-encoding decision is reachable through one public API, is named for the JSON encoding it selects rather than for the abstract CloudEvents type system, and is applied at the single extension-attribute write site. A `MetadataKind` declared later fails the Release build instead of silently picking a JSON form. - [ ] A complex metadata value that reaches the extension-attribute writer is rejected with an exception naming the kind, rather than emitting a nested JSON array or object. - [ ] Extension attribute text that core §2.4 excludes from `String` — C0 and C1 control characters, Unicode noncharacters, and surrogate code points outside a valid pair — is rejected at the write boundary with an exception identifying the attribute and the offending code point. Values are never normalized, and kinds whose canonical text is machine-generated are not scanned. -- [ ] The rejection holds on every write path, including a custom `ICloudEventsAttributeConversionService` and a directly constructed `CloudEventsEnvelopeForWriting`, and the envelope carries no partially written property when it fails — the public writer takes the attribute name and value together, so it owns validation, null omission, and the property name as one unit. No conformant attribute is scanned more than once per event. +- [ ] The rejection holds on every write path, including a custom `ICloudEventsAttributeConversionService` and a directly constructed `CloudEventsEnvelopeForWriting`, and the envelope carries no partially written property when it fails — the public writer takes the attribute name and value together, so it owns validation, null omission, and the property name as one unit. On the built-in write path no conformant attribute is scanned more than once per event; a caller that opts into preflight validation in a custom conversion service is choosing a second scan in exchange for failing before serialization starts. - [ ] The character rule is public API, so a conversion service can apply the same check at its own seam and fail before serialization starts. Tests cover a C0 control character, a C1 control character, a lone high surrogate, a lone low surrogate, a noncharacter, a `Char` value holding one of these, and a valid surrogate pair plus non-ASCII text that must be accepted unchanged. - [ ] `ToCloudEvent_ShouldWriteDecimalExtensionAttribute_AsUnquotedNumber` is replaced by a test pinning the quoted, canonical decimal text. - [ ] A test matrix covers every non-null primitive `MetadataKind` in an extension attribute, including all four `Int64` range boundaries (`int.MinValue`, `int.MaxValue`, and the first value outside the range on each side). `Null` is covered by the omission and unset criteria above, having no JSON value to assert on. @@ -100,7 +100,9 @@ These values are rejected, not normalized. Silently stripping or replacing a cha Enforcement belongs at the write boundary, not at the conversion seam. `DefaultCloudEventsAttributeConversionService.ValidateAttributeValue` is bypassed by any custom `ICloudEventsAttributeConversionService` and by a directly constructed `CloudEventsEnvelopeForWriting`, whose constructor takes the attribute `MetadataObject` and is public. Validating only there would make the character rule advisory while the complex-kind rule is absolute — an inconsistency with no justification, since both protect the same invariant. `WriteExtensionAttributes` therefore performs the check itself, before `WritePropertyName`, so no path can emit prohibited text and no half-written property is left behind when it fails. That site also holds the attribute name the exception message needs; the value writer alone does not. -There is exactly one place that writes an attribute, so the text is scanned once and every caller is covered by construction. The conversion service does not repeat the scan — a conformant attribute would then pay for it twice on every event — but `CloudEventsAttributeText` is public precisely so a custom service can run the check at its own seam when it prefers failing at `ToCloudEvent` time over failing mid-serialization. +There is exactly one place that writes an attribute, so on the built-in path the text is scanned once and every caller is covered by construction. `DefaultCloudEventsAttributeConversionService` deliberately does not repeat the scan: writer validation is mandatory, so a second check there would make every conformant attribute pay twice on every event to buy nothing the writer does not already guarantee. + +`CloudEventsAttributeText` is public so that a custom service can nonetheless run the check at its own seam. That is a real trade and not a free option — the writer still validates afterwards, so preflighting costs a second pass over conforming text. It buys failing at `ToCloudEvent` time, with an `ArgumentException` naming the offending attribute before a single byte is serialized, which is worth the pass to a caller assembling attributes from untrusted input. The single-scan guarantee is therefore a property of the default path, not of every configuration. The exception is an `InvalidOperationException` naming the attribute and the offending code point, matching the complex-kind throw at the same site. A conversion service applying the rule earlier throws `ArgumentException` from its own seam, as `ValidateAttributeValue` already does for names and complex values. From 50f0c65a74f756158413d6c5a1cc09865e4e1f38 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 08:05:33 +0200 Subject: [PATCH 08/12] docs(cloud-events): narrow two overreaching statements Standard-attribute writing was called spec-conformant while the same plan records that their text is never scanned for the characters CloudEvents excludes from a String. Claim only what holds: their JSON rendering is unaffected by this plan. The release-note section scoped every entry to extension attributes, but TryGetInt64 reading canonical text applies to any MetadataValue of kind String regardless of origin. Give that entry its own global scope. Refs #53 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UrQRRJp3wRtHPgoKfngajd --- ai-plans/0053-cloud-events-extension-attribute-types.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ai-plans/0053-cloud-events-extension-attribute-types.md b/ai-plans/0053-cloud-events-extension-attribute-types.md index 80d56c8..f224f6a 100644 --- a/ai-plans/0053-cloud-events-extension-attribute-types.md +++ b/ai-plans/0053-cloud-events-extension-attribute-types.md @@ -175,11 +175,13 @@ Name policy splits along whether a name is illegitimate or merely written elsewh The throw for complex kinds is a behavior change only on the paths that bypass `DefaultCloudEventsAttributeConversionService.ValidateAttributeValue` — a custom conversion service, or an envelope constructed directly — which today produce a nested JSON value that no CloudEvents receiver can type. Failing fast is the better contract, and it matches the existing `MetadataNumberEncoding.None` arm in `MetadataExtensions.WriteNumberValue`. -Standard attributes never reach this path. `WriteExtensionAttributes` skips `CloudEventsConstants.StandardAttributeNames`, and `ResolveAttributes` renders them from `ToCanonicalString()` into `WriteString` calls, which is already spec-conformant. +Standard attributes never reach this path. `WriteExtensionAttributes` skips `CloudEventsConstants.StandardAttributeNames`, and `ResolveAttributes` renders them from `ToCanonicalString()` into `WriteString` calls, so their JSON shape already matches what the type-system mapping would choose and this plan leaves it untouched. That is a statement about their rendering, not a claim that they are fully conformant: their text is not scanned for the characters `String` excludes, which is the gap recorded above. ### Release notes -Everything here ships in the unreleased 0.7.0, so the entries go into that block of `` rather than a new one. Five behavior changes are visible to a consumer and belong under **Breaking changes**: the string encoding of `Double`, `Single`, `Decimal`, and out-of-range `Int64` in extension attributes; the omission of null extension attributes on write; their treatment as unset on read; the rejection of text that CloudEvents excludes from a `String`; and `TryGetInt64` now returning `true` for a string value holding canonical integer text. Each needs the scope stated — extension attributes only, with `data` payloads and `problem+json` bodies untouched — because the natural reading of "decimals are now strings" is that it applies everywhere. +Everything here ships in the unreleased 0.7.0, so the entries go into that block of `` rather than a new one. Five behavior changes are visible to a consumer and belong under **Breaking changes**. Four are confined to CloudEvents extension attributes: the string encoding of `Double`, `Single`, `Decimal`, and out-of-range `Int64`; the omission of null attributes on write; their treatment as unset on read; and the rejection of text that CloudEvents excludes from a `String`. Each of those four needs its scope stated — extension attributes only, with `data` payloads and `problem+json` bodies untouched — because the natural reading of "decimals are now strings" is that it applies everywhere. + +The fifth is not a CloudEvents change and must not be filed under that scope: `TryGetInt64` returning `true` for a string holding canonical integer text applies to every `MetadataValue` of kind `String`, whatever produced it — an HTTP header, a JSON body, or a literal in caller code. Its entry belongs with the metadata accessors and states the round-trip guard, so a reader can tell that `"01234"` and `"+5"` are still rejected. The existing decimal entry has to be corrected rather than merely supplemented. It currently states that decimal metadata values "are serialized as JSON numbers instead of quoted strings", which after this plan is true of `data` and `problem+json` but false of extension attributes — the same conflation that #52 introduced and that this plan resolves. Scope that sentence to the payload, and let the new entry carry the attribute rule. From afb20905a13c9bae9fa887349cd05e7fc7f63c65 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 08:09:49 +0200 Subject: [PATCH 09/12] docs(cloud-events): validate the full extension-name grammar at the writer The public writer owned the whole attribute but only rejected data, data_base64, and lproutcome, so an invalid name reached the envelope whenever conversion was bypassed, and a standard name got the extension mapping applied to it: "type" with an in-range Int64 emits a JSON number where the specification requires a string. Reject every reserved and standard name and enforce the lowercase alphanumeric grammar. WriteExtensionAttributes still skips the standard names ahead of the call, so the guard exists for external callers and is tested directly. Also narrow the validity guarantee to the extension attributes the library emits, since standard-attribute character validation is deferred. Refs #53 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UrQRRJp3wRtHPgoKfngajd --- .../0053-cloud-events-extension-attribute-types.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/ai-plans/0053-cloud-events-extension-attribute-types.md b/ai-plans/0053-cloud-events-extension-attribute-types.md index f224f6a..f4f3c98 100644 --- a/ai-plans/0053-cloud-events-extension-attribute-types.md +++ b/ai-plans/0053-cloud-events-extension-attribute-types.md @@ -14,6 +14,7 @@ This plan resolves the open decision in favor of mapping rather than deviating: - [ ] An inbound extension attribute whose JSON value is `null` is treated as unset: it does not appear in the metadata produced by reading the envelope, and it does not replace a value of the same key coming from the payload. - [ ] The kind-to-JSON-encoding decision is reachable through one public API, is named for the JSON encoding it selects rather than for the abstract CloudEvents type system, and is applied at the single extension-attribute write site. A `MetadataKind` declared later fails the Release build instead of silently picking a JSON form. - [ ] A complex metadata value that reaches the extension-attribute writer is rejected with an exception naming the kind, rather than emitting a nested JSON array or object. +- [ ] The public extension-attribute writer rejects any name that is not a legal extension attribute name — null, empty or whitespace, containing anything outside lowercase alphanumerics, a reserved name, or a standard CloudEvents name — so a name reaching the value encoding is guaranteed to be one the extension mapping may legally be applied to. Guard tests cover each category directly against the public method, including `type` with an in-range `Int64`, which would otherwise emit a JSON number for an attribute the specification requires to be a string. - [ ] Extension attribute text that core §2.4 excludes from `String` — C0 and C1 control characters, Unicode noncharacters, and surrogate code points outside a valid pair — is rejected at the write boundary with an exception identifying the attribute and the offending code point. Values are never normalized, and kinds whose canonical text is machine-generated are not scanned. - [ ] The rejection holds on every write path, including a custom `ICloudEventsAttributeConversionService` and a directly constructed `CloudEventsEnvelopeForWriting`, and the envelope carries no partially written property when it fails — the public writer takes the attribute name and value together, so it owns validation, null omission, and the property name as one unit. On the built-in write path no conformant attribute is scanned more than once per event; a caller that opts into preflight validation in a custom conversion service is choosing a second scan in exchange for failing before serialization starts. - [ ] The character rule is public API, so a conversion service can apply the same check at its own seam and fail before serialization starts. Tests cover a C0 control character, a C1 control character, a lone high surrogate, a lone low surrogate, a noncharacter, a `Char` value holding one of these, and a valid surrogate pair plus non-ASCII text that must be accepted unchanged. @@ -62,7 +63,7 @@ One thing is unavailable at any price: a stable `Integer`. For a value beyond in **Value-dependent** keeps the common case natural — small integers stay JSON numbers and read back as `MetadataKind.Int64` — and confines the instability to keys whose values straddle the boundary. Such a key could never have carried a valid `Integer` definition anyway, and a key that consistently holds small or consistently holds large values is stable in practice. Its cost is that one attribute name can change JSON type between events, and that the "encoding is a function of the kind" rule acquires an exception. -A stable type is guidance to whoever *defines* an extension, and this library cannot see that definition: it can guarantee that every event it emits is valid, not that a caller's values fit a type the caller never declared. That argument supports validity in both designs and does not by itself decide between them. +A stable type is guidance to whoever *defines* an extension, and this library cannot see that definition: it can guarantee that every extension attribute it emits is valid, not that a caller's values fit a type the caller never declared. That argument supports validity in both designs and does not by itself decide between them. **The decision is value-dependent encoding together with canonical-string parsing in `TryGetInt64`.** The parsing arm is what makes the threshold defensible: without it, an out-of-range value writes as a string and then reads back as something `TryGetInt64` refuses, so the threshold would quietly break exactly the consumers it was meant to keep working. With it, the accessor is total across both encodings — whichever side of the boundary a value falls on, a consumer calling `TryGetInt64` gets its `long` back. What remains observable is `Kind` and the raw JSON, not the ability to read the value. @@ -151,8 +152,9 @@ namespace Light.PortableResults.CloudEvents.Writing.Json; public static class JsonCloudEventsExtensions { - // Writes one complete extension attribute: omits it entirely for a null value, otherwise validates - // the text, writes the property name, and emits the value in its CloudEvents JSON encoding. + // Writes one complete extension attribute: rejects a name that is not a legal extension attribute + // name, omits the attribute entirely for a null value, otherwise validates the text, writes the + // property name, and emits the value in its CloudEvents JSON encoding. public static void WriteCloudEventsExtensionAttribute( this Utf8JsonWriter writer, string attributeName, @@ -171,7 +173,11 @@ The public method takes the name and the value, because the attribute — not th The `MetadataValueAnnotation` argument that `WriteMetadataValue` takes disappears: it only ever filtered complex children, which can no longer occur, and the top level was never filtered by it — attributes are already selected by annotation in `CloudEventsResultExtensions.ConvertMetadataToCloudEventsAttributes`. -Name policy splits along whether a name is illegitimate or merely written elsewhere. `data`, `data_base64`, and `lproutcome` are never valid as extension attributes, so the method rejects them exactly as `ValidateAttributeName` does at the conversion seam. The standard names are legitimate attributes that this integration renders from `ResolveAttributes`, and only the envelope writer knows they have already been emitted, so skipping them stays in `WriteExtensionAttributes` where that knowledge lives. A custom writer doing its own envelope assembly owns that decision, and the documentation says so. +The method validates the name in full: non-null, non-empty, lowercase alphanumeric per `IsValidExtensionAttributeName`, and absent from both `ForbiddenConvertedAttributeNames` and `StandardAttributeNames`. Anything reaching it is then guaranteed to be a name that may legally carry the extension mapping. + +Rejecting the standard names matters for correctness, not tidiness. This method applies the *extension* encoding, and the standard attributes do not use it — `type` is a CloudEvents `String`, so `WriteCloudEventsExtensionAttribute(writer, "type", MetadataValue.FromInt64(5))` would emit `"type": 5`, a JSON number where the specification requires a string. Accepting a standard name would mean accepting a guaranteed-invalid envelope, which is worse than the duplicate-property problem that first suggested leaving the decision to the caller. The grammar check has the same justification as moving text validation to the writer: `DefaultCloudEventsAttributeConversionService.ValidateAttributeName` is bypassed by a custom conversion service or a directly constructed envelope, so a name like `Bad_Name` or an empty string reaches the writer with nothing in its way. + +`WriteExtensionAttributes` keeps skipping the standard names before it calls the method, because they are rendered from `ResolveAttributes` and only the envelope writer knows that. The built-in path therefore never reaches the throw; the guard exists for external callers and is tested directly against the public method, which is the case `tests/AGENTS.md` reserves solitary tests for. The throw for complex kinds is a behavior change only on the paths that bypass `DefaultCloudEventsAttributeConversionService.ValidateAttributeValue` — a custom conversion service, or an envelope constructed directly — which today produce a nested JSON value that no CloudEvents receiver can type. Failing fast is the better contract, and it matches the existing `MetadataNumberEncoding.None` arm in `MetadataExtensions.WriteNumberValue`. From ad6185f32a0f9da79b082d14f3403ae2583ec3a3 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 08:26:40 +0200 Subject: [PATCH 10/12] docs(cloud-events): condense the plan without dropping decisions Cut the plan from ~5.6k to ~2.3k words. Every acceptance criterion and technical decision survives; the reduction comes from merging related criteria and trimming rationale prose, not from removing scope. Repairs three defects the condensing introduced: an inverted sentence about the decimal test replacement, an acceptance criterion that read as if TryGetInt64 had stopped accepting MetadataKind.Int64, and a reference to a nonexistent MetadataValue.String. Restores the implementation anchors and the two rejected-alternative arguments that the shortening lost: MetadataValue.cs:385, DefaultHttpHeaderParsingService, MetadataKindExtensions, TreatWarningsAsErrors, the System.Text.Json reason for not pinning Utf8JsonWriter behavior, the map-versus-document-the-deviation decision, and the always-String failure-mode argument. Records one new constraint found while checking those anchors: IsValidExtensionAttributeName is private on DefaultCloudEventsAttributeConversionService, so the name grammar has to move somewhere both it and the writer can call. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UrQRRJp3wRtHPgoKfngajd --- ...-cloud-events-extension-attribute-types.md | 183 ++++-------------- 1 file changed, 42 insertions(+), 141 deletions(-) diff --git a/ai-plans/0053-cloud-events-extension-attribute-types.md b/ai-plans/0053-cloud-events-extension-attribute-types.md index f4f3c98..52b60f1 100644 --- a/ai-plans/0053-cloud-events-extension-attribute-types.md +++ b/ai-plans/0053-cloud-events-extension-attribute-types.md @@ -2,48 +2,34 @@ ## Rationale -`JsonCloudEventsExtensions.WriteExtensionAttributes` writes every converted attribute through `SharedJsonSerialization.Writing.MetadataExtensions.WriteMetadataValue`, which emits each `MetadataKind` in its natural JSON form. That is correct for the event `data` payload and for `problem+json` bodies, but context attributes are not free-form JSON: CloudEvents core §2.4 closes the attribute type system to `Boolean`, `Integer`, `String`, `Binary`, `URI`, `URI-reference`, and `Timestamp`, §2.3 binds extension attributes to that same set, and the JSON Event Format §2.2 maps it onto exactly three JSON forms — boolean, number (integer digits only), and string — with `null` reserved as the encoding of an attribute that is not set. A fractional JSON number is therefore not the valid serialization of any CloudEvents attribute type, and a JSON number outside the int32 range is outside the only type it could belong to. Today `Double`, `Single`, `Decimal`, and out-of-range `Int64` all violate this. +`JsonCloudEventsExtensions.WriteExtensionAttributes` currently uses `SharedJsonSerialization.Writing.MetadataExtensions.WriteMetadataValue`, which emits each `MetadataKind` in its natural JSON form. That is correct for event `data` and `problem+json`, but not for context attributes: CloudEvents core §2.4 limits them to `Boolean`, `Integer`, `String`, `Binary`, `URI`, `URI-reference`, and `Timestamp`; §2.3 applies the same types to extensions; and JSON Event Format §2.2 permits only boolean, integer-number, and string representations, with `null` meaning unset. `Double`, `Single`, `Decimal`, and out-of-range `Int64` therefore violate the format today. -This plan resolves the open decision in favor of mapping rather than deviating: extension attributes are written through the CloudEvents type system, with `String` used as the spec's escape hatch for values the type system cannot express — subject to the character rules that `String` itself imposes, which the library does not enforce today either. For every non-null value the framing on the wire changes but nothing is lost. Null attributes are the one exception, and in the other direction: the format defines a null attribute as unset, so the reader must stop materializing one as `MetadataKind.Null` — a `Null` entry annotated for extension attributes therefore no longer survives a round trip, by design. +Extension attributes will instead use the CloudEvents mapping, falling back to `String` when a metadata value has no valid native attribute type and enforcing `String`'s character contract. Conforming non-null values retain their canonical text; null is deliberately lossy because the format requires readers to treat it as absent. ## Acceptance Criteria -- [ ] The decision is recorded in the codebase: extension attributes are written through the JSON Event Format's type-system mapping instead of the metadata kind's natural JSON form, documented on the public encoding API and in the README's CloudEvents section. -- [ ] In extension attributes, `Boolean` writes a JSON boolean, `Int64` inside the inclusive int32 range writes a JSON number, and every other primitive kind — including `Double`, `Single`, `Decimal`, and `Int64` outside the int32 range — writes a JSON string carrying the value's canonical invariant text. -- [ ] A `Null` metadata value produces no extension attribute at all: the property name is not written, and the envelope is byte-identical to one whose metadata never contained the entry. -- [ ] An inbound extension attribute whose JSON value is `null` is treated as unset: it does not appear in the metadata produced by reading the envelope, and it does not replace a value of the same key coming from the payload. -- [ ] The kind-to-JSON-encoding decision is reachable through one public API, is named for the JSON encoding it selects rather than for the abstract CloudEvents type system, and is applied at the single extension-attribute write site. A `MetadataKind` declared later fails the Release build instead of silently picking a JSON form. -- [ ] A complex metadata value that reaches the extension-attribute writer is rejected with an exception naming the kind, rather than emitting a nested JSON array or object. -- [ ] The public extension-attribute writer rejects any name that is not a legal extension attribute name — null, empty or whitespace, containing anything outside lowercase alphanumerics, a reserved name, or a standard CloudEvents name — so a name reaching the value encoding is guaranteed to be one the extension mapping may legally be applied to. Guard tests cover each category directly against the public method, including `type` with an in-range `Int64`, which would otherwise emit a JSON number for an attribute the specification requires to be a string. -- [ ] Extension attribute text that core §2.4 excludes from `String` — C0 and C1 control characters, Unicode noncharacters, and surrogate code points outside a valid pair — is rejected at the write boundary with an exception identifying the attribute and the offending code point. Values are never normalized, and kinds whose canonical text is machine-generated are not scanned. -- [ ] The rejection holds on every write path, including a custom `ICloudEventsAttributeConversionService` and a directly constructed `CloudEventsEnvelopeForWriting`, and the envelope carries no partially written property when it fails — the public writer takes the attribute name and value together, so it owns validation, null omission, and the property name as one unit. On the built-in write path no conformant attribute is scanned more than once per event; a caller that opts into preflight validation in a custom conversion service is choosing a second scan in exchange for failing before serialization starts. -- [ ] The character rule is public API, so a conversion service can apply the same check at its own seam and fail before serialization starts. Tests cover a C0 control character, a C1 control character, a lone high surrogate, a lone low surrogate, a noncharacter, a `Char` value holding one of these, and a valid surrogate pair plus non-ASCII text that must be accepted unchanged. -- [ ] `ToCloudEvent_ShouldWriteDecimalExtensionAttribute_AsUnquotedNumber` is replaced by a test pinning the quoted, canonical decimal text. -- [ ] A test matrix covers every non-null primitive `MetadataKind` in an extension attribute, including all four `Int64` range boundaries (`int.MinValue`, `int.MaxValue`, and the first value outside the range on each side). `Null` is covered by the omission and unset criteria above, having no JSON value to assert on. -- [ ] `MetadataValue.TryGetInt64` returns the value for a `MetadataKind.String` holding its canonical text, guarded by a round trip so that `"+5"`, `"-0"`, `"01234"`, `" 5"`, and out-of-range text are rejected. `Int64` therefore matches `UInt64`, `Single`, `Char`, and `Decimal`, which already parse their canonical encodings. -- [ ] An extension attribute holding an `Int64` outside int32 range reads back through `TryGetInt64` with its original value, so the value-dependent encoding is not observable through the typed accessor on either side of the boundary. -- [ ] The `Int64` encoding is documented as value-dependent on the public encoding API and in the README: one attribute name can appear as a JSON number in one event and a JSON string in the next, which deviates from the stable-type expectation in core §2.3. The documentation names the supported way to obtain a stable `String` attribute — a `CloudEventsAttributeConverter` that converts the value before it reaches the writer — and a test pins both the instability across two events and the converter that removes it. -- [ ] Reading a written envelope back is covered for each JSON encoding: string-mapped values return as `MetadataKind.String` with the canonical text, a null attribute is absent from the metadata rather than returned as `MetadataKind.Null`, the kind change is documented on the encoding API the way numeric-token behavior is documented on `MetadataJsonReader`, and a registered `CloudEventsAttributeParser` restores the original kind. -- [ ] Standard attributes resolved from metadata (`type`, `source`, `subject`, `dataschema`, `time`, `id`) keep their current string rendering and are unaffected by the mapping. -- [ ] Writing a `Double` or `Single` extension attribute allocates nothing after warm-up, asserted the same way as in `CanonicalFloatingPointFormatterTests`. The remaining string-mapped kinds allocate at most the one canonical string `MetadataValue.TryFormatCanonical` materializes for them today; removing that is separate work. -- [ ] `` records the new encoding and every behavior change it causes, and the existing decimal entry is corrected so that it no longer claims decimals serialize as JSON numbers everywhere. -- [ ] Test code coverage stays above 95%. +- [ ] The public encoding API and README document that extension attributes use the JSON Event Format mapping rather than each metadata kind's natural JSON shape. +- [ ] `Boolean` writes a JSON boolean; an `Int64` in the inclusive int32 range writes a JSON number; every other conforming non-null primitive writes a JSON string containing its canonical invariant text. A test pinning the quoted, canonical decimal text replaces `ToCloudEvent_ShouldWriteDecimalExtensionAttribute_AsUnquotedNumber`, and a public-surface matrix covers every non-null primitive plus all four `Int64` boundaries. +- [ ] A `Null` value omits the property and produces bytes identical to an envelope without that entry. An inbound null attribute is absent from extension metadata and cannot replace payload metadata with the same key. +- [ ] One public, value-level API classifies the JSON encoding and one public writer owns the complete name/value operation. Its exhaustive `MetadataKind` switch makes a later named kind fail the Release build; complex values throw with the kind named. +- [ ] The public writer rejects null, empty, whitespace, non-lowercase-alphanumeric, reserved, and standard attribute names before writing anything. Direct guard tests cover every category, including an in-range `Int64` named `type`. +- [ ] CloudEvents-disallowed `String` text is rejected without normalization before a property name is written, with the attribute and offending code point identified. This holds for custom conversion services and directly constructed envelopes. The public character rule is tested with C0, C1, lone high and low surrogates, a noncharacter, an invalid `Char`, and an accepted surrogate pair plus non-ASCII text. +- [ ] The default path scans only caller-controlled text — `String`, `Char`, and `Uri` — and performs at most one CloudEvents character-validation scan per conforming attribute. A custom service may deliberately preflight through the public rule, paying for a second scan to fail before serialization. +- [ ] `MetadataValue.TryGetInt64` additionally accepts a `MetadataKind.String` holding a `long`'s canonical text, and only the canonical form: the parsed value must reproduce the source text, rejecting `"+5"`, `"-0"`, `"01234"`, `" 5"`, and out-of-range text. An out-of-int32 extension value reads back through this accessor unchanged. +- [ ] The public API and README document the value-dependent `Int64` encoding, its stable-type deviation, and the `CloudEventsAttributeConverter` remedy that converts a key to `MetadataKind.String`. Tests pin both the shape change for one attribute name across two events and the stable converter. +- [ ] Read-back tests cover every JSON encoding: string-mapped values return as `MetadataKind.String`, null is absent, and a registered `CloudEventsAttributeParser` restores an original kind. The encoding API documents this asymmetry like `MetadataJsonReader` documents numeric tokens. +- [ ] Standard attributes resolved from metadata (`type`, `source`, `subject`, `dataschema`, `time`, `id`) retain their current string rendering. +- [ ] Writing `Double` and `Single` extension attributes allocates nothing after warm-up. Other string-mapped kinds allocate no more than the one canonical string that `MetadataValue.TryFormatCanonical` materializes today. +- [ ] The 0.7.0 `` records every behavior change with the correct scope and corrects the existing claim that decimals always serialize as JSON numbers. +- [ ] Test code coverage remains above 95%. ## Technical Details -### Decision and rejected alternatives +### Encoding policy -Mapping wins over documenting a deviation: portability across CloudEvents implementations is the reason the feature exists, and a receiver validating attribute types against the spec is entitled to reject a fractional number. Two alternatives were considered and rejected: - -- **An opt-out on `PortableResultsCloudEventsWriteOptions`.** It would double the write matrix and enshrine a mode whose only purpose is producing invalid events. Pre-1.0 the break is cheap; a publisher who needs a specific wire shape already has `CloudEventsAttributeConverter`. -- **Throwing for an out-of-range `Int64`.** Large integers — snowflake IDs, Unix timestamps in nanoseconds — are exactly what people put in attributes, and they are perfectly expressible as `String`. Turning a working publish into a runtime exception is a worse outcome than reframing the value. -- **Mapping every `Int64` to `String`, with `TryGetInt64` gaining canonical-string parsing.** This is the serious alternative and it is treated in its own section below. On its own, always-`String` would leave every integer attribute unreadable through `TryGetInt64`, which matches on `Kind` alone (`MetadataValue.cs:385`) — but that is a gap rather than a constraint: `TryGetUInt64`, `TryGetSingle`, `TryGetChar`, and `TryGetDecimal` all parse their canonical text behind a round-trip check (`MetadataValue.cs:475`), and `Int64` is the only numeric kind that does not. Closing it is a few lines against an established pattern, and it makes a stable `String` encoding cheap for consumers. - -### The mapping - -| `MetadataKind` | Abstract CloudEvents type (core §2.4) | JSON encoding (JSON format §2.2) | Change | +| `MetadataKind` | Abstract CloudEvents type | JSON encoding | Change | | --- | --- | --- | --- | -| `Null` | — (unset) | attribute omitted | **new** | +| `Null` | — (unset) | omitted | **new** | | `Boolean` | `Boolean` | boolean | — | | `Int64`, `-2147483648..2147483647` | `Integer` | number | — | | `Int64`, outside that range | `String` | string | **new** | @@ -51,83 +37,23 @@ Mapping wins over documenting a deviation: portability across CloudEvents implem | `UInt64`, `String`, `Char`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, `TimeSpan`, `Guid`, `Uri` | `String` | string | — | | `Array`, `Object` | — | throws | **new** | -The string text is `MetadataValue.ToCanonicalString()`, unchanged from what HTTP headers and `MetadataValue.ToString()` already produce, so the same metadata yields the same text on every transport. - -### The `Int64` arm is value-dependent, and that is part of the contract - -`Integer` is the only encoding chosen from the value rather than the kind, so one extension attribute name can appear as a JSON number in one event and a JSON string in the next — `2147483647` versus `"2147483648"`. Core §2.3 expects an extension definition to fix one type, so this is a real deviation and it is documented rather than hidden. - -One thing is unavailable at any price: a stable `Integer`. For a value beyond int32 that would mean emitting an out-of-range number, the bug this plan fixes. The genuine choice is between the value-dependent encoding above and mapping every `Int64` to `String`, and the two trade against each other as follows. - -**Always `String`** makes the encoding a pure function of the kind, which is the rule the rest of this plan follows and the only way to give an attribute name one stable type. Paired with canonical-string parsing in `TryGetInt64` — the gap noted above, where `Int64` is the only numeric kind that cannot read its own canonical text — consumer access survives: `TryGetInt64` keeps working, only `Kind` changes. It also removes the one exception to the kind-function rule, and it converts a latent, data-dependent interop failure into a single documented break: a consumer that types an attribute as `Integer` works until a value crosses 2^31 and then fails in production, which is a worse failure mode than a uniform change taken once, pre-1.0, in release notes. Its costs are that every integer attribute on the wire becomes a quoted number, `MetadataKind.Int64` no longer survives the round trip, and the `Integer` encoding has no producer until a kind exists whose domain fits int32. - -**Value-dependent** keeps the common case natural — small integers stay JSON numbers and read back as `MetadataKind.Int64` — and confines the instability to keys whose values straddle the boundary. Such a key could never have carried a valid `Integer` definition anyway, and a key that consistently holds small or consistently holds large values is stable in practice. Its cost is that one attribute name can change JSON type between events, and that the "encoding is a function of the kind" rule acquires an exception. - -A stable type is guidance to whoever *defines* an extension, and this library cannot see that definition: it can guarantee that every extension attribute it emits is valid, not that a caller's values fit a type the caller never declared. That argument supports validity in both designs and does not by itself decide between them. - -**The decision is value-dependent encoding together with canonical-string parsing in `TryGetInt64`.** The parsing arm is what makes the threshold defensible: without it, an out-of-range value writes as a string and then reads back as something `TryGetInt64` refuses, so the threshold would quietly break exactly the consumers it was meant to keep working. With it, the accessor is total across both encodings — whichever side of the boundary a value falls on, a consumer calling `TryGetInt64` gets its `long` back. What remains observable is `Kind` and the raw JSON, not the ability to read the value. - -That also settles the asymmetry with `UInt64` properly. `UInt64` was already safe as a string precisely because `TryGetUInt64` parses its canonical text; `Int64` was the outlier that could not. Once both parse, the rule reads cleanly in two layers: **every numeric kind can read its own canonical text, and on top of that the writer preserves `MetadataKind` wherever the CloudEvents type system permits.** `Int64` in int32 range is the only place it permits it, because a JSON number is what `MetadataJsonReader` turns back into `MetadataKind.Int64`. Promoting `UInt64` would add an unstable shape while preserving no kind, so it stays `String`. - -Two costs survive this and are not fixed by the parsing arm: `Kind` still differs between events for a straddling key, and a foreign consumer whose extension definition types the attribute as `Integer` still breaks when a value crosses the boundary. Only always-`String` would remove the second, and nothing removes it for a key that straddles. - -A publisher who needs one fixed type for a key has a supported way to get it: a `CloudEventsAttributeConverter` that converts the value to `MetadataKind.String` before it reaches the writer produces a stable `String` attribute for every value, matching a `String`-typed extension definition. Document that alongside the rule. - -### `TryGetInt64` reads canonical text - -`MetadataValue.TryGetInt64` gains a string arm modeled on `TryGetUInt64` (`MetadataValue.cs:475`): parse with `CultureInfo.InvariantCulture`, then accept only when re-formatting the parsed value reproduces the input exactly. The round-trip guard is what keeps this from turning the accessor into a lenient parser — `"+5"`, `"-0"`, `"01234"`, `" 5"`, and text outside `long` range are all rejected, because none of them is the canonical encoding of the value they parse to. `NumberStyles.AllowLeadingSign` replaces `TryGetUInt64`'s `NumberStyles.None`, since `Int64` is signed. - -The change is confined to what a consumer can read. The only production caller outside `MetadataObject.TryGetInt64` is `MetadataExtensions.WriteNumberValue`, which reaches it after dispatching on `GetNumberEncoding() == Int64`, so `Kind` is already `Int64` there and the string arm is unreachable. The HTTP header reader does not need it either: `DefaultHttpHeaderParsingService` already sniffs integral header text into `MetadataValue.FromInt64` itself. - -It is still a visible behavior change for existing callers — a `MetadataKind.String` value holding `"5"` now returns `true` where it returned `false` — so it belongs in the release notes rather than passing as an internal fix. - -The two right-hand columns are distinct things, and only the last one is modeled in code. The abstract type column records why each row lands where it does; the JSON column is the decision the writer makes. `Binary`, `URI`, `URI-reference`, and `Timestamp` are deliberately not modeled, because the library never has enough information to promise the stricter type: a `DateTime` with `DateTimeKind.Unspecified` has no offset and is therefore not a valid RFC 3339 `Timestamp`, and a `Uri` metadata value may be relative. Claiming the narrower type would be a claim the canonical text cannot back — so the code names the JSON encoding it actually decides and stays silent about the abstract type. - -### The deferred `Bytes` kind stays deferred - -`0055-0` assigned the deferred `Bytes` kind to this issue, "since CloudEvents `Binary` is the first concrete consumer of it", as part of a sequencing note that expected #53 to become "a lookup from `MetadataKind`" to CloudEvents *types*. This plan models the JSON *encoding* instead, for the reasons above. That change removes the premise: `Binary` is not a JSON encoding. The JSON Event Format renders it as a base64 string, so a `Bytes` kind would select the same `String` encoding that base64 text in a `MetadataKind.String` already selects today. It would change no byte on the wire, no acceptance criterion here, and nothing about the four encodings the writer chooses between. - -The blockers `0055-0` recorded are also untouched by anything in this plan, and they are metadata-system questions rather than CloudEvents ones: whether equality over a `byte[]` payload is structural (O(n) comparison and hashing for values used as `MetadataObject` entries) or by reference (inconsistent with every other kind), and whether the factory copies defensively to preserve immutability. Settling those inside a conformance fix would be the wrong venue, and adding the kind would touch every exhaustive `MetadataKind` switch in the library — `GetJsonShape`, `GetNumberEncoding`, `ToCanonicalString`, `Equals`, `GetHashCode`, `ToString`, `HttpHeaderValueFormatter`, the OpenAPI mapper, validation message formatting, and the new encoding switch — turning a scoped bug fix into a cross-cutting change. +String encodings use `MetadataValue.ToCanonicalString()`, matching HTTP headers and `MetadataValue.ToString()`. Only the JSON encoding is modeled: `Binary`, `URI`, `URI-reference`, and `Timestamp` share JSON's string representation, while the metadata value alone cannot always justify the narrower abstract type (`DateTimeKind.Unspecified` is not RFC 3339 and `Uri` may be relative). A future binary-mode writer would need the seven abstract types plus information unavailable here; it would not reuse this JSON enum. -`Bytes` therefore remains deferred and moves to its own issue; the sequencing note in `0055-0` is superseded on this point, and that plan is not edited, per the convention that completed plans are left as written. Deferring stays cheap for the same reason `0055-0` gave: enum values never leave the process, and base64 inside a `String` is exactly what the kind would produce, so introducing it later is additive and not wire-visible. A caller holding `byte[]` today writes base64 into a `String` attribute, which this plan encodes correctly — and the base64 alphabet is ASCII-printable, so it never trips the character rule below. +Mapping wins over documenting the deviation: portability across CloudEvents implementations is why the feature exists, and a receiver validating attribute types against the spec is entitled to reject a fractional number. Mapping is also preferred to an opt-out on `PortableResultsCloudEventsWriteOptions`: that would double the write matrix and preserve an invalid mode, while pre-1.0 callers needing a specific valid shape already have `CloudEventsAttributeConverter`. Out-of-range `Int64` values are string-mapped rather than rejected because common values such as snowflake IDs and nanosecond timestamps remain representable without loss. -### `String` is a character contract, not just a JSON string +### Value-dependent `Int64` -Choosing the `String` type is necessary but not sufficient. Core §2.4 defines `String` as a sequence of *allowable* Unicode characters and excludes three groups: the C0 and C1 control characters (U+0000–U+001F and U+007F–U+009F), the Unicode noncharacters (U+FDD0–U+FDEF and U+FFFE/U+FFFF in each of the 17 planes), and surrogate code points outside a valid pair. Escaping does not help — a `\u0001` escape in JSON still decodes to a control character in the attribute value, so the event is non-conformant however it is written. +One key may appear as `2147483647` and later as `"2147483648"`, contrary to core §2.3's stable-type expectation. A stable `Integer` is impossible across the `long` domain. Always using `String` would be stable and, after adding canonical parsing to `TryGetInt64`, would preserve typed accessor use; however, every integer would become quoted, `MetadataKind.Int64` would never round-trip, and no kind would produce CloudEvents `Integer` until a narrower integer kind existed. Its strongest argument is the failure mode: always-`String` takes one uniform break, pre-1.0 and stated in release notes, in exchange for removing a latent, data-dependent one — a consumer that types the attribute as `Integer` works until a value crosses 2^31 and then fails in production. The chosen value-dependent rule preserves natural JSON and `MetadataKind.Int64` wherever CloudEvents permits it. A straddling key still changes raw JSON and `Kind`, and a foreign consumer declaring it as `Integer` can fail at the boundary; a consistently small or large key remains stable in practice. The library can guarantee valid emitted attributes, not compatibility with an extension type declaration it cannot see. -Most rows of the table are safe by construction: the canonical text of every numeric, boolean, date, time, `Guid`, and `TimeSpan` kind is ASCII digits, signs, and separators. Only `String`, `Char`, and `Uri` carry text the caller controls, and those three admit all of the disallowed groups today. +`TryGetInt64` (`MetadataValue.cs:385`) therefore gains the same canonical-string path as `TryGetUInt64` (`MetadataValue.cs:475`), `TryGetSingle`, `TryGetChar`, and `TryGetDecimal`: parse with `CultureInfo.InvariantCulture` and `NumberStyles.AllowLeadingSign`, then require an exact formatting round trip. This makes the accessor work on both sides of the int32 threshold without becoming lenient. It changes every `MetadataValue` of kind `String`, not only CloudEvents values, and is release-noted accordingly. Existing production writing remains unaffected: outside `MetadataObject.TryGetInt64`, only `MetadataExtensions.WriteNumberValue` calls it, after `GetNumberEncoding() == Int64`; and `DefaultHttpHeaderParsingService` already sniffs integral header text into `MetadataValue.FromInt64`, so the header reader never depended on the string arm. -These values are rejected, not normalized. Silently stripping or replacing a character changes data the caller deliberately put in an attribute and is discovered, if ever, on the consumer side. +`UInt64` stays uniformly `String`: its accessor already parses canonical text, and a JSON number would not restore `MetadataKind.UInt64`, so a magnitude-dependent form would buy no fidelity. Publishers needing a stable `String` for `Int64` can use `CloudEventsAttributeConverter` to convert the value before writing. -Enforcement belongs at the write boundary, not at the conversion seam. `DefaultCloudEventsAttributeConversionService.ValidateAttributeValue` is bypassed by any custom `ICloudEventsAttributeConversionService` and by a directly constructed `CloudEventsEnvelopeForWriting`, whose constructor takes the attribute `MetadataObject` and is public. Validating only there would make the character rule advisory while the complex-kind rule is absolute — an inconsistency with no justification, since both protect the same invariant. `WriteExtensionAttributes` therefore performs the check itself, before `WritePropertyName`, so no path can emit prohibited text and no half-written property is left behind when it fails. That site also holds the attribute name the exception message needs; the value writer alone does not. +### Deferred `Bytes` -There is exactly one place that writes an attribute, so on the built-in path the text is scanned once and every caller is covered by construction. `DefaultCloudEventsAttributeConversionService` deliberately does not repeat the scan: writer validation is mandatory, so a second check there would make every conformant attribute pay twice on every event to buy nothing the writer does not already guarantee. +This supersedes `0055-0`'s sequencing note assigning `Bytes` to #53; completed plans remain unedited. A `Bytes` kind would still select JSON `String` and emit the same base64 text callers use today, so it changes neither this wire contract nor its four encodings. Its unresolved questions—structural versus reference equality for `byte[]`, O(n) hashing, and defensive copying—belong to the metadata model. Adding it also touches every exhaustive kind dispatch (`GetJsonShape`, `GetNumberEncoding`, canonical/debug formatting, equality/hash, HTTP, OpenAPI, validation messages, and this mapping), so it moves to its own issue. Deferral is additive and not wire-visible because enum values do not leave the process and base64 is allowable ASCII text. -`CloudEventsAttributeText` is public so that a custom service can nonetheless run the check at its own seam. That is a real trade and not a free option — the writer still validates afterwards, so preflighting costs a second pass over conforming text. It buys failing at `ToCloudEvent` time, with an `ArgumentException` naming the offending attribute before a single byte is serialized, which is worth the pass to a caller assembling attributes from untrusted input. The single-scan guarantee is therefore a property of the default path, not of every configuration. - -The exception is an `InvalidOperationException` naming the attribute and the offending code point, matching the complex-kind throw at the same site. A conversion service applying the rule earlier throws `ArgumentException` from its own seam, as `ValidateAttributeValue` already does for names and complex values. - -Scanning is limited to the three text-bearing kinds, and the per-character test is a single range comparison for the ASCII-printable majority, with the noncharacter and surrogate work reached only above U+D7FF. A conformant attribute therefore pays one linear pass over text the writer is about to walk again anyway. Attribute *names* need no new check — `IsValidExtensionAttributeName` already restricts them to lowercase alphanumerics. - -`Utf8JsonWriter`'s own behavior for ill-formed UTF-16 — replacement versus exception — is not this library's contract to define, and no test should pin it: it is incidental to the promise being made, and a test asserting it would break on a `System.Text.Json` update that changes nothing about this library. What matters is that the writer never sees such text. The lone-surrogate test therefore asserts the rejection through the public serialization API, which is the behavior the library actually owns. - -Two gaps remain and are deliberate, not oversights: - -- **Standard attributes** resolved from metadata (`type`, `subject`, `id`, and the `source`/`dataschema` URI-references) are rendered on a different path in `ResolveAttributes` and are not scanned. They are usually developer-supplied constants rather than runtime data, and adding a second validation site belongs with a review of that path. Worth a follow-up issue. -- **Inbound events** are not validated. A producer that ships a control character in an attribute produces an event this library will still read; rejecting it would break consumers over a defect in someone else's encoder, which is the wrong trade for a consumer library. - -### Null attributes are unset, in both directions - -The JSON Event Format permits `null` for an attribute and requires a decoder to treat it as if the attribute were not present. That rule is normative and it settles both ends: - -- **Reading.** `CloudEventsEnvelopeJsonReader` currently adds every extension attribute to the builder, so `"ext": null` becomes a `MetadataKind.Null` entry. That contradicts the rule: the decoded event must look as though `ext` was never there. The null token is skipped before the builder sees it, which also means a null attribute can no longer replace a payload-metadata value of the same key under `CloudEventsAttributeConflictStrategy`/`MergeStrategy`. Inbound envelopes from other producers get the same treatment; this is not limited to what this library writes. -- **Writing.** Because a conformant decoder must ignore it, emitting `null` conveys nothing, so the attribute is omitted entirely rather than written as `null`. The two are semantically identical under the format and omission is the cheaper of the two. This changes the bytes on the wire for a `Null` metadata value annotated for extension attributes. - -Omission has to be decided before the property name is written, which is why the public writer takes the name and the value together: it consults the encoding first and returns without writing anything for a null. No entry point exists that can be reached after a property name is already committed, so there is no path on which a null attribute still has to be written as `null`. Standard attributes already behave this way: `GetStringAttribute` excludes `MetadataKind.Null` explicitly, and `ReadOptionalStringValue` maps an inbound `null` to "not present". - -The consequence for the metadata model is that a `Null` extension attribute does not round-trip. That is the correct reading of the format rather than a defect: CloudEvents has no way to say "this attribute is present and empty". - -### Public API +### Public API and write boundary ```csharp namespace Light.PortableResults.CloudEvents; @@ -141,8 +67,6 @@ public static class CloudEventsAttributeJsonEncodingExtensions public static class CloudEventsAttributeText { - // Returns the index of the first character core §2.4 excludes from a String, or -1 when the text conforms. - // A high surrogate followed by a low surrogate is one valid character; either one alone is not. public static int IndexOfDisallowedCharacter(ReadOnlySpan text); } ``` @@ -152,9 +76,6 @@ namespace Light.PortableResults.CloudEvents.Writing.Json; public static class JsonCloudEventsExtensions { - // Writes one complete extension attribute: rejects a name that is not a legal extension attribute - // name, omits the attribute entirely for a null value, otherwise validates the text, writes the - // property name, and emits the value in its CloudEvents JSON encoding. public static void WriteCloudEventsExtensionAttribute( this Utf8JsonWriter writer, string attributeName, @@ -163,50 +84,30 @@ public static class JsonCloudEventsExtensions } ``` -The enum is named for the JSON Event Format encoding it selects, not for the abstract type system. Calling it `CloudEventsAttributeType` while omitting `Binary`, `URI`, `URI-reference`, and `Timestamp` — and adding `Null`, which core §2.4 does not define as a type at all — would misstate a normative distinction of the v1.0.2 type system. `Null`, `Boolean`, `Integer`, and `String` are exactly the four JSON encodings the format admits, and the enum is complete for that job. - -The enum lives in `Light.PortableResults.CloudEvents` rather than under `Writing.Json` because it describes the format in both directions; a reader-side conformance check would classify against the same four values. - -Only the value-level classification is public. A kind-level overload would answer differently from the writer for an out-of-range `Int64`, which is a footgun in a rule whose whole point is having one answer. `GetCloudEventsAttributeJsonEncoding` is implemented over an exhaustive `switch` expression on `MetadataKind` with no default arm, following `MetadataKindExtensions`: `CS8524` stays suppressed for unnamed values, while a newly declared named kind produces `CS8509`, which is an error under `TreatWarningsAsErrors` in Release. `Array` and `Object` throw `InvalidOperationException` from that switch — a branch a caller can reach directly, so it is testable without a contrived writer setup. - -The public method takes the name and the value, because the attribute — not the value — is the unit the rule applies to. Every part of the contract needs the pair: omission is a decision about whether the property exists at all, the exception message names the attribute, and "no partially written property" is only enforceable by whoever calls `WritePropertyName`. A value-only method would push all three onto the caller and could satisfy none of them. `WriteExtensionAttributes` therefore reduces to a filter plus a loop over this one method, and a custom writer that calls it gets the identical rule rather than a reimplementation of it — which matters because `CloudEventsEnvelopeForWriting` is a record struct whose primary constructor accepts `ExtensionAttributes` outright, so the writer is reachable without passing through any conversion service. - -The `MetadataValueAnnotation` argument that `WriteMetadataValue` takes disappears: it only ever filtered complex children, which can no longer occur, and the top level was never filtered by it — attributes are already selected by annotation in `CloudEventsResultExtensions.ConvertMetadataToCloudEventsAttributes`. - -The method validates the name in full: non-null, non-empty, lowercase alphanumeric per `IsValidExtensionAttributeName`, and absent from both `ForbiddenConvertedAttributeNames` and `StandardAttributeNames`. Anything reaching it is then guaranteed to be a name that may legally carry the extension mapping. - -Rejecting the standard names matters for correctness, not tidiness. This method applies the *extension* encoding, and the standard attributes do not use it — `type` is a CloudEvents `String`, so `WriteCloudEventsExtensionAttribute(writer, "type", MetadataValue.FromInt64(5))` would emit `"type": 5`, a JSON number where the specification requires a string. Accepting a standard name would mean accepting a guaranteed-invalid envelope, which is worse than the duplicate-property problem that first suggested leaving the decision to the caller. The grammar check has the same justification as moving text validation to the writer: `DefaultCloudEventsAttributeConversionService.ValidateAttributeName` is bypassed by a custom conversion service or a directly constructed envelope, so a name like `Bad_Name` or an empty string reaches the writer with nothing in its way. - -`WriteExtensionAttributes` keeps skipping the standard names before it calls the method, because they are rendered from `ResolveAttributes` and only the envelope writer knows that. The built-in path therefore never reaches the throw; the guard exists for external callers and is tested directly against the public method, which is the case `tests/AGENTS.md` reserves solitary tests for. - -The throw for complex kinds is a behavior change only on the paths that bypass `DefaultCloudEventsAttributeConversionService.ValidateAttributeValue` — a custom conversion service, or an envelope constructed directly — which today produce a nested JSON value that no CloudEvents receiver can type. Failing fast is the better contract, and it matches the existing `MetadataNumberEncoding.None` arm in `MetadataExtensions.WriteNumberValue`. - -Standard attributes never reach this path. `WriteExtensionAttributes` skips `CloudEventsConstants.StandardAttributeNames`, and `ResolveAttributes` renders them from `ToCanonicalString()` into `WriteString` calls, so their JSON shape already matches what the type-system mapping would choose and this plan leaves it untouched. That is a statement about their rendering, not a claim that they are fully conformant: their text is not scanned for the characters `String` excludes, which is the gap recorded above. - -### Release notes +The enum names the four JSON encodings, not the seven abstract CloudEvents types; `Null` represents the format's unset encoding rather than an abstract type. It lives in the root CloudEvents namespace because the same classification can support read-side conformance. Classification is value-level because out-of-range `Int64` differs from in-range values. Its switch lists every `MetadataKind` without a default, following `MetadataKindExtensions`; suppressing only `CS8524` preserves `CS8509`, which `TreatWarningsAsErrors` turns into a Release error for a newly declared kind. `Array` and `Object` explicitly throw `InvalidOperationException`. -Everything here ships in the unreleased 0.7.0, so the entries go into that block of `` rather than a new one. Five behavior changes are visible to a consumer and belong under **Breaking changes**. Four are confined to CloudEvents extension attributes: the string encoding of `Double`, `Single`, `Decimal`, and out-of-range `Int64`; the omission of null attributes on write; their treatment as unset on read; and the rejection of text that CloudEvents excludes from a `String`. Each of those four needs its scope stated — extension attributes only, with `data` payloads and `problem+json` bodies untouched — because the natural reading of "decimals are now strings" is that it applies everywhere. +The writer takes the name and value together so it can validate before `WritePropertyName`, omit null atomically, identify failures, and prevent partial properties. It rejects null/blank names, names outside lowercase ASCII letters and digits, `ForbiddenConvertedAttributeNames`, and every `CloudEventsConstants.StandardAttributeNames` member. The grammar rule exists today only as the private `DefaultCloudEventsAttributeConversionService.IsValidExtensionAttributeName` (`DefaultCloudEventsAttributeConversionService.cs:99`), so it moves to a shared location both the service and the writer call rather than being reimplemented. Standard names cannot use generic extension encoding—for example, an `Int64` `type` would become a number even though `type` is defined as `String`. `WriteExtensionAttributes` skips standard names already emitted through `ResolveAttributes`, then delegates every remaining pair to this method. Custom writers get the identical contract, including paths that bypass `DefaultCloudEventsAttributeConversionService` through a custom service or direct `CloudEventsEnvelopeForWriting` construction. Direct public guard tests are appropriate because these paths are otherwise unreachable through the default sociable surface. -The fifth is not a CloudEvents change and must not be filed under that scope: `TryGetInt64` returning `true` for a string holding canonical integer text applies to every `MetadataValue` of kind `String`, whatever produced it — an HTTP header, a JSON body, or a literal in caller code. Its entry belongs with the metadata accessors and states the round-trip guard, so a reader can tell that `"01234"` and `"+5"` are still rejected. +Complex values fail here even if conversion validation was bypassed, matching the existing `MetadataNumberEncoding.None` failure in `MetadataExtensions.WriteNumberValue`. The old `MetadataValueAnnotation` argument is unnecessary: it filtered only complex children, while top-level entries were already selected in `ConvertMetadataToCloudEventsAttributes`. -The existing decimal entry has to be corrected rather than merely supplemented. It currently states that decimal metadata values "are serialized as JSON numbers instead of quoted strings", which after this plan is true of `data` and `problem+json` but false of extension attributes — the same conflation that #52 introduced and that this plan resolves. Scope that sentence to the payload, and let the new entry carry the attribute rule. +### CloudEvents `String` validation -### Allocation +`String` excludes C0/C1 controls (U+0000–U+001F, U+007F–U+009F), noncharacters (U+FDD0–U+FDEF and U+FFFE/U+FFFF in every plane), and unpaired surrogates. JSON escaping does not make these values valid. Only caller-controlled `String`, `Char`, and `Uri` text is scanned; numeric, boolean, date/time, `Guid`, and `TimeSpan` text is safe by construction. Values are rejected, never normalized. -The string arm formats through `MetadataValue.TryFormatCanonical` into a 32-character stack buffer and calls `Utf8JsonWriter.WriteStringValue(ReadOnlySpan)`, falling back to `WriteStringValue(value.ToCanonicalString())` when the destination is too small. Thirty-two characters cover every numeric kind: the widest decimal text is 31 characters — a negative value at scale 28, `-0.0000000000000000000000000001`, not `decimal.MinValue` at 30. +The public `CloudEventsAttributeText.IndexOfDisallowedCharacter` returns the UTF-16 index of the first invalid code point, or `-1` when the text conforms; it treats a valid surrogate pair as one scalar and still rejects a pair that encodes a noncharacter. The writer throws `InvalidOperationException` naming the attribute and code point. A conversion service that preflights may instead throw its normal `ArgumentException`; this deliberately adds a second scan to gain failure before serialization. The default service does not preflight, so the mandatory writer check scans once. The fast path is one ASCII range check, with surrogate/noncharacter work only above U+D7FF. -That path is only genuinely allocation-free for `Double` and `Single` today. `TryFormatCanonical` span-formats those two kinds and materializes `ToCanonicalString()` for everything else (`MetadataValue.cs:848`), so `Int64`, `UInt64`, and `Decimal` still allocate a string that is then copied into the buffer. Plan `0058` deferred direct span formatting for the remaining kinds deliberately, and it stays deferred here: doing it properly needs `TryFormat` shims for `long`, `ulong`, and `decimal` on `netstandard2.0`, where those overloads do not exist — the `BitOperationsCompat` pattern or a UTF-8 `Utf8Formatter` path, either of which is its own change with its own differential tests. It is tracked as follow-up work on a separate branch. +Do not test `Utf8JsonWriter`'s replacement/exception behavior for malformed UTF-16: it is not this library's contract, and a `System.Text.Json` update could break such a test without changing anything here. The public serialization test must prove invalid text never reaches it. Two deliberate gaps remain: standard attributes are rendered elsewhere and retain their current JSON string shape without this character scan, which belongs in a follow-up review of that path; inbound invalid strings remain accepted to avoid breaking consumers because of a producer defect. -The net effect of this plan on allocations is therefore: `Double` and `Single` improve (today they allocate through `WriteRawValue(ToCanonicalString())`), `Decimal` and out-of-range `Int64` regress by one string each as the price of conformance, and every kind that already wrote as a string is unchanged. Writing through the buffer rather than passing the string straight to the writer costs a copy but keeps the call site correct for free once the deferred work lands. No new benchmark is required: the change is one branch plus a span format on a path `CloudEventsWritingBenchmarks` already exercises. +### Null, reading, and standard attributes -### Reading +The writer omits null before committing a property name. The reader skips a null token before adding it to `CloudEventsEnvelopeJsonReader`'s builder, so it is indistinguishable from omission, cannot participate in `CloudEventsAttributeConflictStrategy`, and cannot replace payload metadata under `MergeStrategy`. A null extension therefore cannot round-trip, as required by the format. Tests cover omission, a hand-written inbound null, and collision with payload metadata. Standard attributes already treat null as absent through `GetStringAttribute` and `ReadOptionalStringValue`. -Apart from the null rule above, the read path is unchanged. A value written as a string returns as `MetadataKind.String`, so `Double`, `Single`, `Decimal`, and out-of-range `Int64` change kind on the way in — the same class of asymmetry already documented on `MetadataJsonReader`, which never produces `Decimal` at all. State it on `CloudEventsAttributeJsonEncodingExtensions` and in the README, and point at the supported remedy: register a `CloudEventsAttributeParser` for the attribute name to parse the canonical text back into the intended kind. A test should demonstrate that remedy for one kind so the documented escape hatch is known to work. +Apart from null, reading is unchanged. String-mapped `Double`, `Single`, `Decimal`, and out-of-range `Int64` return as `MetadataKind.String`, the same kind of asymmetry already documented because `MetadataJsonReader` never produces `Decimal`. The public encoding API and README document this, and a registered `CloudEventsAttributeParser` is the supported way to restore a specific original kind. Standard attributes never enter this writer: `ResolveAttributes` continues writing their canonical text as strings. Their JSON shape is unchanged, although their separate path does not yet enforce the CloudEvents `String` character restrictions. -A binary content-mode writer would not reuse this enum: attributes become header text there, so the JSON encoding has nothing to say about them. What carries over is the canonical text and the decision recorded here that `String` is the fallback for anything the type system cannot express. If binary mode ever needs to distinguish `Binary` from `Timestamp` from `URI` in a header, that is the point at which modeling the seven abstract types earns its keep — and it will need input the writer does not have today, so it is a separate decision rather than a rename of this one. +### Allocation, release notes, and verification -### Testing notes +The string arm first calls `MetadataValue.TryFormatCanonical` into a 32-character stack buffer and passes the resulting span to `Utf8JsonWriter.WriteStringValue`, falling back to `ToCanonicalString()` when necessary. The widest numeric text is the 31-character scale-28 value `-0.0000000000000000000000000001`; `decimal.MinValue` is 30. Only `Double` and `Single` span-format without allocation today; `Int64`, `UInt64`, and `Decimal` still materialize one string (`MetadataValue.cs:848`). Removing that requires netstandard2.0 `TryFormat` shims following the `BitOperationsCompat` pattern, or a UTF-8 `Utf8Formatter` path with differential tests, and remains follow-up work from `0058`. Thus floating-point attributes improve, decimal and out-of-range `Int64` regress by one required string, and existing string-shaped kinds are unchanged; the intermediate copy keeps the call site ready for the deferred formatter. The allocation assertion follows `CanonicalFloatingPointFormatterTests`; the existing CloudEvents benchmark already exercises the path, so no new benchmark is required. -The matrix belongs on the public `ToCloudEvent` surface rather than on the writer helper, so it pins the shipped behavior: for every non-null primitive kind, assert both `JsonValueKind` and the value's text. `Null` is not in the matrix — an omitted property has neither — and is covered by the three null tests below instead. The four `Int64` boundaries are the only non-obvious cases — `int.MinValue` and `int.MaxValue` must be numbers, `(long) int.MinValue - 1` and `(long) int.MaxValue + 1` must be the corresponding quoted digits. +Release notes go into unreleased 0.7.0. Four breaking changes are CloudEvents-extension-only: string encoding for `Double`, `Single`, `Decimal`, and out-of-range `Int64`; null omission on write; null-as-unset on read; and rejection of invalid `String` text. Their entries explicitly exclude data and `problem+json`. The fifth change—canonical-string support in `TryGetInt64`—applies globally to strings from headers, JSON, or caller code and is documented separately, including its rejection of noncanonical forms. The existing decimal note is narrowed to payload serialization. -The null rule needs three tests, because writing and reading are now separate statements: a `Null` metadata value yields an envelope with no such property; a hand-written envelope carrying `"ext": null` — the form other producers may send — reads back without an `ext` entry; and a null extension attribute does not displace a payload-metadata entry of the same key. +Verification uses the public `ToCloudEvent` matrix for every non-null primitive, asserting `JsonValueKind` and text. The four `Int64` cases are `int.MinValue`, `int.MaxValue`, `(long) int.MinValue - 1`, and `(long) int.MaxValue + 1`; `Null` is covered separately. Read-back covers every encoding and one parser restoration. Release builds, allocation tests, and solution coverage complete the checks. From 545bb57b8248997bcb3cb8c0714b0171d6a6aba0 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 09:11:02 +0200 Subject: [PATCH 11/12] feat(cloud-events): enforce extension attribute types Map extension metadata through the CloudEvents JSON Event Format type system, omit null attributes, and validate names and string text before committing a property. Support canonical Int64 text on read-back, document the value-dependent encoding and stable converter remedy, and cover the public write/read surfaces plus allocation contracts. Closes #53 Signed-off-by: Kenny Pflug --- README.md | 45 +++ ...-cloud-events-extension-attribute-types.md | 28 +- .../CloudEventsAttributeJsonEncoding.cs | 100 ++++++ .../CloudEvents/CloudEventsAttributeName.cs | 43 +++ .../CloudEvents/CloudEventsAttributeText.cs | 77 +++++ .../Json/CloudEventsEnvelopeJsonReader.cs | 7 +- .../PortableResultsCloudEventsReadOptions.cs | 4 +- ...ltCloudEventsAttributeConversionService.cs | 20 +- .../Writing/Json/JsonCloudEventsExtensions.cs | 189 ++++++++++- .../PortableResultsCloudEventsWriteOptions.cs | 7 +- .../Light.PortableResults.csproj | 18 +- .../Metadata/MetadataValue.cs | 17 +- .../CloudEventsAttributeJsonEncodingTests.cs | 89 +++++ .../CloudEventsAttributeTextTests.cs | 49 +++ ...eadOnlyMemoryCloudEventsExtensionsTests.cs | 115 +++++++ .../CloudEventsResultExtensionsTests.cs | 278 +++++++++++++++- .../Writing/JsonCloudEventsExtensionsTests.cs | 315 ++++++++++++++++++ .../Metadata/TypedMetadataValueTests.cs | 22 ++ 18 files changed, 1374 insertions(+), 49 deletions(-) create mode 100644 src/Light.PortableResults/CloudEvents/CloudEventsAttributeJsonEncoding.cs create mode 100644 src/Light.PortableResults/CloudEvents/CloudEventsAttributeName.cs create mode 100644 src/Light.PortableResults/CloudEvents/CloudEventsAttributeText.cs create mode 100644 tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeJsonEncodingTests.cs create mode 100644 tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeTextTests.cs create mode 100644 tests/Light.PortableResults.Tests/CloudEvents/Writing/JsonCloudEventsExtensionsTests.cs diff --git a/README.md b/README.md index d960059..a49df6e 100644 --- a/README.md +++ b/README.md @@ -589,6 +589,51 @@ await channel.BasicPublishAsync( ); ``` +### Extension Attribute Encoding + +CloudEvents extension attributes use the JSON Event Format context-attribute mapping, not each +`MetadataKind`'s natural JSON shape: + +| Metadata value | Extension-attribute JSON | +|---|---| +| `Null` | Omitted (unset) | +| `Boolean` | JSON boolean | +| `Int64` from `-2147483648` through `2147483647` | JSON number | +| Every other non-null primitive, including larger `Int64`, `Double`, `Single`, and `Decimal` | JSON string containing canonical invariant text | +| `Array` or `Object` | Rejected | + +CloudEvents `String` values are validated without normalization: controls, Unicode noncharacters, and +unpaired surrogates are rejected. This mapping affects only extension attributes; metadata inside `data` +and `problem+json` keeps its normal JSON representation. + +The `Int64` rule is value-dependent. For example, one extension name can be emitted as `2147483647` in +one event and as `"2147483648"` in another. This is a deliberate deviation from CloudEvents' stable-type +recommendation so all in-range CloudEvents integers retain their natural JSON representation while the +full `long` domain remains lossless. If a key requires a stable string shape, convert it to +`MetadataKind.String` with a `CloudEventsAttributeConverter`: + +```csharp +public sealed class StableSequenceConverter : CloudEventsAttributeConverter +{ + public StableSequenceConverter() : base(["sequence"]) { } + + public override KeyValuePair PrepareCloudEventsAttribute( + string metadataKey, + MetadataValue value + ) => + new ( + metadataKey, + MetadataValue.FromString(value.ToCanonicalString(), value.Annotation) + ); +} +``` + +On read-back, a JSON boolean becomes `MetadataKind.Boolean`, an integer-number becomes +`MetadataKind.Int64`, and every string-mapped value initially becomes `MetadataKind.String`. Canonical +out-of-range integer text remains accessible through `MetadataValue.TryGetInt64`. Register a +`CloudEventsAttributeParser` when a particular attribute must be restored to another original kind. +Inbound `null` means unset and is not added to extension metadata. + ### Consume from RabbitMQ ```csharp diff --git a/ai-plans/0053-cloud-events-extension-attribute-types.md b/ai-plans/0053-cloud-events-extension-attribute-types.md index 52b60f1..851c992 100644 --- a/ai-plans/0053-cloud-events-extension-attribute-types.md +++ b/ai-plans/0053-cloud-events-extension-attribute-types.md @@ -8,20 +8,20 @@ Extension attributes will instead use the CloudEvents mapping, falling back to ` ## Acceptance Criteria -- [ ] The public encoding API and README document that extension attributes use the JSON Event Format mapping rather than each metadata kind's natural JSON shape. -- [ ] `Boolean` writes a JSON boolean; an `Int64` in the inclusive int32 range writes a JSON number; every other conforming non-null primitive writes a JSON string containing its canonical invariant text. A test pinning the quoted, canonical decimal text replaces `ToCloudEvent_ShouldWriteDecimalExtensionAttribute_AsUnquotedNumber`, and a public-surface matrix covers every non-null primitive plus all four `Int64` boundaries. -- [ ] A `Null` value omits the property and produces bytes identical to an envelope without that entry. An inbound null attribute is absent from extension metadata and cannot replace payload metadata with the same key. -- [ ] One public, value-level API classifies the JSON encoding and one public writer owns the complete name/value operation. Its exhaustive `MetadataKind` switch makes a later named kind fail the Release build; complex values throw with the kind named. -- [ ] The public writer rejects null, empty, whitespace, non-lowercase-alphanumeric, reserved, and standard attribute names before writing anything. Direct guard tests cover every category, including an in-range `Int64` named `type`. -- [ ] CloudEvents-disallowed `String` text is rejected without normalization before a property name is written, with the attribute and offending code point identified. This holds for custom conversion services and directly constructed envelopes. The public character rule is tested with C0, C1, lone high and low surrogates, a noncharacter, an invalid `Char`, and an accepted surrogate pair plus non-ASCII text. -- [ ] The default path scans only caller-controlled text — `String`, `Char`, and `Uri` — and performs at most one CloudEvents character-validation scan per conforming attribute. A custom service may deliberately preflight through the public rule, paying for a second scan to fail before serialization. -- [ ] `MetadataValue.TryGetInt64` additionally accepts a `MetadataKind.String` holding a `long`'s canonical text, and only the canonical form: the parsed value must reproduce the source text, rejecting `"+5"`, `"-0"`, `"01234"`, `" 5"`, and out-of-range text. An out-of-int32 extension value reads back through this accessor unchanged. -- [ ] The public API and README document the value-dependent `Int64` encoding, its stable-type deviation, and the `CloudEventsAttributeConverter` remedy that converts a key to `MetadataKind.String`. Tests pin both the shape change for one attribute name across two events and the stable converter. -- [ ] Read-back tests cover every JSON encoding: string-mapped values return as `MetadataKind.String`, null is absent, and a registered `CloudEventsAttributeParser` restores an original kind. The encoding API documents this asymmetry like `MetadataJsonReader` documents numeric tokens. -- [ ] Standard attributes resolved from metadata (`type`, `source`, `subject`, `dataschema`, `time`, `id`) retain their current string rendering. -- [ ] Writing `Double` and `Single` extension attributes allocates nothing after warm-up. Other string-mapped kinds allocate no more than the one canonical string that `MetadataValue.TryFormatCanonical` materializes today. -- [ ] The 0.7.0 `` records every behavior change with the correct scope and corrects the existing claim that decimals always serialize as JSON numbers. -- [ ] Test code coverage remains above 95%. +- [x] The public encoding API and README document that extension attributes use the JSON Event Format mapping rather than each metadata kind's natural JSON shape. +- [x] `Boolean` writes a JSON boolean; an `Int64` in the inclusive int32 range writes a JSON number; every other conforming non-null primitive writes a JSON string containing its canonical invariant text. A test pinning the quoted, canonical decimal text replaces `ToCloudEvent_ShouldWriteDecimalExtensionAttribute_AsUnquotedNumber`, and a public-surface matrix covers every non-null primitive plus all four `Int64` boundaries. +- [x] A `Null` value omits the property and produces bytes identical to an envelope without that entry. An inbound null attribute is absent from extension metadata and cannot replace payload metadata with the same key. +- [x] One public, value-level API classifies the JSON encoding and one public writer owns the complete name/value operation. Its exhaustive `MetadataKind` switch makes a later named kind fail the Release build; complex values throw with the kind named. +- [x] The public writer rejects null, empty, whitespace, non-lowercase-alphanumeric, reserved, and standard attribute names before writing anything. Direct guard tests cover every category, including an in-range `Int64` named `type`. +- [x] CloudEvents-disallowed `String` text is rejected without normalization before a property name is written, with the attribute and offending code point identified. This holds for custom conversion services and directly constructed envelopes. The public character rule is tested with C0, C1, lone high and low surrogates, a noncharacter, an invalid `Char`, and an accepted surrogate pair plus non-ASCII text. +- [x] The default path scans only caller-controlled text — `String`, `Char`, and `Uri` — and performs at most one CloudEvents character-validation scan per conforming attribute. A custom service may deliberately preflight through the public rule, paying for a second scan to fail before serialization. +- [x] `MetadataValue.TryGetInt64` additionally accepts a `MetadataKind.String` holding a `long`'s canonical text, and only the canonical form: the parsed value must reproduce the source text, rejecting `"+5"`, `"-0"`, `"01234"`, `" 5"`, and out-of-range text. An out-of-int32 extension value reads back through this accessor unchanged. +- [x] The public API and README document the value-dependent `Int64` encoding, its stable-type deviation, and the `CloudEventsAttributeConverter` remedy that converts a key to `MetadataKind.String`. Tests pin both the shape change for one attribute name across two events and the stable converter. +- [x] Read-back tests cover every JSON encoding: string-mapped values return as `MetadataKind.String`, null is absent, and a registered `CloudEventsAttributeParser` restores an original kind. The encoding API documents this asymmetry like `MetadataJsonReader` documents numeric tokens. +- [x] Standard attributes resolved from metadata (`type`, `source`, `subject`, `dataschema`, `time`, `id`) retain their current string rendering. +- [x] Writing `Double` and `Single` extension attributes allocates nothing after warm-up. Other string-mapped kinds allocate no more than the one canonical string that `MetadataValue.TryFormatCanonical` materializes today. +- [x] The 0.7.0 `` records every behavior change with the correct scope and corrects the existing claim that decimals always serialize as JSON numbers. +- [x] Test code coverage remains above 95%. ## Technical Details diff --git a/src/Light.PortableResults/CloudEvents/CloudEventsAttributeJsonEncoding.cs b/src/Light.PortableResults/CloudEvents/CloudEventsAttributeJsonEncoding.cs new file mode 100644 index 0000000..34f8852 --- /dev/null +++ b/src/Light.PortableResults/CloudEvents/CloudEventsAttributeJsonEncoding.cs @@ -0,0 +1,100 @@ +using System; +using Light.PortableResults.Metadata; + +namespace Light.PortableResults.CloudEvents; + +/// +/// Identifies the JSON Event Format encoding used for a CloudEvents extension attribute value. +/// +/// +/// +/// These values describe JSON encodings, not the seven abstract CloudEvents context-attribute types. +/// represents an unset attribute and is omitted from the JSON object. +/// +/// +/// An uses only while its value is in the +/// inclusive 32-bit signed range; values outside that range use . Publishers that +/// require a stable string shape for one attribute name can use a +/// to convert its value to +/// before writing. +/// +/// +public enum CloudEventsAttributeJsonEncoding +{ + /// The attribute is unset and is omitted. + Null, + + /// The value is written as a JSON Boolean. + Boolean, + + /// The value is written as a JSON integer number. + Integer, + + /// The value is written as a JSON string containing its canonical invariant text. + String +} + +#pragma warning disable CS8524 // Unnamed enum values intentionally throw SwitchExpressionException. + +/// +/// Provides CloudEvents JSON Event Format encoding classification for metadata values. +/// +public static class CloudEventsAttributeJsonEncodingExtensions +{ + /// + /// Gets the JSON Event Format encoding for a CloudEvents extension attribute value. + /// + /// The metadata value to classify. + /// The CloudEvents attribute JSON encoding. + /// + /// + /// Boolean values use JSON booleans, signed integers in the inclusive 32-bit range use JSON numbers, + /// and all other conforming non-null primitive values use JSON strings containing their canonical + /// invariant text. Null represents an unset attribute and is omitted. + /// + /// + /// The mapping is intentionally different from each metadata kind's natural JSON shape. On read-back, + /// every string-mapped value is therefore initially represented as . + /// Register a when an extension attribute must be + /// restored to a specific metadata kind. + /// + /// + /// + /// Thrown when contains an array or object. + /// + public static CloudEventsAttributeJsonEncoding GetCloudEventsAttributeJsonEncoding(this MetadataValue value) => + value.Kind switch + { + MetadataKind.Null => CloudEventsAttributeJsonEncoding.Null, + MetadataKind.Boolean => CloudEventsAttributeJsonEncoding.Boolean, + MetadataKind.Int64 => GetInt64Encoding(value), + MetadataKind.Double => CloudEventsAttributeJsonEncoding.String, + MetadataKind.String => CloudEventsAttributeJsonEncoding.String, + MetadataKind.Decimal => CloudEventsAttributeJsonEncoding.String, + MetadataKind.UInt64 => CloudEventsAttributeJsonEncoding.String, + MetadataKind.Single => CloudEventsAttributeJsonEncoding.String, + MetadataKind.Char => CloudEventsAttributeJsonEncoding.String, + MetadataKind.DateTime => CloudEventsAttributeJsonEncoding.String, + MetadataKind.DateTimeOffset => CloudEventsAttributeJsonEncoding.String, + MetadataKind.DateOnly => CloudEventsAttributeJsonEncoding.String, + MetadataKind.TimeOnly => CloudEventsAttributeJsonEncoding.String, + MetadataKind.TimeSpan => CloudEventsAttributeJsonEncoding.String, + MetadataKind.Guid => CloudEventsAttributeJsonEncoding.String, + MetadataKind.Uri => CloudEventsAttributeJsonEncoding.String, + MetadataKind.Array => ThrowComplexValue(MetadataKind.Array), + MetadataKind.Object => ThrowComplexValue(MetadataKind.Object) + }; + + private static CloudEventsAttributeJsonEncoding GetInt64Encoding(MetadataValue value) + { + value.TryGetInt64(out var int64Value); + return int64Value is >= int.MinValue and <= int.MaxValue ? + CloudEventsAttributeJsonEncoding.Integer : + CloudEventsAttributeJsonEncoding.String; + } + + private static CloudEventsAttributeJsonEncoding ThrowComplexValue(MetadataKind kind) => + throw new InvalidOperationException( + $"CloudEvents extension attributes cannot encode metadata kind '{kind}'." + ); +} diff --git a/src/Light.PortableResults/CloudEvents/CloudEventsAttributeName.cs b/src/Light.PortableResults/CloudEvents/CloudEventsAttributeName.cs new file mode 100644 index 0000000..274b6f0 --- /dev/null +++ b/src/Light.PortableResults/CloudEvents/CloudEventsAttributeName.cs @@ -0,0 +1,43 @@ +using System; + +namespace Light.PortableResults.CloudEvents; + +/// +/// Provides low-level syntax validation for CloudEvents extension attribute names. +/// +public static class CloudEventsAttributeName +{ + /// + /// Determines whether an attribute name contains only lowercase ASCII letters and decimal digits. + /// + /// The attribute name whose character syntax is checked. + /// + /// when every character is a lowercase ASCII letter or decimal digit; + /// otherwise, . + /// + /// + /// This method validates only the character syntax. An empty name vacuously satisfies this predicate, + /// and standard or reserved CloudEvents names can also satisfy it. Callers that write complete extension + /// attributes must enforce those additional constraints; WriteCloudEventsExtensionAttribute does so. + /// + /// + /// Thrown when is . + /// + public static bool IsValidExtensionAttributeName(string attributeName) + { + if (attributeName is null) + { + throw new ArgumentNullException(nameof(attributeName)); + } + + foreach (var character in attributeName) + { + if (character is (< 'a' or > 'z') and (< '0' or > '9')) + { + return false; + } + } + + return true; + } +} diff --git a/src/Light.PortableResults/CloudEvents/CloudEventsAttributeText.cs b/src/Light.PortableResults/CloudEvents/CloudEventsAttributeText.cs new file mode 100644 index 0000000..4d8efbb --- /dev/null +++ b/src/Light.PortableResults/CloudEvents/CloudEventsAttributeText.cs @@ -0,0 +1,77 @@ +using System; + +namespace Light.PortableResults.CloudEvents; + +/// +/// Provides validation for the CloudEvents String context-attribute type. +/// +public static class CloudEventsAttributeText +{ + /// + /// Finds the first character that is not allowed by the CloudEvents String type. + /// + /// The text to inspect. + /// + /// The UTF-16 index of the first disallowed Unicode code point, or -1 when the text conforms. + /// + /// + /// C0 and C1 control characters, Unicode noncharacters, and unpaired UTF-16 surrogates are disallowed. + /// A valid surrogate pair is treated as one Unicode scalar value. The JSON extension-attribute writer + /// always applies this rule. A custom conversion service can call it earlier when failure before + /// serialization is more important than avoiding the writer's second validation scan. + /// + public static int IndexOfDisallowedCharacter(ReadOnlySpan text) + { + for (var index = 0; index < text.Length; index++) + { + var character = text[index]; + + if (character <= '\u007E') + { + if (character < '\u0020') + { + return index; + } + + continue; + } + + if (character <= '\u009F') + { + return index; + } + + if (character < '\uD800') + { + continue; + } + + if (character > '\uDFFF') + { + if (character is >= '\uFDD0' and <= '\uFDEF' || character >= '\uFFFE') + { + return index; + } + + continue; + } + + if (character >= '\uDC00' || + index + 1 >= text.Length || + text[index + 1] is < '\uDC00' or > '\uDFFF') + { + return index; + } + + var codePoint = 0x10000 + ((character - '\uD800') << 10) + text[index + 1] - '\uDC00'; + if ((codePoint & 0xFFFF) >= 0xFFFE) + { + return index; + } + + index++; + } + + return -1; + } +} diff --git a/src/Light.PortableResults/CloudEvents/Reading/Json/CloudEventsEnvelopeJsonReader.cs b/src/Light.PortableResults/CloudEvents/Reading/Json/CloudEventsEnvelopeJsonReader.cs index 9cfe0c6..d129ff4 100644 --- a/src/Light.PortableResults/CloudEvents/Reading/Json/CloudEventsEnvelopeJsonReader.cs +++ b/src/Light.PortableResults/CloudEvents/Reading/Json/CloudEventsEnvelopeJsonReader.cs @@ -138,8 +138,11 @@ out var parsed throw new JsonException("Unexpected end of JSON while reading extension attribute value."); } - var extensionValue = ReadExtensionAttributeValue(ref reader); - extensionBuilder.AddOrReplace(extensionAttributeName, extensionValue); + if (reader.TokenType != JsonTokenType.Null) + { + var extensionValue = ReadExtensionAttributeValue(ref reader); + extensionBuilder.AddOrReplace(extensionAttributeName, extensionValue); + } } } diff --git a/src/Light.PortableResults/CloudEvents/Reading/PortableResultsCloudEventsReadOptions.cs b/src/Light.PortableResults/CloudEvents/Reading/PortableResultsCloudEventsReadOptions.cs index 4071da0..085dcc8 100644 --- a/src/Light.PortableResults/CloudEvents/Reading/PortableResultsCloudEventsReadOptions.cs +++ b/src/Light.PortableResults/CloudEvents/Reading/PortableResultsCloudEventsReadOptions.cs @@ -32,7 +32,9 @@ public sealed record PortableResultsCloudEventsReadOptions public Func? IsFailureType { get; init; } /// - /// Gets or sets an optional parsing service used to convert extension attributes into metadata for tier-1 methods. + /// Gets or sets an optional parsing service used to convert extension attributes into metadata for tier-1 + /// methods. JSON string attributes initially have even when the writer + /// mapped another primitive kind to canonical text; register an attribute parser to restore that kind. /// public ICloudEventsAttributeParsingService? ParsingService { get; init; } diff --git a/src/Light.PortableResults/CloudEvents/Writing/DefaultCloudEventsAttributeConversionService.cs b/src/Light.PortableResults/CloudEvents/Writing/DefaultCloudEventsAttributeConversionService.cs index 4ea58d9..899da7b 100644 --- a/src/Light.PortableResults/CloudEvents/Writing/DefaultCloudEventsAttributeConversionService.cs +++ b/src/Light.PortableResults/CloudEvents/Writing/DefaultCloudEventsAttributeConversionService.cs @@ -43,7 +43,10 @@ FrozenDictionary converters /// The metadata value to convert. /// The CloudEvents attribute key and value pair. /// Thrown when is . - /// Thrown when the resulting attribute name is invalid, reserved, or the value is not a primitive type for extension attributes. + /// + /// Thrown when the resulting attribute name is invalid, reserved, or the value is not a primitive type for extension + /// attributes. + /// public KeyValuePair PrepareCloudEventsAttribute( string metadataKey, MetadataValue metadataValue @@ -87,7 +90,7 @@ private static void ValidateAttributeName(string attributeName) return; } - if (!IsValidExtensionAttributeName(attributeName)) + if (!CloudEventsAttributeName.IsValidExtensionAttributeName(attributeName)) { throw new ArgumentException( $"The CloudEvents extension attribute '{attributeName}' is invalid. Only lowercase alphanumeric names are allowed.", @@ -96,19 +99,6 @@ private static void ValidateAttributeName(string attributeName) } } - private static bool IsValidExtensionAttributeName(string attributeName) - { - foreach (var character in attributeName) - { - if (character is (< 'a' or > 'z') and (< '0' or > '9')) - { - return false; - } - } - - return true; - } - private static void ValidateAttributeValue(string attributeName, MetadataValue value) { if (CloudEventsConstants.StandardAttributeNames.Contains(attributeName)) diff --git a/src/Light.PortableResults/CloudEvents/Writing/Json/JsonCloudEventsExtensions.cs b/src/Light.PortableResults/CloudEvents/Writing/Json/JsonCloudEventsExtensions.cs index 81e7c6a..3fc6775 100644 --- a/src/Light.PortableResults/CloudEvents/Writing/Json/JsonCloudEventsExtensions.cs +++ b/src/Light.PortableResults/CloudEvents/Writing/Json/JsonCloudEventsExtensions.cs @@ -11,6 +11,10 @@ namespace Light.PortableResults.CloudEvents.Writing.Json; /// public static class JsonCloudEventsExtensions { + // Guid's canonical D format is the longest bounded primitive encoding. Arbitrarily long String and Uri + // values reuse their existing text when this buffer is insufficient. + private const int CanonicalTextBufferLength = 36; + /// /// Serializes the contents of a into the provided /// using the supplied serializer options. @@ -130,6 +134,77 @@ JsonSerializerOptions serializerOptions writer.WriteEndObject(); } + /// + /// Writes one CloudEvents extension attribute using the JSON Event Format context-attribute mapping. + /// + /// The JSON writer that receives the complete name/value operation. + /// The lowercase alphanumeric extension attribute name. + /// The metadata value to encode. + /// + /// + /// Null values are omitted. Booleans use JSON booleans, signed integers in the inclusive 32-bit range + /// use JSON numbers, and every other conforming primitive uses a JSON string containing its canonical + /// invariant text. This differs from the metadata value's natural JSON shape. + /// + /// + /// The attribute name and string text are validated before the property name is written, so a rejected + /// attribute cannot leave the writer with a partial JSON property. + /// + /// + /// + /// Thrown when or is null. + /// + /// + /// Thrown when is empty, invalid, reserved, or a standard CloudEvents + /// attribute name. + /// + /// + /// Thrown when is complex or its string encoding violates the CloudEvents + /// character contract. + /// + public static void WriteCloudEventsExtensionAttribute( + this Utf8JsonWriter writer, + string attributeName, + MetadataValue value + ) + { + if (writer is null) + { + throw new ArgumentNullException(nameof(writer)); + } + + ValidateExtensionAttributeName(attributeName); + var encoding = value.GetCloudEventsAttributeJsonEncoding(); + + switch (encoding) + { + case CloudEventsAttributeJsonEncoding.Null: + return; + + case CloudEventsAttributeJsonEncoding.Boolean: + value.TryGetBoolean(out var booleanValue); + writer.WritePropertyName(attributeName); + writer.WriteBooleanValue(booleanValue); + return; + + case CloudEventsAttributeJsonEncoding.Integer: + value.TryGetInt64(out var int64Value); + writer.WritePropertyName(attributeName); + writer.WriteNumberValue(int64Value); + return; + + case CloudEventsAttributeJsonEncoding.String: + ValidateCallerControlledText(attributeName, value); + WriteStringAttribute(writer, attributeName, value); + return; + + default: + throw new InvalidOperationException( + $"Unknown CloudEvents attribute JSON encoding '{encoding}'." + ); + } + } + private static void WriteEnvelopeStart( Utf8JsonWriter writer, string type, @@ -202,17 +277,119 @@ private static void WriteExtensionAttributes(Utf8JsonWriter writer, MetadataObje foreach (var keyValuePair in convertedAttributes.Value) { - if (CloudEventsConstants.StandardAttributeNames.Contains(keyValuePair.Key) || - CloudEventsConstants.ForbiddenConvertedAttributeNames.Contains(keyValuePair.Key)) + if (CloudEventsConstants.StandardAttributeNames.Contains(keyValuePair.Key)) { continue; } - writer.WritePropertyName(keyValuePair.Key); - writer.WriteMetadataValue( - keyValuePair.Value, - MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes + writer.WriteCloudEventsExtensionAttribute(keyValuePair.Key, keyValuePair.Value); + } + } + + private static void ValidateExtensionAttributeName(string attributeName) + { + if (attributeName is null) + { + throw new ArgumentNullException(nameof(attributeName)); + } + + if (string.IsNullOrWhiteSpace(attributeName)) + { + throw new ArgumentException( + "CloudEvents extension attribute names must not be empty or whitespace.", + nameof(attributeName) + ); + } + + if (!CloudEventsAttributeName.IsValidExtensionAttributeName(attributeName)) + { + throw new ArgumentException( + $"The CloudEvents extension attribute '{attributeName}' is invalid. Only lowercase alphanumeric names are allowed.", + nameof(attributeName) + ); + } + + if (CloudEventsConstants.ForbiddenConvertedAttributeNames.Contains(attributeName)) + { + throw new ArgumentException( + $"The CloudEvents extension attribute '{attributeName}' is reserved.", + nameof(attributeName) ); } + + if (CloudEventsConstants.StandardAttributeNames.Contains(attributeName)) + { + throw new ArgumentException( + $"The CloudEvents attribute '{attributeName}' is standard and cannot use extension encoding.", + nameof(attributeName) + ); + } + } + + private static void ValidateCallerControlledText(string attributeName, MetadataValue value) + { + switch (value.Kind) + { + case MetadataKind.String: + value.TryGetString(out var stringValue); + ValidateAttributeText(attributeName, stringValue.AsSpan()); + return; + + case MetadataKind.Char: + value.TryGetChar(out var character); + Span characterText = stackalloc char[1]; + characterText[0] = character; + ValidateAttributeText(attributeName, characterText); + return; + + case MetadataKind.Uri: + value.TryGetUri(out var uri); + ValidateAttributeText(attributeName, uri!.OriginalString.AsSpan()); + return; + } + } + + private static void ValidateAttributeText(string attributeName, ReadOnlySpan text) + { + var invalidIndex = CloudEventsAttributeText.IndexOfDisallowedCharacter(text); + if (invalidIndex < 0) + { + return; + } + + var codePoint = GetCodePoint(text, invalidIndex); + throw new InvalidOperationException( + $"CloudEvents extension attribute '{attributeName}' contains disallowed code point " + + $"U+{codePoint.ToString("X4", CultureInfo.InvariantCulture)} at UTF-16 index {invalidIndex}." + ); + } + + private static int GetCodePoint(ReadOnlySpan text, int index) + { + var first = text[index]; + return first is >= '\uD800' and <= '\uDBFF' && + index + 1 < text.Length && + text[index + 1] is >= '\uDC00' and <= '\uDFFF' ? + 0x10000 + ((first - '\uD800') << 10) + text[index + 1] - '\uDC00' : + first; + } + + private static void WriteStringAttribute( + Utf8JsonWriter writer, + string attributeName, + MetadataValue value + ) + { + Span canonicalText = stackalloc char[CanonicalTextBufferLength]; + if (value.TryFormatCanonical(canonicalText, out var charsWritten)) + { + writer.WritePropertyName(attributeName); + writer.WriteStringValue(canonicalText.Slice(0, charsWritten)); + return; + } + + var materializedText = value.ToCanonicalString(); + writer.WritePropertyName(attributeName); + writer.WriteStringValue(materializedText); } } diff --git a/src/Light.PortableResults/CloudEvents/Writing/PortableResultsCloudEventsWriteOptions.cs b/src/Light.PortableResults/CloudEvents/Writing/PortableResultsCloudEventsWriteOptions.cs index 8ea6ddf..41946e5 100644 --- a/src/Light.PortableResults/CloudEvents/Writing/PortableResultsCloudEventsWriteOptions.cs +++ b/src/Light.PortableResults/CloudEvents/Writing/PortableResultsCloudEventsWriteOptions.cs @@ -2,6 +2,7 @@ using System.Buffers; using System.Text.Json; using Light.PortableResults.Buffers; +using Light.PortableResults.Metadata; using Light.PortableResults.SharedJsonSerialization; namespace Light.PortableResults.CloudEvents.Writing; @@ -33,7 +34,11 @@ public sealed record PortableResultsCloudEventsWriteOptions PortableResultsCloudEventsWritingModule.DefaultSerializerOptions; /// - /// Gets or sets the conversion service used to map metadata entries to CloudEvents attributes. + /// Gets or sets the conversion service used to map metadata entries to CloudEvents attributes. After + /// conversion, extension values use the JSON Event Format mapping described by + /// . + /// Convert an to + /// when one attribute name requires a stable string encoding across the full signed 64-bit range. /// public ICloudEventsAttributeConversionService ConversionService { get; set; } = DefaultCloudEventsAttributeConversionService.Instance; diff --git a/src/Light.PortableResults/Light.PortableResults.csproj b/src/Light.PortableResults/Light.PortableResults.csproj index cc21522..fcbfa81 100644 --- a/src/Light.PortableResults/Light.PortableResults.csproj +++ b/src/Light.PortableResults/Light.PortableResults.csproj @@ -31,14 +31,28 @@ - Default HTTP header conversion no longer surrounds string metadata values with quotes. - Float metadata values now use MetadataKind.Single rather than MetadataKind.Double. - Decimal metadata values now have the dedicated kind MetadataKind.Decimal instead of MetadataKind.String, - and they are serialized as JSON numbers instead of quoted strings. This aligns the response bodies with - the generated OpenAPI documents, which have always described decimals as numbers. + and they are serialized as JSON numbers instead of quoted strings in response bodies, problem+json, and + CloudEvents data payloads. This aligns those payloads with the generated OpenAPI documents, which have + always described decimals as numbers. CloudEvents extension attributes use the separate mapping below. - MetadataValue.TryGetString no longer returns true for decimal metadata values. Use TryGetDecimal instead. - Decimal metadata values that differ only in trailing zeros (19.50 and 19.5) now compare equal, because equality is delegated to decimal. The scale is still preserved during serialization. - The numeric values of MetadataKind.Array and MetadataKind.Object changed to 200 and 201 so that the values 16 to 199 are reserved for future primitive kinds. This only affects callers that persisted or transmitted the numeric value of MetadataKind. + - CloudEvents extension attributes now follow the JSON Event Format context-attribute mapping. Double, + Single, Decimal, and Int64 values outside the inclusive 32-bit signed range are written as JSON strings + containing canonical invariant text. This does not change CloudEvents data or problem+json payloads. + - Null CloudEvents extension attributes are now omitted on write because null means unset. This does not + change null metadata in CloudEvents data or problem+json payloads. + - Null CloudEvents extension attributes are now treated as absent on read and cannot participate in metadata + conflict resolution. This does not change null metadata in CloudEvents data or problem+json payloads. + - CloudEvents extension attributes whose String encoding contains controls, Unicode noncharacters, or + unpaired surrogates are now rejected. Values are not normalized. This validation is limited to extension + attributes and does not change CloudEvents data or problem+json payloads. + - MetadataValue.TryGetInt64 now accepts canonical signed-integer text from any string metadata value, + including values originating in headers, JSON, or caller code. Noncanonical forms such as a leading plus, + negative zero, leading zeros, surrounding whitespace, and out-of-range text remain rejected. diff --git a/src/Light.PortableResults/Metadata/MetadataValue.cs b/src/Light.PortableResults/Metadata/MetadataValue.cs index 2492016..d5bb530 100644 --- a/src/Light.PortableResults/Metadata/MetadataValue.cs +++ b/src/Light.PortableResults/Metadata/MetadataValue.cs @@ -115,7 +115,7 @@ public static MetadataValue FromDecimal( decimal value, MetadataValueAnnotation annotation = DefaultAnnotation ) => - new (MetadataKind.Decimal, new MetadataPayload((object) value), annotation); + new (MetadataKind.Decimal, new MetadataPayload(value), annotation); /// /// Creates a metadata value from an unsigned 64-bit integer. @@ -136,7 +136,7 @@ public static MetadataValue FromSingle( ) { ValidateFinite(value, nameof(value)); - return new MetadataValue(MetadataKind.Single, new MetadataPayload((double) value), annotation); + return new MetadataValue(MetadataKind.Single, new MetadataPayload(value), annotation); } /// @@ -182,7 +182,7 @@ public static MetadataValue FromDateTimeOffset( DateTimeOffset value, MetadataValueAnnotation annotation = DefaultAnnotation ) => - new (MetadataKind.DateTimeOffset, new MetadataPayload((object) value), annotation); + new (MetadataKind.DateTimeOffset, new MetadataPayload(value), annotation); #if NET10_0_OR_GREATER /// @@ -220,7 +220,7 @@ public static MetadataValue FromGuid( Guid value, MetadataValueAnnotation annotation = DefaultAnnotation ) => - new (MetadataKind.Guid, new MetadataPayload((object) value), annotation); + new (MetadataKind.Guid, new MetadataPayload(value), annotation); /// /// Creates a metadata value from a URI. A null URI becomes a null metadata value. @@ -381,7 +381,7 @@ public bool TryGetBoolean(out bool value) return false; } - /// Attempts to get the stored signed 64-bit integer. + /// Attempts to get a signed 64-bit integer or parse its canonical string encoding. public bool TryGetInt64(out long value) { if (Kind == MetadataKind.Int64) @@ -390,6 +390,13 @@ public bool TryGetInt64(out long value) return true; } + if (TryGetRawString(out var text) && + long.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out value) && + string.Equals(value.ToString(CultureInfo.InvariantCulture), text, StringComparison.Ordinal)) + { + return true; + } + value = 0; return false; } diff --git a/tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeJsonEncodingTests.cs b/tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeJsonEncodingTests.cs new file mode 100644 index 0000000..fc39696 --- /dev/null +++ b/tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeJsonEncodingTests.cs @@ -0,0 +1,89 @@ +using System; +using System.Runtime.CompilerServices; +using FluentAssertions; +using Light.PortableResults.CloudEvents; +using Light.PortableResults.Metadata; +using Light.PortableResults.Tests.Metadata; +using Xunit; + +namespace Light.PortableResults.Tests.CloudEvents; + +public sealed class CloudEventsAttributeJsonEncodingTests +{ + public static TheoryData PrimitiveValues => + new () + { + { MetadataValue.Null, CloudEventsAttributeJsonEncoding.Null }, + { MetadataValue.FromBoolean(true), CloudEventsAttributeJsonEncoding.Boolean }, + { MetadataValue.FromInt64(int.MinValue), CloudEventsAttributeJsonEncoding.Integer }, + { MetadataValue.FromInt64(int.MaxValue), CloudEventsAttributeJsonEncoding.Integer }, + { MetadataValue.FromInt64((long) int.MinValue - 1), CloudEventsAttributeJsonEncoding.String }, + { MetadataValue.FromInt64((long) int.MaxValue + 1), CloudEventsAttributeJsonEncoding.String }, + { MetadataValue.FromDouble(5), CloudEventsAttributeJsonEncoding.String }, + { MetadataValue.FromString("text"), CloudEventsAttributeJsonEncoding.String }, + { MetadataValue.FromDecimal(19.50m), CloudEventsAttributeJsonEncoding.String }, + { MetadataValue.FromUInt64(ulong.MaxValue), CloudEventsAttributeJsonEncoding.String }, + { MetadataValue.FromSingle(0.1f), CloudEventsAttributeJsonEncoding.String }, + { MetadataValue.FromChar('x'), CloudEventsAttributeJsonEncoding.String }, + { + MetadataValue.FromDateTime(new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Utc)), + CloudEventsAttributeJsonEncoding.String + }, + { + MetadataValue.FromDateTimeOffset( + new DateTimeOffset(2026, 7, 26, 13, 45, 30, TimeSpan.FromHours(2)) + ), + CloudEventsAttributeJsonEncoding.String + }, +#if !TESTING_NETSTANDARD_ASSET + { MetadataValue.FromDateOnly(new DateOnly(2026, 7, 26)), CloudEventsAttributeJsonEncoding.String }, + { MetadataValue.FromTimeOnly(new TimeOnly(13, 45, 30)), CloudEventsAttributeJsonEncoding.String }, +#endif + { MetadataValue.FromTimeSpan(TimeSpan.FromSeconds(5)), CloudEventsAttributeJsonEncoding.String }, + { + MetadataValue.FromGuid(new Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890")), + CloudEventsAttributeJsonEncoding.String + }, + { + MetadataValue.FromUri(new Uri("https://example.com/items/42")), + CloudEventsAttributeJsonEncoding.String + } + }; + + [Theory] + [MemberData(nameof(PrimitiveValues))] + public void GetCloudEventsAttributeJsonEncodingShouldClassifyEveryPrimitive( + MetadataValue value, + CloudEventsAttributeJsonEncoding expected + ) + { + value.GetCloudEventsAttributeJsonEncoding().Should().Be(expected); + } + + [Fact] + public void GetCloudEventsAttributeJsonEncodingShouldNameComplexKindsInFailures() + { + var array = MetadataValue.FromArray(MetadataArray.Empty); + var @object = MetadataValue.FromObject(MetadataObject.Empty); + + Action arrayAct = () => array.GetCloudEventsAttributeJsonEncoding(); + Action objectAct = () => @object.GetCloudEventsAttributeJsonEncoding(); + + arrayAct.Should().Throw().WithMessage("*Array*"); + objectAct.Should().Throw().WithMessage("*Object*"); + } + + [Fact] + public void GetCloudEventsAttributeJsonEncodingShouldThrowForUndeclaredKind() + { + var value = MetadataValueTestFactory.CreateWithUndeclaredKind(); + + Action act = () => value.GetCloudEventsAttributeJsonEncoding(); + +#if TESTING_NETSTANDARD_ASSET + act.Should().Throw(); +#else + act.Should().Throw(); +#endif + } +} diff --git a/tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeTextTests.cs b/tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeTextTests.cs new file mode 100644 index 0000000..4af1bc9 --- /dev/null +++ b/tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeTextTests.cs @@ -0,0 +1,49 @@ +using FluentAssertions; +using Light.PortableResults.CloudEvents; +using Xunit; + +namespace Light.PortableResults.Tests.CloudEvents; + +public sealed class CloudEventsAttributeTextTests +{ + [Theory] + [InlineData("a\u0000b", 1)] + [InlineData("a\u001Fb", 1)] + [InlineData("a\u007Fb", 1)] + [InlineData("a\u009Fb", 1)] + [InlineData("a\uFDD0b", 1)] + public void IndexOfDisallowedCharacterShouldReturnFirstInvalidUtf16Index(string text, int expectedIndex) + { + CloudEventsAttributeText.IndexOfDisallowedCharacter(text).Should().Be(expectedIndex); + } + + [Fact] + public void IndexOfDisallowedCharacterShouldRejectLoneHighAndLowSurrogates() + { + var loneHighSurrogate = new string(['a', '\uD800', 'b']); + var loneLowSurrogate = new string(['a', '\uDC00', 'b']); + + CloudEventsAttributeText.IndexOfDisallowedCharacter(loneHighSurrogate).Should().Be(1); + CloudEventsAttributeText.IndexOfDisallowedCharacter(loneLowSurrogate).Should().Be(1); + } + + [Fact] + public void IndexOfDisallowedCharacterShouldRejectLastNoncharactersInEveryPlane() + { + var basicPlaneNoncharacter = new string(['a', '\uFFFE', 'b']); + var supplementaryPlaneNoncharacter = new string(['a', '\uD83F', '\uDFFE', 'b']); + + CloudEventsAttributeText.IndexOfDisallowedCharacter(basicPlaneNoncharacter).Should().Be(1); + CloudEventsAttributeText.IndexOfDisallowedCharacter(supplementaryPlaneNoncharacter).Should().Be(1); + } + + [Theory] + [InlineData("")] + [InlineData("plain ASCII")] + [InlineData("Grüße 日本語 😀")] + [InlineData("\uD83D\uDE00")] + public void IndexOfDisallowedCharacterShouldAcceptConformingText(string text) + { + CloudEventsAttributeText.IndexOfDisallowedCharacter(text).Should().Be(-1); + } +} diff --git a/tests/Light.PortableResults.Tests/CloudEvents/Reading/ReadOnlyMemoryCloudEventsExtensionsTests.cs b/tests/Light.PortableResults.Tests/CloudEvents/Reading/ReadOnlyMemoryCloudEventsExtensionsTests.cs index aa0f978..7376c6d 100644 --- a/tests/Light.PortableResults.Tests/CloudEvents/Reading/ReadOnlyMemoryCloudEventsExtensionsTests.cs +++ b/tests/Light.PortableResults.Tests/CloudEvents/Reading/ReadOnlyMemoryCloudEventsExtensionsTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Immutable; using System.Text; using System.Text.Json; using FluentAssertions; @@ -558,5 +559,119 @@ public void ReadResult_ShouldIgnoreLprOutcomeWhenMergingExtensionMetadata() result.Metadata.Should().BeNull(); } + [Fact] + public void ReadResultWithCloudEventsEnvelopeShouldReadEveryAttributeEncodingAndOmitNull() + { + var cloudEvent = CreateUtf8( + """ + { + "specversion": "1.0", + "type": "app.success", + "source": "urn:test:source", + "id": "evt-encodings", + "lproutcome": "success", + "booleanattribute": true, + "integerattribute": 2147483647, + "stringattribute": "19.50", + "largeinteger": "2147483648", + "nullattribute": null + } + """ + ); + + var envelope = cloudEvent.ReadResultWithCloudEventsEnvelope(); + + var attributes = envelope.ExtensionAttributes!.Value; + attributes["booleanattribute"].Kind.Should().Be(MetadataKind.Boolean); + attributes["integerattribute"].Kind.Should().Be(MetadataKind.Int64); + attributes["stringattribute"].Kind.Should().Be(MetadataKind.String); + attributes["largeinteger"].Kind.Should().Be(MetadataKind.String); + attributes["largeinteger"].TryGetInt64(out var largeInteger).Should().BeTrue(); + largeInteger.Should().Be(2147483648L); + attributes.ContainsKey("nullattribute").Should().BeFalse(); + } + + [Fact] + public void ReadResultShouldNotLetNullExtensionReplacePayloadMetadata() + { + var cloudEvent = CreateUtf8( + """ + { + "specversion": "1.0", + "type": "app.success", + "source": "urn:test:source", + "id": "evt-null-collision", + "lproutcome": "success", + "traceid": null, + "data": { + "metadata": { + "traceid": "from-payload" + } + } + } + """ + ); + var options = new PortableResultsCloudEventsReadOptions + { + ParsingService = new DefaultCloudEventsAttributeParsingService(), + MergeStrategy = MetadataMergeStrategy.PreserveExisting + }; + + var result = cloudEvent.ReadResult(options); + + result.Metadata!.Value.TryGetString("traceid", out var traceId).Should().BeTrue(); + traceId.Should().Be("from-payload"); + } + + [Fact] + public void ReadResultShouldUseRegisteredParserToRestoreStringMappedKind() + { + var cloudEvent = CreateUtf8( + """ + { + "specversion": "1.0", + "type": "app.success", + "source": "urn:test:source", + "id": "evt-parser", + "lproutcome": "success", + "price": "19.50" + } + """ + ); + var parsers = CloudEventsAttributeParserRegistry.Create([new DecimalAttributeParser()]); + var options = new PortableResultsCloudEventsReadOptions + { + ParsingService = new DefaultCloudEventsAttributeParsingService(parsers) + }; + + var result = cloudEvent.ReadResult(options); + + var price = result.Metadata!.Value["price"]; + price.Kind.Should().Be(MetadataKind.Decimal); + price.TryGetDecimal(out var decimalValue).Should().BeTrue(); + decimalValue.Should().Be(19.50m); + } + private static ReadOnlyMemory CreateUtf8(string json) => Encoding.UTF8.GetBytes(json); + + private sealed class DecimalAttributeParser : CloudEventsAttributeParser + { + public DecimalAttributeParser() : base("price", ImmutableArray.Create("price")) { } + + public override MetadataValue ParseAttribute( + string attributeName, + MetadataValue value, + MetadataValueAnnotation annotation + ) + { + if (!value.TryGetDecimal(out var decimalValue)) + { + throw new InvalidOperationException( + $"CloudEvents extension attribute '{attributeName}' is not a decimal." + ); + } + + return MetadataValue.FromDecimal(decimalValue, annotation); + } + } } diff --git a/tests/Light.PortableResults.Tests/CloudEvents/Writing/CloudEventsResultExtensionsTests.cs b/tests/Light.PortableResults.Tests/CloudEvents/Writing/CloudEventsResultExtensionsTests.cs index d3c13eb..b4bacfa 100644 --- a/tests/Light.PortableResults.Tests/CloudEvents/Writing/CloudEventsResultExtensionsTests.cs +++ b/tests/Light.PortableResults.Tests/CloudEvents/Writing/CloudEventsResultExtensionsTests.cs @@ -1,7 +1,13 @@ using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; using System.Text.Json; using FluentAssertions; +using Light.PortableResults.CloudEvents; using Light.PortableResults.CloudEvents.Writing; +using Light.PortableResults.CloudEvents.Writing.Json; using Light.PortableResults.Metadata; using Light.PortableResults.SharedJsonSerialization; using Xunit; @@ -537,7 +543,7 @@ public void ToCloudEvent_ShouldFallBackToCurrentTimestamp_WhenTimeAttributeIsNul } [Fact] - public void ToCloudEvent_ShouldWriteDecimalExtensionAttribute_AsUnquotedNumber() + public void ToCloudEventShouldWriteDecimalExtensionAttributeAsQuotedCanonicalText() { var metadata = MetadataObject.Create( ( @@ -557,8 +563,225 @@ public void ToCloudEvent_ShouldWriteDecimalExtensionAttribute_AsUnquotedNumber() using var document = JsonDocument.Parse(json); var price = document.RootElement.GetProperty("price"); - price.ValueKind.Should().Be(JsonValueKind.Number); - price.GetDecimal().Should().Be(19.99m); + price.ValueKind.Should().Be(JsonValueKind.String); + price.GetString().Should().Be("19.99"); + } + + [Fact] + public void ToCloudEventShouldUseCloudEventsMappingForEveryNonNullPrimitive() + { + var values = new (MetadataValue Value, JsonValueKind JsonKind, string CanonicalText)[] + { + (MetadataValue.FromBoolean(true), JsonValueKind.True, "true"), + (MetadataValue.FromInt64(int.MinValue), JsonValueKind.Number, "-2147483648"), + (MetadataValue.FromInt64(int.MaxValue), JsonValueKind.Number, "2147483647"), + (MetadataValue.FromInt64((long) int.MinValue - 1), JsonValueKind.String, "-2147483649"), + (MetadataValue.FromInt64((long) int.MaxValue + 1), JsonValueKind.String, "2147483648"), + (MetadataValue.FromDouble(5), JsonValueKind.String, "5.0"), + (MetadataValue.FromString("plain text"), JsonValueKind.String, "plain text"), + (MetadataValue.FromDecimal(19.50m), JsonValueKind.String, "19.50"), + (MetadataValue.FromUInt64(ulong.MaxValue), JsonValueKind.String, "18446744073709551615"), + (MetadataValue.FromSingle(0.1f), JsonValueKind.String, "0.1"), + (MetadataValue.FromChar('ß'), JsonValueKind.String, "ß"), + ( + MetadataValue.FromDateTime(new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Utc)), + JsonValueKind.String, + "2026-07-26T13:45:30Z" + ), + ( + MetadataValue.FromDateTimeOffset( + new DateTimeOffset(2026, 7, 26, 13, 45, 30, TimeSpan.FromHours(2)) + ), + JsonValueKind.String, + "2026-07-26T13:45:30+02:00" + ), +#if !TESTING_NETSTANDARD_ASSET + ( + MetadataValue.FromDateOnly(new DateOnly(2026, 7, 26)), + JsonValueKind.String, + "2026-07-26" + ), + (MetadataValue.FromTimeOnly(new TimeOnly(13, 45, 30)), JsonValueKind.String, "13:45:30"), +#endif + (MetadataValue.FromTimeSpan(TimeSpan.FromSeconds(5)), JsonValueKind.String, "PT5S"), + ( + MetadataValue.FromGuid(new Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890")), + JsonValueKind.String, + "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + ), + ( + MetadataValue.FromUri(new Uri("https://example.com/items/42")), + JsonValueKind.String, + "https://example.com/items/42" + ) + }; + + foreach (var (value, expectedKind, expectedText) in values) + { + var annotatedValue = MetadataValueAnnotationHelper.WithAnnotation( + value, + MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes + ); + var result = Result.Ok(MetadataObject.Create(("attribute", annotatedValue))); + + var json = result.ToCloudEvent( + successType: "app.success", + failureType: "app.failure", + id: "evt-matrix", + time: new DateTimeOffset(2026, 7, 26, 13, 45, 30, TimeSpan.Zero), + options: CreateWriteOptions() + ); + + using var document = JsonDocument.Parse(json); + var attribute = document.RootElement.GetProperty("attribute"); + attribute.ValueKind.Should().Be(expectedKind, "{0} has a normative CloudEvents encoding", value.Kind); + var actualText = expectedKind == JsonValueKind.String ? attribute.GetString() : attribute.GetRawText(); + actualText.Should().Be(expectedText, "{0} uses canonical invariant text", value.Kind); + } + } + + [Fact] + public void ToCloudEventShouldOmitNullExtensionAttributeWithoutChangingEnvelopeBytes() + { + var annotation = MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes; + var withNull = Result.Ok(MetadataObject.Create(("optional", MetadataValue.FromNull(annotation)))); + var withoutAttribute = Result.Ok(); + var time = new DateTimeOffset(2026, 7, 26, 13, 45, 30, TimeSpan.Zero); + + var withNullJson = withNull.ToCloudEvent( + successType: "app.success", + failureType: "app.failure", + id: "evt-null", + time: time, + options: CreateWriteOptions() + ); + var withoutAttributeJson = withoutAttribute.ToCloudEvent( + successType: "app.success", + failureType: "app.failure", + id: "evt-null", + time: time, + options: CreateWriteOptions() + ); + + withNullJson.Should().Equal(withoutAttributeJson); + } + + [Fact] + public void ToCloudEventShouldExposeValueDependentInt64ShapeForOneAttributeName() + { + using var inRange = WriteInt64Attribute(int.MaxValue, CreateWriteOptions()); + using var outOfRange = WriteInt64Attribute((long) int.MaxValue + 1, CreateWriteOptions()); + + inRange.RootElement.GetProperty("sequence").ValueKind.Should().Be(JsonValueKind.Number); + outOfRange.RootElement.GetProperty("sequence").ValueKind.Should().Be(JsonValueKind.String); + } + + [Fact] + public void ToCloudEventShouldKeepInt64ShapeStableWhenConverterUsesStringKind() + { + var converter = new Int64ToStringAttributeConverter(); + var converters = new Dictionary(StringComparer.Ordinal) + { + ["sequence"] = converter + }.ToFrozenDictionary(StringComparer.Ordinal); + var options = CreateWriteOptions(); + options.ConversionService = new DefaultCloudEventsAttributeConversionService(converters); + + using var inRange = WriteInt64Attribute(int.MaxValue, options); + using var outOfRange = WriteInt64Attribute((long) int.MaxValue + 1, options); + + inRange.RootElement.GetProperty("sequence").ValueKind.Should().Be(JsonValueKind.String); + outOfRange.RootElement.GetProperty("sequence").ValueKind.Should().Be(JsonValueKind.String); + inRange.RootElement.GetProperty("sequence").GetString().Should().Be("2147483647"); + outOfRange.RootElement.GetProperty("sequence").GetString().Should().Be("2147483648"); + } + + [Fact] + public void ToCloudEventShouldRejectInvalidTextReturnedByCustomConversionService() + { + var annotation = MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes; + var metadata = MetadataObject.Create(("original", MetadataValue.FromString("safe", annotation))); + var options = CreateWriteOptions(); + options.ConversionService = new InvalidTextConversionService(); + + Action act = () => Result.Ok(metadata).ToCloudEvent( + successType: "app.success", + failureType: "app.failure", + id: "evt-invalid-custom", + options: options + ); + + act.Should().Throw().WithMessage("*converted*U+0001*"); + } + + [Fact] + public void WriteCloudEventsShouldRejectInvalidTextFromDirectlyConstructedEnvelope() + { + var extensionAttributes = MetadataObject.Create( + ("direct", MetadataValue.FromString("invalid\uFDD0text")) + ); + var envelope = new CloudEventsEnvelopeForWriting( + "app.success", + "urn:test:source", + "evt-direct", + Result.Ok(), + new ResolvedCloudEventsWriteOptions(MetadataSerializationMode.ErrorsOnly), + ExtensionAttributes: extensionAttributes + ); + + Action act = () => + { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + writer.WriteCloudEvents(envelope, PortableResultsCloudEventsWriteOptions.Default.SerializerOptions); + }; + + act.Should().Throw().WithMessage("*direct*U+FDD0*"); + } + + [Fact] + public void ToCloudEventShouldKeepMetadataResolvedStandardAttributesAsStrings() + { + var annotation = MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes; + var metadata = MetadataObject.Create( + ("type", MetadataValue.FromInt64(42, annotation)), + ("source", MetadataValue.FromUri(new Uri("urn:test:source"), annotation)), + ("subject", MetadataValue.FromDecimal(19.50m, annotation)), + ( + "dataschema", + MetadataValue.FromUri(new Uri("https://example.com/schema"), annotation) + ), + ( + "time", + MetadataValue.FromDateTimeOffset( + new DateTimeOffset(2026, 7, 26, 13, 45, 30, TimeSpan.Zero), + annotation + ) + ), + ( + "id", + MetadataValue.FromGuid( + new Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"), + annotation + ) + ) + ); + + var json = Result.Ok(metadata).ToCloudEvent(options: CreateWriteOptions(source: null)); + + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + foreach (var attributeName in new[] { "type", "source", "subject", "dataschema", "time", "id" }) + { + root.GetProperty(attributeName).ValueKind.Should().Be(JsonValueKind.String); + } + + root.GetProperty("type").GetString().Should().Be("42"); + root.GetProperty("source").GetString().Should().Be("urn:test:source"); + root.GetProperty("subject").GetString().Should().Be("19.50"); + root.GetProperty("dataschema").GetString().Should().Be("https://example.com/schema"); + root.GetProperty("time").GetString().Should().Be("2026-07-26T13:45:30.0000000+00:00"); + root.GetProperty("id").GetString().Should().Be("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); } [Fact] @@ -758,4 +981,53 @@ private static PortableResultsCloudEventsWriteOptions CreateWriteOptions(string? Source = source }; } + + private static JsonDocument WriteInt64Attribute( + long value, + PortableResultsCloudEventsWriteOptions options + ) + { + var metadata = MetadataObject.Create( + ( + "sequence", + MetadataValue.FromInt64( + value, + MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes + ) + ) + ); + var json = Result.Ok(metadata).ToCloudEvent( + successType: "app.success", + failureType: "app.failure", + id: "evt-int64-shape", + options: options + ); + return JsonDocument.Parse(json); + } + + private sealed class Int64ToStringAttributeConverter : CloudEventsAttributeConverter + { + public Int64ToStringAttributeConverter() : base(ImmutableArray.Create("sequence")) { } + + public override KeyValuePair PrepareCloudEventsAttribute( + string metadataKey, + MetadataValue value + ) => + new ( + metadataKey, + MetadataValue.FromString(value.ToCanonicalString(), value.Annotation) + ); + } + + private sealed class InvalidTextConversionService : ICloudEventsAttributeConversionService + { + public KeyValuePair PrepareCloudEventsAttribute( + string metadataKey, + MetadataValue metadataValue + ) => + new ( + "converted", + MetadataValue.FromString("invalid\u0001text", metadataValue.Annotation) + ); + } } diff --git a/tests/Light.PortableResults.Tests/CloudEvents/Writing/JsonCloudEventsExtensionsTests.cs b/tests/Light.PortableResults.Tests/CloudEvents/Writing/JsonCloudEventsExtensionsTests.cs new file mode 100644 index 0000000..c59f4d1 --- /dev/null +++ b/tests/Light.PortableResults.Tests/CloudEvents/Writing/JsonCloudEventsExtensionsTests.cs @@ -0,0 +1,315 @@ +using System; +using System.Buffers; +using System.IO; +using System.Text.Json; +using FluentAssertions; +using Light.PortableResults.CloudEvents.Writing.Json; +using Light.PortableResults.Metadata; +using Xunit; + +namespace Light.PortableResults.Tests.CloudEvents.Writing; + +public sealed class JsonCloudEventsExtensionsTests +{ + private const int AllocationSampleCount = 5; + + [Fact] + public void WriteCloudEventsExtensionAttributeShouldThrowWhenWriterIsNull() + { + Utf8JsonWriter writer = null!; + + var act = () => writer.WriteCloudEventsExtensionAttribute("attempt", MetadataValue.FromInt64(1)); + + act.Should().Throw().Where(exception => exception.ParamName == "writer"); + } + + [Fact] + public void WriteCloudEventsExtensionAttributeShouldRejectNullNameBeforeWriting() + { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + writer.WriteStartObject(); + var bytesPending = writer.BytesPending; + + var act = () => writer.WriteCloudEventsExtensionAttribute(null!, MetadataValue.FromInt64(1)); + + act.Should().Throw().Where(exception => exception.ParamName == "attributeName"); + writer.BytesPending.Should().Be(bytesPending); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void WriteCloudEventsExtensionAttributeShouldRejectBlankNameBeforeWriting(string attributeName) + { + AssertNameRejectedBeforeWriting(attributeName, "*empty or whitespace*"); + } + + [Theory] + [InlineData("Traceid")] + [InlineData("trace-id")] + [InlineData("trace_id")] + [InlineData("tracé")] + public void WriteCloudEventsExtensionAttributeShouldRejectNonLowercaseAlphanumericNameBeforeWriting( + string attributeName + ) + { + AssertNameRejectedBeforeWriting(attributeName, "*lowercase alphanumeric*"); + } + + [Fact] + public void WriteCloudEventsExtensionAttributeShouldRejectReservedNameBeforeWriting() + { + AssertNameRejectedBeforeWriting("lproutcome", "*reserved*"); + } + + [Fact] + public void WriteCloudEventsExtensionAttributeShouldRejectStandardNameBeforeWriting() + { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + writer.WriteStartObject(); + var bytesPending = writer.BytesPending; + + var act = () => writer.WriteCloudEventsExtensionAttribute("type", MetadataValue.FromInt64(1)); + + act.Should().Throw().WithMessage("*standard*"); + writer.BytesPending.Should().Be(bytesPending); + } + + [Theory] + [InlineData('\u0001', "U+0001")] + [InlineData('\u0080', "U+0080")] + [InlineData('\uD800', "U+D800")] + [InlineData('\uDC00', "U+DC00")] + [InlineData('\uFDD0', "U+FDD0")] + public void WriteCloudEventsExtensionAttributeShouldRejectInvalidCharBeforeWriting( + char character, + string expectedCodePoint + ) + { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + writer.WriteStartObject(); + var bytesPending = writer.BytesPending; + + var act = () => writer.WriteCloudEventsExtensionAttribute( + "attempt", + MetadataValue.FromChar(character) + ); + + act.Should().Throw() + .WithMessage($"*attempt*{expectedCodePoint}*"); + writer.BytesPending.Should().Be(bytesPending); + } + + [Fact] + public void WriteCloudEventsExtensionAttributeShouldAcceptSurrogatePairAndNonAsciiText() + { + using var document = WriteAttribute(MetadataValue.FromString("Grüße 日本語 😀")); + + document.RootElement.GetProperty("attribute").GetString().Should().Be("Grüße 日本語 😀"); + } + + [Fact] + public void WriteCloudEventsExtensionAttributeShouldValidateUriOriginalText() + { + var uri = new Uri("https://example.com/invalid\uFDD0text"); + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + writer.WriteStartObject(); + var bytesPending = writer.BytesPending; + + var act = () => writer.WriteCloudEventsExtensionAttribute( + "location", + MetadataValue.FromUri(uri) + ); + + act.Should().Throw().WithMessage("*location*U+FDD0*"); + writer.BytesPending.Should().Be(bytesPending); + } + + [Fact] + public void WriteCloudEventsExtensionAttributeShouldNameComplexKindBeforeWriting() + { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + writer.WriteStartObject(); + var bytesPending = writer.BytesPending; + + var act = () => writer.WriteCloudEventsExtensionAttribute( + "attribute", + MetadataValue.FromArray(MetadataArray.Empty) + ); + + act.Should().Throw().WithMessage("*Array*"); + writer.BytesPending.Should().Be(bytesPending); + } + + [Fact] + public void WriteCloudEventsExtensionAttributeShouldOmitNullAtomically() + { + using var withNull = WriteAttribute(MetadataValue.Null); + using var withoutAttribute = JsonDocument.Parse("{}"); + + withNull.RootElement.GetRawText().Should().Be(withoutAttribute.RootElement.GetRawText()); + } + + [Fact] + public void FloatingPointExtensionAttributeWritingShouldAllocateNothingAfterWarmup() + { + var output = new ArrayBufferWriter(1024 * 1024); + using var writer = new Utf8JsonWriter(output); + var doubleValue = MetadataValue.FromDouble(36_028_797_018_963_968.0); + var singleValue = MetadataValue.FromSingle(123_456_789f); + writer.WriteStartObject(); + + for (var index = 0; index < 100; index++) + { + writer.WriteCloudEventsExtensionAttribute("doublevalue", doubleValue); + writer.WriteCloudEventsExtensionAttribute("singlevalue", singleValue); + } + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var index = 0; index < 1_000; index++) + { + writer.WriteCloudEventsExtensionAttribute("doublevalue", doubleValue); + writer.WriteCloudEventsExtensionAttribute("singlevalue", singleValue); + } + + var after = GC.GetAllocatedBytesForCurrentThread(); + after.Should().Be(before); + } + + [Fact] + public void OtherStringMappedKindsShouldNotAllocateMoreThanCanonicalFormatting() + { + var values = new[] + { + MetadataValue.FromInt64((long) int.MaxValue + 1), + MetadataValue.FromString(new string('x', 64)), + MetadataValue.FromDecimal(decimal.MinValue), + MetadataValue.FromUInt64(ulong.MaxValue), + MetadataValue.FromChar('ß'), + MetadataValue.FromDateTime( + new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Utc).AddTicks(1_234_567) + ), + MetadataValue.FromDateTimeOffset( + new DateTimeOffset(2026, 7, 26, 13, 45, 30, TimeSpan.FromHours(2)).AddTicks(1_234_567) + ), +#if !TESTING_NETSTANDARD_ASSET + MetadataValue.FromDateOnly(new DateOnly(2026, 7, 26)), + MetadataValue.FromTimeOnly(new TimeOnly(13, 45, 30, 123)), +#endif + MetadataValue.FromTimeSpan(TimeSpan.MaxValue), + MetadataValue.FromGuid(new Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890")), + MetadataValue.FromUri(new Uri("https://example.com/a/path/longer/than/thirty-six/characters")) + }; + + foreach (var value in values) + { + var canonicalAllocations = MeasureMinimumCanonicalFormattingAllocations(value); + var writerAllocations = MeasureMinimumWriterAllocations(value); + + writerAllocations.Should().BeLessThanOrEqualTo( + canonicalAllocations, + "writing {0} should materialize at most its existing canonical string", + value.Kind + ); + } + } + + private static void AssertNameRejectedBeforeWriting(string attributeName, string expectedMessage) + { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + writer.WriteStartObject(); + var bytesPending = writer.BytesPending; + + var act = () => writer.WriteCloudEventsExtensionAttribute( + attributeName, + MetadataValue.FromInt64(1) + ); + + act.Should().Throw().WithMessage(expectedMessage); + writer.BytesPending.Should().Be(bytesPending); + } + + private static JsonDocument WriteAttribute(MetadataValue value) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + writer.WriteCloudEventsExtensionAttribute("attribute", value); + writer.WriteEndObject(); + writer.Flush(); + } + + return JsonDocument.Parse(stream.ToArray()); + } + + // A persistent per-write allocation appears in every sample; taking the minimum filters out + // occasional runtime or test-host allocations recorded on the current thread. + private static long MeasureMinimumCanonicalFormattingAllocations(MetadataValue value) + { + var minimumAllocations = long.MaxValue; + for (var sample = 0; sample < AllocationSampleCount; sample++) + { + minimumAllocations = Math.Min( + minimumAllocations, + MeasureCanonicalFormattingAllocations(value) + ); + } + + return minimumAllocations; + } + + private static long MeasureMinimumWriterAllocations(MetadataValue value) + { + var minimumAllocations = long.MaxValue; + for (var sample = 0; sample < AllocationSampleCount; sample++) + { + minimumAllocations = Math.Min(minimumAllocations, MeasureWriterAllocations(value)); + } + + return minimumAllocations; + } + + private static long MeasureCanonicalFormattingAllocations(MetadataValue value) + { + Span destination = stackalloc char[36]; + for (var index = 0; index < 100; index++) + { + value.TryFormatCanonical(destination, out _); + } + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var index = 0; index < 1_000; index++) + { + value.TryFormatCanonical(destination, out _); + } + + return GC.GetAllocatedBytesForCurrentThread() - before; + } + + private static long MeasureWriterAllocations(MetadataValue value) + { + var output = new ArrayBufferWriter(1024 * 1024); + using var writer = new Utf8JsonWriter(output); + writer.WriteStartObject(); + for (var index = 0; index < 100; index++) + { + writer.WriteCloudEventsExtensionAttribute("attribute", value); + } + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var index = 0; index < 1_000; index++) + { + writer.WriteCloudEventsExtensionAttribute("attribute", value); + } + + return GC.GetAllocatedBytesForCurrentThread() - before; + } +} diff --git a/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs b/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs index 64d2626..6a4bcb4 100644 --- a/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs +++ b/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs @@ -216,6 +216,13 @@ public void DateTimeAccessorShouldRejectTextCarryingANumericOffset() [Fact] public void StringAccessorsShouldAcceptOnlyCanonicalEncodings() { + MetadataValue + .FromString(long.MinValue.ToString()) + .TryGetInt64(out var int64) + .Should() + .BeTrue(); + int64.Should().Be(long.MinValue); + MetadataValue.FromString(ulong.MaxValue.ToString()) .TryGetUInt64(out var uint64) .Should() @@ -271,6 +278,21 @@ public void StringAccessorsShouldAcceptOnlyCanonicalEncodings() MetadataValue.FromString("hello world").TryGetUri(out _).Should().BeFalse(); } + [Theory] + [InlineData("+5")] + [InlineData("-0")] + [InlineData("01234")] + [InlineData(" 5")] + [InlineData("9223372036854775808")] + [InlineData("-9223372036854775809")] + public void Int64AccessorShouldRejectNoncanonicalOrOutOfRangeText(string text) + { + var value = MetadataValue.FromString(text); + + value.TryGetInt64(out var result).Should().BeFalse(); + result.Should().Be(0); + } + [Fact] public void EveryKindShouldSerializeWithItsVocabularyEncoding() { From f5c8fb9f9cf6d9ea0d68d0b4a342c2ee74d22da6 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 09:37:34 +0200 Subject: [PATCH 12/12] fix(cloud-events): complete extension attribute handling Remove an unreachable encoding fallback and close the warning suppression. Cover the public name and text validation gaps, and document reserved outcome handling in the release notes. --- .../CloudEventsAttributeJsonEncoding.cs | 2 ++ .../Writing/Json/JsonCloudEventsExtensions.cs | 5 ----- .../Light.PortableResults.csproj | 4 ++++ .../CloudEventsAttributeNameTests.cs | 18 ++++++++++++++++++ .../CloudEventsAttributeTextTests.cs | 1 + 5 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeNameTests.cs diff --git a/src/Light.PortableResults/CloudEvents/CloudEventsAttributeJsonEncoding.cs b/src/Light.PortableResults/CloudEvents/CloudEventsAttributeJsonEncoding.cs index 34f8852..58cd5c0 100644 --- a/src/Light.PortableResults/CloudEvents/CloudEventsAttributeJsonEncoding.cs +++ b/src/Light.PortableResults/CloudEvents/CloudEventsAttributeJsonEncoding.cs @@ -98,3 +98,5 @@ private static CloudEventsAttributeJsonEncoding ThrowComplexValue(MetadataKind k $"CloudEvents extension attributes cannot encode metadata kind '{kind}'." ); } + +#pragma warning restore CS8524 diff --git a/src/Light.PortableResults/CloudEvents/Writing/Json/JsonCloudEventsExtensions.cs b/src/Light.PortableResults/CloudEvents/Writing/Json/JsonCloudEventsExtensions.cs index 3fc6775..fa9e532 100644 --- a/src/Light.PortableResults/CloudEvents/Writing/Json/JsonCloudEventsExtensions.cs +++ b/src/Light.PortableResults/CloudEvents/Writing/Json/JsonCloudEventsExtensions.cs @@ -197,11 +197,6 @@ MetadataValue value ValidateCallerControlledText(attributeName, value); WriteStringAttribute(writer, attributeName, value); return; - - default: - throw new InvalidOperationException( - $"Unknown CloudEvents attribute JSON encoding '{encoding}'." - ); } } diff --git a/src/Light.PortableResults/Light.PortableResults.csproj b/src/Light.PortableResults/Light.PortableResults.csproj index fcbfa81..9c32266 100644 --- a/src/Light.PortableResults/Light.PortableResults.csproj +++ b/src/Light.PortableResults/Light.PortableResults.csproj @@ -50,6 +50,10 @@ - CloudEvents extension attributes whose String encoding contains controls, Unicode noncharacters, or unpaired surrogates are now rejected. Values are not normalized. This validation is limited to extension attributes and does not change CloudEvents data or problem+json payloads. + - A custom ICloudEventsAttributeConversionService or directly constructed CloudEvents envelope that supplies + the reserved lproutcome attribute now causes an ArgumentException during JSON writing instead of having + that supplied value silently dropped. Supplied data and data_base64 entries remain skipped as standard + attributes. - MetadataValue.TryGetInt64 now accepts canonical signed-integer text from any string metadata value, including values originating in headers, JSON, or caller code. Noncanonical forms such as a leading plus, negative zero, leading zeros, surrounding whitespace, and out-of-range text remain rejected. diff --git a/tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeNameTests.cs b/tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeNameTests.cs new file mode 100644 index 0000000..d685a20 --- /dev/null +++ b/tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeNameTests.cs @@ -0,0 +1,18 @@ +using System; +using FluentAssertions; +using Light.PortableResults.CloudEvents; +using Xunit; + +namespace Light.PortableResults.Tests.CloudEvents; + +public sealed class CloudEventsAttributeNameTests +{ + [Fact] + public void IsValidExtensionAttributeNameShouldThrowWhenAttributeNameIsNull() + { + Action act = () => CloudEventsAttributeName.IsValidExtensionAttributeName(null!); + + act.Should().Throw() + .Where(exception => exception.ParamName == "attributeName"); + } +} diff --git a/tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeTextTests.cs b/tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeTextTests.cs index 4af1bc9..837c00f 100644 --- a/tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeTextTests.cs +++ b/tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeTextTests.cs @@ -42,6 +42,7 @@ public void IndexOfDisallowedCharacterShouldRejectLastNoncharactersInEveryPlane( [InlineData("plain ASCII")] [InlineData("Grüße 日本語 😀")] [InlineData("\uD83D\uDE00")] + [InlineData("\uFFFD")] public void IndexOfDisallowedCharacterShouldAcceptConformingText(string text) { CloudEventsAttributeText.IndexOfDisallowedCharacter(text).Should().Be(-1);