Skip to content

An AOT-safe, source-generated DataContractSerializer - #1766

Draft
g7ed6e wants to merge 39 commits into
CoreWCF:mainfrom
g7ed6e:feat/aot-datacontractserializer
Draft

An AOT-safe, source-generated DataContractSerializer#1766
g7ed6e wants to merge 39 commits into
CoreWCF:mainfrom
g7ed6e:feat/aot-datacontractserializer

Conversation

@g7ed6e

@g7ed6e g7ed6e commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

An AOT-safe, source-generated DataContractSerializer

Draft. Not ready to merge — see Open decisions for the calls that need a maintainer.

DataContractSerializer cannot run under PublishAot: it generates IL at run time and reflects over
members a trimmer has no reason to keep. Every CoreWCF service that carries a data contract inherits
that. This branch adds a Roslyn generator that reads [DataContract]/[DataMember] at compile time
and emits a reflection-free serializer per contract, plus the seam CoreWCF needs to use one.

It is an optimization with a fallback, not a replacement. With the switch off, not one byte of
CoreWCF's behaviour changes: GetSerializer returns null, the formatter uses the reflection-based
serializer, and the 155 existing CoreWCF.Primitives tests pass unchanged. Contracts migrate one at
a time.

The premise is executed rather than assumed: a CoreWCF service published with Native AOT answers over
HTTP, and the serializer it replaces fails on the same contract in the same binary.


Contents


Scope

In: the operation body path. A service's parameters and return values are serialized and
deserialized by generated code when a context covers them.

Out, deliberately: everything else that constructs a DataContractSerializer directly —
PrimitiveOperationFormatter, FaultContractInfo, Message/MessageFault/MessageHeader(s)/
AddressHeader, and the WS-Trust and secure-conversation serializers. Those are listed under
Still to do; none of them is blocked by anything here.

Out, permanently: contracts with no accessible parameterless constructor.
DataContractSerializer allocates without running one and generated code cannot. These are written
byte-exactly and never read — the one place the generated path is strictly less capable than
reflection. It declines rather than producing a wrong graph.


What works

Corpus coverage
WriteObject 81 of 86 cases byte-identical to the recorded fixture
ReadObject 79 of 86 — the same set minus the two constructors above

The five write-side skips are deliberate exclusions: three contracts whose [KnownType] names a
method resolved at run time, one with a non-public data member, one with no [DataContract] at all.

Capabilities covered, each verified byte-for-byte:

  • member ordering and Order
  • EmitDefaultValue, IsRequired
  • contract and member renaming
  • every primitive XmlWriterDelegator handles
  • i:nil, Nullable<T>
  • nested contracts, inheritance
  • enums, including [Flags] and [EnumMember]
  • arrays, List<T>, jagged arrays, ArrayList
  • Dictionary<K,V>
  • IsReferencez:Id/z:Ref, cycles
  • [KnownType] and i:type polymorphism
  • boxed members (object, ValueType, Enum, Array)
  • [Serializable], Uri, DateTimeOffset, XmlQualifiedName
  • DateOnly/TimeOnly, whose format the runtime decides

Verification

Suite Result
CoreWCF.DataContractSerialization.Tests 365 passing × net8.0/9.0/10.0, 198 × net472, 0 failures
CoreWCF.DataContractSerialization.Generator.Tests 74 passing × net8.0/9.0/10.0
CoreWCF.Primitives.Tests (switch never set) 155 passing — the gate that matters
CoreWCF.sln builds with 0 errors
AOT smoke test published, run, all five stages pass

net472 runs the same suite and reports every case unsupported rather than silently passing, which
is a distinct outcome worth asserting: a pass because nothing ran looks identical to a pass because
everything matched.


How the generator is built

The pipeline

[DataContractSerializable] on a partial context class
        │
        ▼
ForAttributeWithMetadataName ──► Parser ──► ContextSpec ──► Emitter ──► one .g.cs
                                (symbols)   (values only)   (no symbols)

Parser is the only code that touches an ISymbol, and it runs inside transform — before
Collect() — so its result is a plain value Roslyn can compare and cache. Emitter never sees a
symbol or a Compilation.

This is a deliberate divergence from CoreWCF.BuildTools, noted in a comment where it matters.
Those generators hold raw ISymbol references in their specs and build them inside
RegisterSourceOutput, which defeats incremental caching and re-runs on every keystroke. The cost of
getting this wrong is build time, silently — it fails no test.

Two consequences of that rule shape the code:

  • EquatableArray<T> wraps every collection on a spec. Arrays compare by reference, so a spec
    holding a bare T[] never equals its predecessor and the cache never hits.
  • DiagnosticInfo / LocationInfo carry a diagnostic as values. A Diagnostic holds a
    Location, which holds a SyntaxTree, which roots an entire Compilation; putting one in a cached
    model would both defeat caching and keep compilations alive. The real diagnostic is built at the
    point of reporting.

Layout

Five files mirroring the house style of CoreWCF.BuildTools, ~5,400 lines:

File Lines
…Generator.Emitter.cs 3,197 spec → StringBuilderAddSource
…Generator.Parser.cs 1,451 the only symbol-aware code; reports diagnostics
…Generator.ContractSpec.cs 342 per-contract and per-member specs, MemberKind
…Generator.cs 88 pipeline only
EquatableArray.cs, DiagnosticInfo.cs, DiagnosticDescriptors.cs, Indentor.cs ~300 plumbing

What gets emitted

One partial fill-in for the user's context class, containing:

  • shared helpersWriteNil, IsNil, ReadText, ReadXsiType, ReadQName, WriteEnum/
    ReadEnum, the DateTimeOffset adapter, the boxed-primitive table and its inverse;
  • a reference scope per direction__ReferenceScope (object → id, plus the by-value cycle
    guard) and __ReadScope (id → object), one instance per WriteObject/ReadObject call so nothing
    survives between documents;
  • a content writer and content reader per contract, taking the scope, with no wrapping element —
    the mirror of how the wire format nests;
  • a serializer class per root contract, deriving from AotXmlObjectSerializer.

Three levels of falling back, all returning null or false

Level Signal
The generator declines a contract no serializer emitted; GetSerializer returns null
The context does not cover the operation's known types CreateAotSerializer returns null
The serializer writes but cannot read CanReadObject is false

None of them throws, and each is checked before use, so a contract the generator does not handle is
simply served by the reflection-based serializer. That is what makes step-by-step migration possible.

Since falling back under AOT means a runtime failure rather than a lost optimization, each one is
also a build warning — COREWCF_0403 (nothing generated) and COREWCF_0404 (written but not read),
both Warning severity and suppressible. They carry the reason, nested as far as it goes:

App.Outer … member 'Value' cannot be read back: App.Inner cannot be read back: member
'_hidden' is not public

Reported on the type the user listed, not on every contract reachable from it — a nested contract
that cannot be written makes its container unsupported too, and reporting both buries the actionable
line under its consequences.

The generated code is checked in

Five snapshots, taken with Verify.SourceGenerators, put ~4,600 lines of emitted code in the
repository under …Generator/tests/Snapshots/. Nobody reviewing a change to this generator can hold
six hundred lines of output in their head from a diff of the emitter; here they read the output, and
a change to it turns up in the diff of the pull request that caused it.

The line-by-line assertions stay, because the two answer different questions. An assertion says why
a rule exists — that a QName resolves its prefix before the element closes — and survives
reformatting. A snapshot says what was actually emitted, in full. A changed snapshot tells you
something moved but not whether it should have; a passing assertion tells you one line is right and
nothing about the other six hundred. Neither says the output is correct — only the corpus does
that, by comparing bytes against the real serializer.

Five cases rather than one per feature: each snapshot carries the shared helpers as well as its own
contract's code, so they overlap heavily and a sixth would mostly repeat the first. One of them is a
pair of contracts that fall back, because Verify renders diagnostics beside sources — making it the
one place the wording of COREWCF_0403/0404 is visible as a user reads it.

One snapshot serves all three target frameworks. Verify names the .received. file per framework so
concurrent runs cannot collide, but they are compared against the same .verified. file, which is
the right arrangement: the generator's output is a function of the contract it is given rather than
of the runtime the test host is on. Every run re-checks that claim.

Gated by target framework

CoreWCF.DataContractSerialization.props/.targets ship under build/buildTransitive and enable
the generator on net8.0+ only, mirroring how CoreWCF.Primitives.targets gates the OperationInvoker
generator. Two things follow: emitted code may assume a modern language version rather than the
oldest any consumer compiles with, and GetSerializer is virtual returning null rather than
abstract, so a user's partial class MyContext : DataContractSerializerContext still compiles on
net472 where the generator never runs — no #if in user code.

The seam

XmlObjectSerializer cannot be subclassed cleanly: all 16 members carry [RequiresDynamicCode] and
[RequiresUnreferencedCode], IL2046/IL3051 are symmetric so an override must repeat them, and CoreWCF
calls through the base-typed reference anyway. A reflection-free subclass would make serialization
work under AOT without silencing one warning.

The System.Text.Json answer applies: STJ never un-annotated Serialize<T>(T), it added
Serialize(T, JsonTypeInfo<T>) alongside. So this branch adds AotXmlObjectSerializer — an
attribute-free abstraction in CoreWCF.Primitives carrying only the four members the formatter uses —
and DataContractSerializerOperationBehavior.CreateAotSerializer beside CreateSerializer.
PartInfo prefers it for WriteObject, and for IsStartObject/ReadObject when CanReadObject.

Suppressing on CoreWCF's call sites was rejected: at that point CoreWCF cannot know whether the
instance is generated or a real DataContractSerializer, so the suppression would be unsound and
would turn build warnings into runtime failures in someone else's app.


How it was verified

Two disciplines did most of the work, and both are worth keeping.

An oracle, compared byte for byte

86 corpus cases — 75 imported from the WCF test corpus plus 11 written for shapes it does not
reach — each with its exact serialized bytes recorded from the real DataContractSerializer.
Generated output is compared to those bytes, never semantically.

That matters because the hard part is not the element tree, it is the namespace machinery. xmlns:z
sits on the root when the root contract is IsReference but is redeclared on every element when only
a nested member is; z:Id precedes the xmlns declarations in attribute order; a collection member
declares xmlns:b on the member element rather than the root. A semantic XML diff calls all of those
equal.

Per-TFM overrides record where runtimes genuinely differ — DateOnly writes an empty element before
.NET 10 and a value from .NET 10 on, so one test verifies both halves by running on both.

Fixtures are never regenerated to make the generator pass. A mismatch means the generator is
wrong.

The read side has its own oracle: read with generated code, write back with the reflection
serializer, compare to the fixture. A read bug shows up as a byte difference. It caught struct
contracts being read into a copy — ref was missing — which failed nowhere and lost every member.

Read upstream before implementing

System.Private.DataContractSerialization is MIT-licensed source. Reading it first, and citing the
file wherever a non-obvious rule is implemented, repeatedly beat inference:

  • ReadElementContentAsTimeOnly goes through XmlConvert.ToDateTimeOffset, not the
    ParseTimeOnly helper defined next to it — which is dead code. Copying the helper would have
    narrowed what the reader accepts.
  • A z:Ref never points forward, so IsReference needs an id table and not a fixup pass. Upstream
    says so outright: "BinaryFormatter supports this by fixing up such references later. These
    XmlObjectSerializer implementations do not currently support fix-ups. Hence we throw."
    What makes
    a cycle work is recording the instance before reading its members.
  • Member ordering: unordered members precede ordered ones, including Order = 0, compared
    ordinally — settled by ClassDataContract.DataMemberComparer rather than by guessing.

What that combination caught

A sample, all of which passed casual inspection:

  • sbyte is "byte" on the wire while byte is "unsignedByte"; char, Guid and TimeSpan are
    named in the serialization namespace because XML Schema has nothing to call them.
  • writer.WriteValue(ulong) is ambiguous, not missing — CS0121 — so it surfaced as a compiler
    error in generated code rather than as wrong output.
  • A null XmlQualifiedName member element does not get the q: prefix a non-null one does,
    because the prefix belongs to the path that writes a value. This was a live write-side bug found by
    a fixture added for the read side.
  • A root holding a derived instance was written through the declared contract's writer, dropping every
    derived member with no i:type and no error — a silent-data-loss bug on the write path, found by
    building the read side.

What the AOT smoke test found

src/CoreWCF.DataContractSerialization.AotSmokeTest publishes a real service with PublishAot and
calls it with a raw HttpClient over a hand-written envelope. System.ServiceModel is not usable as
the client — it does not support AOT either, so a failure there would say nothing about the service.
It is not part of dotnet test; its README says how to run it.

  ok   runtime is AOT - IsDynamicCodeSupported=false
  ok   switch is left at its default - unset, so the no-dynamic-code default applies
  ok   generated serializer resolves and round-trips - 436 bytes, read back identical
  ok   service answers over HTTP - 569 bytes back, contents match
  ok   reflection serializer does not silently truncate - throws NullReferenceException,
       so the generated path is load-bearing here

Three findings the unit tests could not have shown.

The serializer this replaces loses data silently, not loudly. Same contract, same binary. Before
the contract types were rooted with [DynamicDependency], DataContractSerializer did not throw — it
wrote 95 bytes where the generated one wrote 436: a graph missing most of its members, returned as
though nothing were wrong. That is the concrete argument for warning on a fallback.

CoreWCF needs contracts rooted for the trimmer. TypeLoader finds operations by reflecting over
the contract interface and nothing calls those methods statically, so without [DynamicDependency]
the interface arrives with zero operations and the host refuses to start. An annotation gap in
CoreWCF, not something an application should have to know.

A warning-free publish is far off: 339 trim/AOT warnings, essentially all from CoreWCF — 207
IL3050, 72 IL2026, 56 assorted reflection patterns, and 4 IL3054 for generic recursion aborted in the
message filter tables, which throws if reached. They come from the security stack, the channel proxy
and the dispatcher. A service runs under AOT for this shape; it does so without the guarantees a
clean publish would give.


Still to do

The rest of the seam. CreateSerializer is not a complete injection point. Even with a perfect
generator, CoreWCF still constructs a reflection-based serializer directly in
PrimitiveOperationFormatter (selected instead of the DataContract formatter for simple contracts,
so the simplest services never reach any of this), FaultContractInfo, DeserializeHeaderContents,
the Message/MessageFault/MessageHeader(s)/AddressHeader family, and the WS-Trust and SCT token
serializers.

CoreWCF's own trim annotations. The 339 warnings above, and the TypeLoader rooting gap.

Two behavioural differences from reflection, both recorded in the risk register:

  • an object member holding a collection throws where reflection succeeds — it cannot be a fallback,
    because by the time the runtime type is known the element is already open;
  • contracts with no parameterless constructor cannot be read.

Two things nothing verifies. Incremental caching — the whole spec design exists to keep it
working and there is no test; it fails by costing build time. And CoversKnownTypes is exercised by a
generator test but not through GeneratedDataContractSerializerOperationBehavior.


Open decisions

These want a maintainer's call, not more code.

  1. Public API names. AotXmlObjectSerializer, DataContractSerializerContext,
    DataContractSerializableAttribute become public API on first release. There is no public-API
    baseline in this repo, so nothing stops a rename now and nothing forgives one later. Aot in a
    type name may not age well — the property is "needs no dynamic code", which is also true under
    plain trimming.

  2. Where the user's context lives. Today it sits in the test project, so a generator bug cannot
    break the corpus build that the reflection oracle depends on. That trade reverses the moment an
    in-slice case needs private member access.

  3. Warning severity and noise. COREWCF_0403/0404 fire once per listed contract that falls
    back. On a large existing service that could be a lot of warnings on first adoption. Warning is
    the right default; whether to ship an opt-in escalation to error, or an opt-out, is a product call.

  4. Should this be CoreWCF-specific at all? The generator reads System.Runtime.Serialization
    attributes and mirrors System.Private.DataContractSerialization — nothing about the hard part is
    CoreWCF. Three type names couple it. Splitting those into a neutral package would make it usable
    by System.ServiceModel clients and by plain DataContractSerializer users. Out of scope here,
    but the API decision in (1) is easier to make with an answer to this.


Reviewing this

178 files, +21,300/−9. Most of it is corpus, fixtures and generated-code snapshots. A reading
order that makes it tractable:

  1. Documentation/DesignDocs/aot-datacontractserializer-risks.md — the design record: what the wire
    format actually does, every decision and why, the risk register, and what each upstream source
    settled. Written first and kept current; the best single entry point.
  2. src/CoreWCF.Primitives/src/CoreWCF/Runtime/Serialization/ and the PartInfo changes in
    DataContractSerializerOperationFormatter.cs — the seam, and the only changes to existing CoreWCF
    behaviour.
  3. …Generator/src/DataContractSerializerGenerator{,.Parser,.Emitter}.cs — the generator.
  4. …Generator/tests/Snapshots/ — the generated code itself, if you would rather read the
    output than the emitter.
  5. src/CoreWCF.DataContractSerialization/tests/ — the harness. GoldenRecordTests,
    ReadObjectTests and FixtureStore are how everything above is held to the recorded bytes.
  6. src/CoreWCF.DataContractSerialization.AotSmokeTest/ — the end-to-end proof.

The commits are ordered by capability and each states what it verified, so git log --reverse reads
as the development narrative if that is more useful than the diff.

g7ed6e and others added 30 commits August 8, 2026 13:33
…tSerializer

DataContractSerializer discovers types by reflection and is annotated
[RequiresDynamicCode] + [RequiresUnreferencedCode], so CoreWCF services cannot
publish cleanly with PublishAot or PublishTrimmed. The fix is a Roslyn generator
emitting reflection-free serializers, plugged in via the existing virtual
DataContractSerializerOperationBehavior.CreateSerializer seam.

This commit builds no generator code. It builds the oracle first: contract
instances whose exact serialized bytes are recorded from the real reflection-based
serializer, so the generator can later be diffed byte for byte against them.
A round-trip test would only prove self-consistency, not wire compatibility.

Adds three areas, following the src/<Area>/src + src/<Area>/tests layout of
CoreWCF.BuildTools:

  CoreWCF.DataContractSerialization           the package (netstandard2.0),
                                              empty, packs the generator as an
                                              analyzer under roslyn4.8/cs
  CoreWCF.DataContractSerialization.Generator the generator, empty
  CoreWCF.DataContractSerialization.TestCorpus contract corpus and instance
                                              catalog; pure BCL so it stays
                                              publishable ahead-of-time

Corpus: 10 CoreWCF-owned sanity types covering the capability surface, plus
dotnet/runtime's SerializationTestTypes/Primitives.cs pinned at bbfaee3b.
Primitives.cs is not self-contained; rather than import 244 KB of out-of-scope
files, the two symbols it references are extracted into _ImportSupport.cs with
per-symbol provenance. See SerializationTestTypes/UPSTREAM.md.

Fixtures are byte-exact: UTF-8, no BOM, no XML declaration, no indentation, one
line. Fixtures/.gitattributes marks them -text because the repository root sets
* text=auto, which would rewrite a raw newline inside a serialized string value
on Windows checkout - passing on Linux CI and failing on Windows.

Framework divergence is real and runs in both directions from the net8.0
baseline, so overrides are recorded only where bytes genuinely differ and are
pruned when they stop differing:

  AllTypes, AllTypes2   net472    double.Epsilon renders 4.94065645841247E-324
                                  on .NET Framework, 5E-324 on .NET Core 3.0+
  DateTimeOnlyWrapper   net10.0   .NET 10 made DateOnly/TimeOnly native
                                  serializer primitives (dotnet/runtime#119835)

Regeneration is opt-in via COREWCF_REGENERATE_DCS_FIXTURES, writes to the source
tree, refuses to run on a build agent, and always fails the run - a run that
authors baselines must never be green, or leaving the variable set in CI would
silently rewrite the oracle it is meant to check.

All three src projects set IncludeCommonCode=false: without a Resources/Strings.resx
resources.props omits SR generation, which the shared src/Common sources require.

Verified: 70 tests on net8.0/net9.0/net10.0 and 69 on net472, all green; 3
generator scaffold tests; full solution Release build clean; dotnet pack yields
exactly one package with the analyzer inside. Corrupting a fixture by hand fails
exactly one test and reverts clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…itance cases

Importing a corpus file was a manual sequence of download, header injection,
break-fixing and registration. import.ps1 collapses the mechanical part into one
command: it downloads at a pinned commit, verifies the MIT header (which also
catches a moved path silently returning a 404 page), injects the provenance
block, writes CRLF/UTF-8-no-BOM, and reports any local CoreWCF-marked edit an
overwrite would discard - leaving a .bak, since it cannot re-apply them. -WhatIf
does a dry run.

Uses it to import InheritanceCases.cs and InheritanceObjectRef.cs, taking the
corpus from 39 to 75 cases. The value is the interaction between IsReference and
inheritance, where the emitted XML is genuinely non-obvious:

  - IsReference inherits down a chain of [DataContract] declarations that do not
    restate it, so DerivedNoIsRef5 still emits z:Id on the root.
  - When only a nested member is reference-preserving, xmlns:z is redeclared on
    each element instead of once on the root. Prefix scoping differs from the
    root-is-IsReference case, which a generator would plausibly get wrong.
  - i:type appears only where the declared type differs from the runtime type.

InheritanceObjectRef.cs is imported wholesale: despite its name it contains no
IObjectReference or ISerializable implementation, only inheritance hierarchies of
plain data contracts. The earlier assessment deferring it was based on the
filename and was wrong. ObjRefSample.cs stays out - it declares SerIser, a real
ISerializable type - so SimpleDC and SimpleDCWithRef join the extracted
declarations in _ImportSupport.cs.

Two things the import surfaced:

  - DerivedWithIsRefTrue is an upstream negative test: IsReference = true under a
    base that leaves it false is an invalid contract and throws
    InvalidDataContractException rather than producing XML. Skipped with that
    reason; rejecting it is a generator diagnostic test, not a fixture.
  - Regeneration aborted on the first such case, leaving the fixture set half
    rewritten and hiding every other problem behind one stack trace. It now
    collects per-case failures and reports them all, so a bad import yields the
    full list of cases needing a fix or a Skip in one run.

Verified: 106 tests on net8.0/net9.0/net10.0 and 105 on net472, all green; full
solution Release build clean. No new framework divergence - the three existing
overrides are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… switch

Groundwork for the source generator. Adds the extension point generated
serializers will plug into, plus the switch that selects them - and nothing else.
With the switch unset, which is the default outside Native AOT, CoreWCF behaves
exactly as before: CreateAotSerializer returns null and the operation formatter
takes the reflection path it always took.

Why a new abstraction rather than XmlObjectSerializer: all sixteen of its members
carry [RequiresDynamicCode] and [RequiresUnreferencedCode], and IL2046/IL3051
require an override's annotations to match the member it overrides. Callers reach
it through the base-typed reference anyway, so a reflection-free subclass would
make serialization work under AOT without silencing a single warning - the
annotation lives in the abstraction, not the implementation. The remedy is the one
System.Text.Json used: leave the reflection API annotated and add a second,
attribute-free one that takes the compile-time contract as a parameter.
AotXmlObjectSerializer is that second API, narrowed to what the operation
formatter actually calls, and carrying no attributes of its own - so it needs no
polyfills on netstandard2.0, where those attributes do not exist.

Read support is opt-in via CanReadObject. The first generator slice emits
write-only serializers, and the two directions are independent, so CoreWCF keeps
deserializing with the reflection-based serializer until a generated one claims it
can read.

The switch mirrors OperationInvokerBehavior, which gates the other generator the
same way: explicit AppContext setting wins, otherwise on only where
RuntimeFeature.IsDynamicCodeSupported is false, because under Native AOT the
reflection path is the broken one. An ordinary application must not change
serializer implementation merely because it upgraded CoreWCF.

The decision is a pure function of its two inputs, separate from the AppContext
reads, so it can be tested without mutating process-global state.
IsDynamicCodeSupported is read into static readonly fields elsewhere in CoreWCF
(Dispatcher/InvokerUtil, Dispatcher/DispatchOperationRuntimeHelpers), so a test
that set it could silently change unrelated behaviour for the rest of a
concurrently-running suite.

Also records the design and risk register in
Documentation/DesignDocs/aot-datacontractserializer-risks.md, including the write
algorithm as it actually is - read from dotnet/runtime rather than inferred. Two
findings worth having up front:

  - Member ordering is Order ascending then string.CompareOrdinal on the contract
    name, and DataMemberAttribute.Order defaults to -1 with negatives rejected, so
    unordered members always precede ordered ones including Order = 0. The corpus
    alone could not distinguish -1 from 0.
  - Namespace prefixes are allocated by XmlDictionaryWriter, not by the
    serializer, which only declares namespaces explicitly at the root and per
    member. Emitting the same writer calls in the same order reproduces prefixes
    for free - removing most of the byte-exactness risk.

Verified inert: Primitives 153, Metadata 26, Http 391 and the serialization
harness 103, all green with the switch unset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the package's public surface and a working source generator pipeline. The
generator discovers contexts, resolves contract names and namespaces, and emits a
compiling partial - but every GetSerializer still returns null, so CoreWCF takes
the reflection path exactly as before. Emitting real serializer bodies is next.

Package surface:

  - DataContractSerializableAttribute, modelled on JsonSerializableAttribute:
    declaring a context is the opt-in, and the resulting list of types is rooted,
    which is what makes the output trimmable.
  - DataContractSerializerContext.GetSerializer is virtual returning null rather
    than abstract. The generator is gated by target framework, so on net472 it
    never runs and a user's partial context is never completed - an abstract
    member would leave that class uncompilable. Returning null means the same
    source compiles everywhere and contributes nothing where generation did not
    happen, with no conditional compilation in user code. Verified: the test
    project builds on net8.0/9.0/10.0 with generation, and on net472 without.
  - GeneratedDataContractSerializerOperationBehavior, overriding
    CreateAotSerializer to consult a context.
  - CoreWCF.DataContractSerialization.props/.targets, packed to
    build;buildTransitive, defaulting the generator on for .NET 8 and later. That
    gate is what lets emitted code use a modern language version rather than the
    oldest one any consumer might compile with.

Generator, following the CoreWCF.BuildTools layout (pipeline / Parser / Emitter /
specs) with one deliberate divergence: parsing happens inside the pipeline
transform and the specs hold only values, so Roslyn can actually cache the step.
BuildTools' generators build their specs from live symbols inside
RegisterSourceOutput, which re-runs on every keystroke. That is also why
diagnostics travel as DiagnosticInfo rather than Diagnostic - a Location roots a
whole Compilation - and why EquatableArray exists, since arrays compare by
reference and would defeat the cache on their own.

Member ordering is implemented from ClassDataContract.DataMemberComparer in
dotnet/runtime rather than inferred: Order ascending, then string.CompareOrdinal
on the contract name. DataMemberAttribute.Order defaults to -1 and rejects
negatives, so unordered members always precede ordered ones including Order = 0.

Diagnostics take the free COREWCF_04XX band. They fire only for input that is
definitely wrong - a context that cannot be generated into, or a type that is not
a data contract. A shape the generator merely does not support yet is left to the
reflection serializer, because falling back is a correct outcome rather than an
error.

Two traps worth recording:

  - The shipped .targets cannot simply be imported from a project body. NuGet
    injects it after the SDK targets, where TargetFrameworkIdentifier is
    populated; an import in the body is evaluated before that, silently sees an
    empty value and disables the generator. The test project mirrors the gate
    conditioned on TargetFramework instead, which is set that early.
  - EmitCompilerGeneratedFiles writes under the project directory, where the
    default glob compiles it as ordinary source - so the next build sees every
    generated member declared twice. The test project now excludes generated/**
    so inspecting output stays read-only.

Verified: serialization harness 103 on net8.0 and 102 on net472, generator tests
3, all four target frameworks building.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The generator now writes real serializers, and their output is byte-identical to
the reflection-based DataContractSerializer for the contracts in scope - verified
against fixtures the generator had no part in producing.

Three corpus cases match exactly on net8.0/9.0/10.0; the other 72 report a reason
and skip. That skip list is the coverage report, and it comes from the generator
itself: TryGetUnsupportedReason asks the generated context whether a serializer
exists, so it cannot drift from what was actually emitted.

In scope: primitives, DateTime, Guid, TimeSpan, byte[], strings and nullable value
types; member ordering; Name, Order, IsRequired and EmitDefaultValue; contract
Name and Namespace; i:nil. Out of scope for now - and detected in the parser, so
the contract simply gets no serializer and falls back: inheritance, non-public
members, and any member type not in the list above.

Reproducing the bytes came from reading dotnet/runtime rather than guessing, and
two findings did most of the work:

  - Namespace prefixes are allocated by XmlDictionaryWriter, not by the
    serializer. ReflectionWriteStartElement passes no prefix at all; a: and i:
    fall out of declaration order. The serializer declares namespaces explicitly
    in only two places, and the emitted code makes the same calls in the same
    order - so prefixes match for free rather than being reimplemented.
  - Primitive formatting is just writer.WriteValue, with three exceptions worth
    knowing: char is written as its numeric value, Guid and TimeSpan go through
    WriteRaw because XmlWriter has no WriteValue overload for them, and byte[] is
    base64. Those three are exactly what a from-scratch implementation would get
    wrong.

EmitDefaultValue = false combined with IsRequired throws rather than silently
omitting, matching ReflectionXmlFormatWriter.ReflectionWriteMembers.

Harness change: SerializerProvider.Capture is now virtual. The two producers reach
their serializer through different contracts - XmlObjectSerializer for the
reflection one, the attribute-free AotXmlObjectSerializer for the generated one -
but both use an identically configured writer, so the bytes stay comparable. The
generated provider deliberately does not set CanProduceFixtures: a serializer that
recorded its own expected output would make the corpus a tautology.

On net472 the generator does not run, GetSerializer returns null, and all 74 cases
report unsupported and skip. Verified explicitly rather than assumed - a run that
passes because nothing executed looks identical to one that passes because
everything matched.

Verified: 106 passing on net8.0/9.0/10.0 and 102 on net472, no failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ll checks

Adds 17 unit tests for the generator itself, covering what the golden-record
corpus cannot: diagnostics, the target framework gate, and the decisions that
leave a contract on the reflection path.

Drives CSharpGeneratorDriver directly rather than using
Microsoft.CodeAnalysis.Testing as CoreWCF.BuildTools does. That harness verifies
generated sources by exact text match, which for a serializer body means pinning
sixty lines of emitted code per test and rewriting them all whenever formatting
shifts. Here the emitted text is asserted where it carries meaning - member order,
wire names, the fallback comments - and the result compilation is checked for
errors, so invalid generated code still fails loudly. Failures print the compiler
diagnostics, without which "collection was not empty" says nothing about what
broke.

Writing them found two things worth keeping:

  - A context carrying no [DataContractSerializable] is never discovered at all,
    so nothing is emitted and the base GetSerializer keeps returning null. Three
    tests were originally written expecting a diagnostic there; the attribute is
    the opt-in, and its absence is not an error.
  - The build property name is spelled out in the harness rather than referencing
    the generator's constant. It is the name a consumer's build actually supplies,
    so a rename should break these tests rather than sail through them and break
    consumers instead.

Also removes a redundant null test from the emitted code: for a member with
EmitDefaultValue = false, reaching the write branch already proves the value is
not at its default, and for the nullable kinds "default" is exactly "null" - so
the inner check was dead code.

Verified the change is still inert with the switch unset: Primitives 153, Metadata
26, Http 391, the serialization harness 106 on net8.0 and 102 on net472, and the
full solution builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…two soundness bugs

Coverage was 3 of 75 not because the generator could only handle three cases, but
because the context listed only two types. It now lists every contract in the
corpus and lets the generator decide, which is the honest measurement: 17 of 75
byte-match, and the skip reasons are the generator's own.

Making that measurement immediately found two ways the generator produced output
that was wrong rather than absent - the failure mode the corpus exists to catch,
since both looked entirely plausible:

  - IsReference was never checked. SimpleDC is IsReference = true, so the real
    serializer emits z:Id for object identity; the generated one emitted a
    well-formed element without it.
  - A contract in another assembly may have data members the generator cannot
    see. BaseDCNoIsRef's only [DataMember] is a private field, and compiled from
    metadata rather than source it did not appear at all - so the generator
    emitted a serializer that silently dropped it. Their absence is
    indistinguishable from their not existing, so the only sound answer is to
    decline any contract declared outside the context's own assembly.

That second fix is a real constraint on users, not a test artifact: a context in a
different assembly from its contracts would otherwise serialize them incorrectly
and silently. It also forces the corpus context to move out of the test project
and in alongside the types, which milestone 1 anticipated - "the trade reverses
the moment an in-slice case needs private access". BaseDCNoIsRef is that case.

The cost of that move is the coupling it was placed to avoid: generated code that
fails to compile now breaks the corpus build and the reflection oracle with it.
That is a far smaller risk than it was, with 19 generator unit tests now proving
the emitter produces compiling code, and the fixtures already committed.

CorpusDoesNotReferenceCoreWcf becomes CorpusDoesNotReferenceTheHostingStack. The
corpus now references CoreWCF.DataContractSerialization deliberately; that package
is netstandard2.0 and reflection-free, so it does not compromise what the test
protects. Dragging in the hosting stack still would.

Where the remaining 58 sit, from the generator's own output: 24 blocked on a
member type it cannot write (6 object, 6 collections, 6 nested contracts, the rest
[Serializable] or DateOnly), 20 on inheritance, 12 on IsReference, 1 on a
non-public member. Note IsReference is inherited, so implementing inheritance will
need to walk the base chain for it rather than reading one attribute.

Verified: 120 passing on net8.0/9.0/10.0 and 102 on net472, 19 generator tests, no
failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corpus coverage goes from 17 of 75 to 26. Both capabilities fall out of one
restructure: each contract now emits a static content writer that writes only its
members, and a serializer class that wraps one in a root element.

Splitting them is what makes both work. A contract-typed member has no second
wrapping element on the wire, so it is written by calling the nested contract's
content writer inline. A derived contract writes its base's members first by
calling the base content writer, which also gets the ordering right: members are
sorted within their own contract rather than merged across the hierarchy, so a
base member named Zulu still precedes a derived member named Alpha.

Nested contracts are pulled in transitively, so a user lists the types they
serialize rather than the closure of everything those types reach. Only the
declared ones become GetSerializer entries. A container whose nested contract
cannot be written is itself declined, computed to a fixed point since the
dependency can be several deep.

Members whose type lives in a different contract namespace declare it on the
member element rather than at the root, which is what produces xmlns:b there.
Mirrors ClassDataContract.GetChildNamespaceToDeclare, including its exclusions:
built-in contracts, enums and IXmlSerializable declare nothing.

Three soundness bugs found by the corpus, all of which produced plausible but
wrong XML rather than falling back:

  - Overriding data members were written twice. SerializationTestTypes.Derived
    overrides A and B and both the base and the override carry [DataMember], so
    the base content writer and the derived one each emitted them. An override
    contributes no new element - only a different getter.
  - IsReference was read from one attribute, but it is inherited. A derived
    contract that says nothing still gets it from its base, and would have been
    emitted without z:Id.
  - A contract-typed member whose declared type admits derived instances needs an
    i:type attribute and the derived contract's members. Writing the declared
    type's members instead is wrong, which is what SanityKnownTypeHolder caught.
    Declined now on two independent signals: [KnownType] anywhere in the declared
    type's hierarchy, detected at compile time, and a non-empty knownTypes list
    passed to CreateAotSerializer, which is how CoreWCF supplies them from an
    operation contract where no attribute would reveal them.

Note the closed-world assumption this rests on: without a known type there is
nothing to be polymorphic with, because DataContractSerializer itself throws on an
unexpected runtime type rather than guessing. Declared and runtime type must
therefore agree, which is what makes writing a nested contract inline safe.

Generated output is now sorted by contract name so the emitted file is byte-stable
across builds; dictionary ordering is an implementation detail and generated
source that shuffles shows up as a spurious diff.

Verified: 129 passing on net8.0/9.0/10.0 and 102 on net472, 24 generator tests, no
failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corpus coverage 26 -> 27. Enums are written as their wire names, from a per-enum
value/name table driven by one shared copy of the algorithm rather than a switch
emitted per type.

Mirrors EnumDataContract.WriteEnumValue exactly, including two things a
from-scratch implementation would get wrong: an exact value match wins even for a
flags enum, so a combination that happens to equal a declared member writes that
member's name rather than decomposing; and a flags value that cannot be fully
decomposed is an error rather than a partial write. Members are kept in
declaration order because the decomposition consumes them in that order.

Wire names follow EnumDataContract.ImportDataMembers: when the enum itself carries
[DataContract] only fields with [EnumMember] participate and an explicitly set
Value replaces the field name; otherwise every public constant participates under
its own name.

Only +1 case, which is worth recording: the other enum-bearing contracts are
blocked on object members or IsReference, not on enums. IsReference is now the
single largest blocker at 26 contracts - up from 12, because inheritance support
made the inherited-IsReference detection correctly decline the whole
BaseWithIsRefTrue hierarchy rather than silently emitting it without z:Id.

Verified: 130 passing on net8.0 and 102 on net472, 24 generator tests.
Corpus coverage 27 -> 29 of 76. Arrays and lists of primitives are written as
per-item elements in the serialization Arrays namespace, which is declared on the
member element rather than at the root.

The item element names are XSD-derived and several are not what the CLR type name
suggests, so they were recorded from the real serializer rather than assumed. A
new SanityPrimitiveArrays corpus type carries one array per supported primitive
plus a List<int> and a null element, and its fixture is now the specification for
this table. Two things it corrected that a from-scratch implementation would very
likely get wrong:

  - sbyte is written as "byte" and byte as "unsignedByte" - the reverse of the
    obvious guess.
  - byte[] is not a collection at all. It is a primitive written as base64 in the
    containing contract's namespace, with no child namespace and no per-item
    elements. Treating it as an array of bytes would have produced a completely
    different document.

Also confirmed by that fixture: List<T> and T[] produce identical output, an empty
collection writes the member element with no children rather than nil, and a null
item inside a collection is written as an element carrying i:nil.

Nested collections, dictionaries, ArrayList and collections of contracts or enums
stay on the reflection path - each needs more than an item name to write.

Two generator tests had used List<int> as their example of an unsupported type,
which it no longer is; they now use Dictionary, and two new tests pin the item
naming and the byte[] distinction.

Verified: 133 passing on net8.0/9.0/10.0 and 103 on net472, 26 generator tests, no
failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corpus coverage 29 -> 49 of 76, the largest single jump of this milestone.
IsReference was blocking 28 contracts, more than every other cause combined.

The algorithm was read from XmlObjectSerializerWriteContext.OnHandleIsReference and
ObjectToIdCache before any code was written, and four of its details are ones a
from-scratch implementation is unlikely to guess:

  - Ids come from a per-call cache whose counter starts at 1, and only objects whose
    contract is IsReference consume one. That is why a reference-preserving member of
    a plain root is i1 rather than i2.
  - Lookup is by reference, never Equals. A contract that overrides equality still
    gets one id per instance, and two equal-but-distinct instances must not collapse
    into a single z:Ref.
  - A repeat sight writes z:Ref and nothing else. That, not a visited-set check, is
    what terminates a cycle - the return value means "already written".
  - The decision belongs to the element wrapping the object, so it is taken once at
    the root or on the member element, never once per level of a base chain.

The z prefix is not declared anywhere in the generated code. Writing an attribute in
the serialization namespace makes the writer declare it where it first comes into
scope, which reproduces both fixture shapes for free: xmlns:z on the root when the
root contract is IsReference, and on the member element when only a nested contract
is. The ordering that puts z:Id ahead of the xmlns declarations falls out the same
way, from writing it where OnHandleIsReference sits relative to HandleGraphAtTopLevel.

Two contracts that DataContractSerializer rejects are now declined explicitly rather
than served: one that contradicts its base about IsReference, and IsReference on a
value type. Both throw InvalidDataContractException on the reflection path, so
declining preserves that instead of accepting a contract the real serializer refuses.

Not closed: a cycle in a graph with no IsReference contract overflows the stack where
DataContractSerializer throws CannotSerializeObjectWithCycles past 512 levels. Only
reachable on graphs the real serializer also refuses, so no valid document is
affected, but the failure mode differs. Recorded in the risk register as 4a.

All 20 newly covered cases byte-matched the recorded fixtures on the first run, with
no fixture regenerated.

Verified: 153 passing on net8.0/9.0/10.0 and 103 on net472, 29 generator tests, no
failures, full solution builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corpus coverage 49 -> 58 of 76. A member declared as one contract but holding a
derived one now picks its writer by runtime type and announces the choice with
i:type, instead of leaving the whole contract to reflection.

Read from DataContract.ImportKnownTypeAttributes and
XmlObjectSerializerWriteContext.WriteTypeInfo first. Three details shaped the design:

  - The known-type closure is not a read of one type's attributes. The base chain is
    walked, so a derived contract inherits what its base declared, and the closure is
    transitive, so a known type's own [KnownType]s join it.
  - The attribute may sit on either end - the contract holding the member, or the
    member's own declared type. The serializer has both in scope as it descends.
    SanityKnownTypeHolder declares nothing and its member type declares everything,
    so reading only the holder generated a serializer that silently wrote the base
    contract's members for a derived instance.
  - i:type is written only when the runtime contract differs from the declared one,
    and is written by declaring the value's namespace first and then the qualified
    name into the attribute. Declaring first is what lets an already-bound prefix be
    reused, which is why i:type on a contract in the root's namespace reads
    "a:Derived" and adds no declaration of its own.

Branching is on exact type equality rather than a type pattern, so the emitted code
is order-independent: a pattern would let the base branch swallow a derived instance
depending on the order the candidates happen to be in. A runtime type outside the set
throws, as the reflection path does - falling back is not available once the element
is open.

CoreWCF also supplies known types from the operation description, which no attribute
reveals to the generator. The behavior used to decline whenever any were present,
which is what kept the TestInheritance family on the reflection path even once their
contracts were writable. It now asks the context whether the serializer already
resolves them, answered from the closure rolled up across the whole reachable graph.
Subset, not equality: an operation supplying fewer types than the attributes declare
is still covered.

Three shapes are declined rather than served, each because the real serializer would
reject or resolve something this cannot see: [KnownType] naming a method, which only
exists at run time; a member declared as an abstract contract with nothing to resolve
it to; and the service-level known-type attributes.

All 9 newly covered cases byte-matched the recorded fixtures, no fixture regenerated.

Verified: 162 passing on net8.0/9.0/10.0 and 103 on net472, 34 generator tests, no
failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…overed

Corpus coverage 58 -> 61 of 77. A member declared as object now writes its runtime
type as i:type, resolved against the boxed primitives plus whatever [KnownType]
names.

As with the collection item names, the type table was recorded from the real
serializer rather than assumed. A new SanityBoxedPrimitives corpus type carries one
object member per primitive plus the three shapes that are not a primitive at all,
and its fixture is the specification. What it settles:

  - Most xsi type names come from XML Schema, but char, Guid and TimeSpan are named
    in the serialization namespace because XSD has no equivalent for them. Nothing
    but a byte-exact comparison would surface that split.
  - The sbyte/byte swap is the same as for collection items: sbyte is "byte" and byte
    is "unsignedByte".
  - A bare object is anyType and writes an empty element with no i:type at all; null
    is i:nil with no i:type; a data contract writes i:type and its members.

Registering the two new Sanity types exposed two problems that had nothing to do with
object members and everything to do with trusting a green suite.

SanityPrimitiveArrays was added with the collections work and its fixture recorded,
but the type was never listed on GeneratedCorpusContext. GetSerializer returned null,
the generated half of the harness skipped it, and the skip reason read exactly like a
legitimately unsupported case. The collections work looked verified and was not. A new
integrity test now asserts that every catalogued case's contract type is listed in the
context, so this cannot recur silently.

Registering it then broke the build in a way worth recording: csc exited 1 with no
diagnostic at all, and the same compilation succeeded under
-p:EmitCompilerGeneratedFiles=true, which made it look like a compiler crash. Dumping
the generated file and compiling it as an ordinary source gave the real error: CS0121
on writer.WriteValue(item) for a ulong. XmlWriter has no WriteValue(ulong) - ulong
converts implicitly to float, double and decimal and to none of them better than the
others, so the call is ambiguous rather than missing. Upstream avoids it in
XmlWriterDelegator.WriteUnsignedLong by going through WriteRaw(XmlConvert.ToString),
a fourth WriteRaw case alongside Guid and TimeSpan, and so does the generator now.

Not closed: DataContractSerializer allows arrays and collections in an object member
without them being declared, and the generated switch has no branch for those, so it
throws where reflection succeeds. It cannot fall back - the member element is already
open by the time the runtime type is known. Recorded in the risk register as 4b, along
with 1a for the two verification lessons above.

Verified: 167 passing on net8.0/9.0/10.0 and 105 on net472, 37 generator tests, no
failures, full solution builds clean. No existing fixture was regenerated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corpus coverage 61 -> 65 of 77, closing the TestInheritance cases whose members are
[Serializable] rather than [DataContract].

Read from the else branch of hasDataContract in ClassDataContract.ImportDataMembers.
Three rules, none of which follow from the attribute's name:

  - [DataContract] wins when a type carries both. BaseSerializable in the corpus is
    exactly that shape, and reading its fields instead of its [DataMember]s would
    have written three members where the wire has three different ones.
  - Every instance field takes part, public and non-public, except [NonSerialized].
    Properties never do - not even ones that look like data.
  - Fields sort by the same comparer as data members, but with Order 0 rather than
    the -1 an unspecified [DataMember] gets. Within one contract they are all equal
    either way, so the sort reduces to ordinal by name.

This is not the implicit no-attribute contract that v1 excludes: [Serializable] is an
explicit opt-in with a defined member set, where a bare POCO is inferred.

Two restrictions on top of upstream, both to avoid claiming more than can be
delivered. Only types declared in source are treated this way - [Serializable] is
everywhere in the framework, and Uri, ArrayList and Dictionary carry it while having
wire formats that are nothing like their field layout, so a metadata type keeps
whatever answer it had before. And a type implementing ISerializable is declined
outright: that takes over serialization entirely, so its fields are not what would go
on the wire. Non-public fields still decline for the reason they always did, since
generated code cannot reach another type's privates however close by they compile.

All 4 newly covered cases byte-matched the recorded fixtures, no fixture regenerated,
and no previously-supported contract changed its classification - ArrayList and
Dictionary still decline by name rather than being mistaken for contracts.

Verified: 171 passing on net8.0/9.0/10.0 and 105 on net472, 40 generator tests, no
failures, full solution builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two capabilities off the AllTypes list. Coverage stays at 65 of 77: AllTypes and
AllTypes2 need six more before either can pass, so this moves the blocker rather than
the count.

An enum in an object member is announced with i:type like any other contract and then
written from its own value/name table. An enum item in a collection is the more
surprising of the two: unlike every built-in type, it is named after its own contract
and stays in its own namespace rather than going into the Arrays namespace, which is
why AllTypes.enumArrayData writes <a:MyEnum1> beside its containing contract with no
xmlns declaration on the member element at all.

That required the item namespace to become per-member rather than a constant, and the
enum write to be shared between the member, collection-item and boxed-candidate paths.

Correcting the record: I previously reported AllTypes as blocked on one remaining
capability. It is blocked on eight. ParseContract records only the first reason a
contract is declined and stops describing it, so a wide contract reads like a single
blocker when it is one of many. The full list is now in the risk register as 4c -
XmlQualifiedName, Uri, System.ValueType and System.Enum declared members,
System.Array declared members, and DateTimeOffset - along with the observation that a
report naming one cause per contract will understate the work whenever the contract is
wide.

Verified: 171 passing on net8.0/9.0/10.0 and 105 on net472, 41 generator tests, no
failures. One generator test had an obsolete premise - an enum known type on an object
member was the example of something unsupported - and now pins the behaviour instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corpus coverage 65 of 77 -> 66 of 78. Two more capabilities off the AllTypes list,
and the two on it that turn up in ordinary service contracts rather than only in a
deliberately pathological test type.

Neither is what it looks like on the wire, so a new SanityUriAndOffset corpus type
carries both in every position - bare, null, nullable, and in a collection - and its
fixture is the specification:

  - A Uri is written from GetComponents(SerializationInfoString, UriEscaped), not
    ToString. That normalises it: "http://corewcf.example" goes out with a trailing
    slash.
  - A DateTimeOffset is not a value at all. DataContractSerializer swaps in
    DateTimeOffsetAdapter, a contract with a DateTime and an OffsetMinutes member
    living in http://schemas.datacontract.org/2004/07/System - a namespace neither
    type mentions. The DateTime written is the UTC one, so the offset is recorded
    once rather than baked into both.
  - In a collection the two diverge. A Uri item is an XSD name in the Arrays
    namespace like any other built-in; a DateTimeOffset item is named after its
    contract and stays in the System namespace, the same rule enums follow.
  - A nil nullable DateTimeOffset still declares the System namespace on the member
    element, because the child namespace is written before the value is inspected.

The boxed name came from the oracle too: adding a Uri to SanityBoxedPrimitives had the
generated path throw "Type 'System.Uri' cannot be written into an object member",
which is the intended failure - a clear exception rather than plausible wrong XML -
and the regenerated fixture then supplied "anyURI" in the XML Schema namespace.

AllTypes and AllTypes2 remain blocked. Four of their eight capabilities are now done;
the four left - XmlQualifiedName's own element prefix, System.ValueType and
System.Enum declared members, and System.Array declared members - are shapes that
appear nowhere else in the corpus. The table in the risk register tracks which is
which.

Verified: 173 passing on net8.0/9.0/10.0 and 106 on net472, 43 generator tests, no
failures, full solution builds clean. No existing fixture changed except
SanityBoxedPrimitives, which gained the new member.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… base

Corpus coverage 66 -> 68 of 78. AllTypes and AllTypes2 now byte-match, closing the
last four of the eight capabilities they needed.

Members declared as System.ValueType, System.Enum or System.Array are the same shape
as an object member - a switch on the runtime type, announced with i:type - over a
narrower candidate set. The narrowing is not cosmetic: casting a ValueType to string
is a compile error, so an unfiltered table would emit generated code that does not
build. The parser filters the known types the same way, since an Enum member admits
only enums and a ValueType member only value types.

XmlQualifiedName is the one member type whose element carries a prefix of its own
instead of reusing what the writer has bound, which leaves a second prefix on the
contract's namespace beside the one already in scope. It looks redundant and it is
what the serializer does - see NeedsPrefix in ReflectionXmlFormatWriter, which forces
Globals.ElementPrefix for this type alone and only when the namespace is non-empty.
The value follows XmlWriterDelegator.WriteQName: the empty name writes nothing at all
rather than an empty string.

A System.Array member is the narrowest of the four by design. It writes object[] as a
sequence of anyType items, and the visible difference from a typed collection is where
the namespace is declared: the member element declares nothing, so each item binds the
Arrays namespace as a default xmlns of its own rather than carrying a prefix. The item
writer covers the primitives, a bare object and null, and throws otherwise, because a
contract or enum inside an untyped array would need the containing contract's known
types and an item writer shared across the whole context does not have them.

Verified: 175 passing on net8.0/9.0/10.0 and 106 on net472, 45 generator tests, no
failures, full solution builds clean. No fixture was regenerated - all four landed
byte-exact against the recorded output on the first run.

Ten cases still skip: Dictionary and ArrayList, a jagged array, DateOnly, three
contracts whose [KnownType] names a method, one with a non-public data member, and one
with no [DataContract] at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…stead of overflowing

Two gaps the risk register had open, neither of which moves coverage - both are about
the generated path failing honestly.

ParseContract recorded the first reason a contract could not be written and stopped,
so a wide contract read like a single blocker when it was one of many. That is not a
cosmetic problem: it is how AllTypes was reported as one capability away when it
needed eight. Reasons are now collected into a list and the emitted report prints one
line per reason. DateTimeOnlyWrapper went from one line to four the moment it was
turned on.

A cycle in a graph with no IsReference contract used to recurse until the stack ran
out. DataContractSerializer counts nesting depth and, past 512 levels, checks whether
the object is already on the path and throws CannotSerializeObjectWithCycles - see
XmlObjectSerializerWriteContext.OnHandleReference. The per-call scope that already
tracks z:Id now carries the same counter, and every by-value contract write is wrapped
in EnterByValue/ExitByValue. Reference-preserving contracts are deliberately left
unguarded: the second sight of an instance is a z:Ref with no content, so they cannot
recurse forever.

The golden-record corpus cannot cover this, because the real serializer refuses such a
graph and there is no output to record. CyclicGraphTests covers it directly instead:
both paths must throw SerializationException for the same cyclic graph, and a 600-deep
chain must still come out byte-identical - which is what stops the guard mistaking
depth for a cycle. Both assertions matter; the second is the one that would have
caught an off-by-one in the depth bookkeeping.

One difference from upstream worth knowing: the 512 counts nested contracts here and
XML writer depth there, so the exact level at which the throw happens can differ. Not
observable in a valid document, where neither throws at all.

Verified: 178 passing on net8.0/9.0/10.0 and 107 on net472, 48 generator tests, no
failures, full solution builds clean. No fixture changed - coverage stays at 68 of 78.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corpus coverage 68 -> 71 of 78, closing SanityCollections, DictContainer and
ArrayContainer.

A dictionary entry is named after both type arguments: KeyValueOf followed by the XSD
name of each, so Dictionary<string, string> writes KeyValueOfstringstring and
Dictionary<byte[], byte[]> writes KeyValueOfbase64Binarybase64Binary. Both are pinned
by existing fixtures. Every element involved - the entry, its Key and its Value - sits
in the Arrays namespace, declared once on the member.

ArrayList holds anything, so each item is an anyType element announcing its own
runtime type - the same shape as an object member, once per item. The anyType writer
added for System.Array members already did exactly this, so it is reused rather than
duplicated. The two differ in one visible way, and the fixtures show why: an ArrayList
member declares the Arrays namespace on the member element, so its items carry a
prefix, where a System.Array member declares nothing and each item binds a default
xmlns of its own.

Only built-in key and value types are supported. A contract argument would contribute
its own contract name to the entry name and, when itself generic, a hash - neither
worth guessing at, and both would produce a plausible wrong element name rather than a
fallback.

The XSD name table is now one function serving three callers - collection item names,
i:type local names, and half a dictionary entry name - rather than a switch inlined in
the collection path.

Three generator tests had used ArrayList or Dictionary<string, string> as their
example of an unsupported type, which neither is any more; they now use
Dictionary<string, Order>, whose value is a contract.

Verified: 181 passing on net8.0/9.0/10.0 and 107 on net472, 51 generator tests, no
failures, full solution builds clean. No fixture regenerated - all three cases matched
the recorded bytes on the first run.

Seven cases still skip: a jagged array, DateOnly/TimeOnly, three contracts whose
[KnownType] names a method, one with a non-public data member, and one with no
[DataContract] at all. Only the first two are capabilities rather than deliberate v1
exclusions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corpus coverage 71 -> 72 of 78, closing Array3.

Each outer item of a jagged collection is an array in its own right, written as an
element named ArrayOf plus the XSD name of the innermost type - ArrayOfint for
int[][] - holding the items themselves in the same Arrays namespace. A null outer
array is i:nil like any other missing reference; an empty one is an empty element,
which is what the Array3 fixture pins.

byte[][] is deliberately not this shape and stays as it was. CollectionElementTypeOf
declines byte[], because the serializer treats it as a primitive written as base64
rather than as an array of bytes, so a byte[][] remains a flat collection of
base64Binary items with no ArrayOf wrapper. The generator test asserts both halves of
that distinction, since getting it wrong would silently restructure a document.

Only one level of nesting is supported: the innermost type must be a built-in. Deeper
jagging, or an inner array of contracts, still declines.

One generator test had used int[][] as its example of an unsupported type, which it no
longer is; it now uses List<Order>, whose element is a contract.

Verified: 182 passing on net8.0/9.0/10.0 and 107 on net472, 52 generator tests, no
failures, full solution builds clean. No fixture regenerated.

Six cases still skip, and only one of them is a capability: DateOnly/TimeOnly. The
rest are deliberate v1 exclusions or things a generator cannot reach - three contracts
whose [KnownType] names a method resolved at run time, one with a non-public data
member, and one with no [DataContract] at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corpus coverage 72 of 78 -> 74 of 80. Every case that still skips is now a deliberate
v1 exclusion rather than missing work, so WriteObject is feature-complete for the
corpus.

These two are unlike everything else in the generator: their wire format is a property
of the runtime, not of the contract.

  - Up to .NET 9 DataContractSerializer does not recognise them and writes a contract
    with no members - an empty element that drops the value entirely, with the System
    namespace declared on it.
  - .NET 10 writes them as primitives, with no namespace declaration at all.

That it is runtime-determined and not target-framework-determined was verified rather
than assumed: running the net8.0 test assembly under DOTNET_ROLL_FORWARD=LatestMajor
makes the reflection serializer produce the .NET 10 format. So the generated code
tests Environment.Version.Major at run time instead of the generator deciding at
compile time.

The distinction has teeth. With a compile-time decision a net8.0 assembly rolled
forward onto .NET 10 would have emitted the old format while reflection emitted the
new one, and the harness would not have caught it - the generated provider would have
gone on matching its own stale baseline. With the runtime test, that same roll-forward
run fails both providers on exactly the same two cases, which is the fixtures being
keyed to the compile-time framework and not a defect.

The formats come from XmlWriterDelegator: "yyyy-MM-dd" for a date and
"HH:mm:ss.FFFFFFF" for a time, the optional fractional digits being what omits
trailing zeros and the dot rather than padding them.

The upstream DateTimeOnlyWrapper only ever holds default values, so it cannot tell a
dropped value from a zero one. A new SanityDateAndTimeOnly carries real ones, which is
what makes the pre-.NET 10 data loss visible: a DateOnly of 2020-01-02 records as
<a:Date/>. Its fixture is recorded on both net8.0 and net10.0.

Verified: 185 passing on net8.0/9.0/10.0 and 107 on net472, 53 generator tests, no
failures, full solution builds clean. The net8.0 and net10.0 runs exercise the two
branches of the runtime test against their own recorded bytes.

Six cases still skip: three whose [KnownType] names a method resolved at run time, one
with a non-public data member, one with no [DataContract] at all, and SeasonsEnumContainer
which is the same. None is a capability.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ReadObject is the next milestone, not an increment, and the write side succeeded
because the oracle came first. This is that oracle, with no generated reader behind it
yet.

The write side had an obvious reference: the exact bytes the real serializer produced.
The read side has no equivalent. Comparing two object graphs would mean an equality
rule for all eighty corpus types, and a wrong rule reports success exactly as readily
as a right one.

So the fixture is the oracle here too, used the other way round: read it with the
serializer under test, write the result back out with the reflection serializer, and
compare those bytes to the fixture. That composition only reproduces the fixture if the
graph that came out of the read is the graph that went into the recording, and it
reuses a byte comparison that is already trusted rather than inventing a second notion
of correctness.

Deliberately not a round trip through the generated writer. A mistake shared by the
generated reader and the generated writer - a member skipped by both, a name misspelled
the same way twice - would cancel out and pass. Writing back through reflection
measures the read against the real implementation instead.

The reflection-reads-its-own-fixture theory is not a tautology either: it establishes
that every fixture is round-trippable at all, so a later failure on the generated side
is a defect in the generator rather than a case whose value the real serializer never
recorded. All 79 pass today, including DateTimeOnlyWrapper, whose value the pre-.NET 10
serializer drops on both sides of the trip.

Every generated case skips for now, because CanReadObject is still false everywhere.
That is the honest starting position: the measurement exists before the thing it
measures.

Verified: 264 passing on net8.0 and 184 on net472, no failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first ReadObject slice. 14 of the 79 corpus cases now read back through generated
code and reproduce their fixture; the rest keep CanReadObject false and fall back.

The element walk mirrors ReflectionXmlFormatReader.ReflectionReadMembers, which scans
forward only: a member matches an element only if it comes after the last one matched,
and an element matching nothing is skipped rather than being an error. That is what
makes an absent member keep its default and an unknown element harmless instead of
fatal.

Value parsing is the inverse of WriteValueStatement and has to stay that way. Two
places where it is not symmetrical by accident: char went out as its numeric value so
it comes back through an int, and DateTime needs RoundtripKind or the Utc flag the
writer recorded is silently dropped on the way in.

Readable is deliberately narrower than writable - flat contracts of built-in members,
nothing else. Inheritance, nested contracts, collections, polymorphism and IsReference
each need the read counterpart of machinery the write side already has, and every one
of them is a way to quietly produce a graph that is not the one recorded. A contract
outside the slice never claims to read, which is GetSerializer returning null one level
down.

Two constraints the reflection implementation does not have: a contract needs an
accessible parameterless constructor, because generated code cannot allocate without
running one, and each member has to be settable.

Struct contracts caught a real bug on the first run. VT and PublicDCStruct read the
whole document and then discarded it member by member, failing nowhere, because the
content reader took the contract by value. Their readers now take it by ref. The oracle
is what surfaced this: a round trip through the generated writer would have compared
one empty struct against another and passed.

Verified: 278 passing on net8.0/9.0/10.0 and 184 on net472, 53 generator tests, no
failures, full solution builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The second ReadObject slice. 20 of the 79 corpus cases now read back through generated
code and reproduce their fixture, up from 14.

A nested contract member is the mirror of how it is written: the wire format has no
second wrapping element, so the member element is the nested contract's element. Open
it, hand the reader to that contract's content reader, close it.

A collection accumulates into a List before assigning, and turns it into an array only
where the member is one. An empty collection and a null one are different documents and
stay different: an empty element yields an empty collection, and only i:nil yields
null. Getting that backwards is invisible until something round-trips, which is the
kind of mistake this harness exists to catch.

Readability is now a graph question rather than a per-member one, so it is computed
with memoisation. A contract that reaches itself - a linked list being the ordinary
case - is assumed readable while its own answer is being computed, which is the right
answer because the recursion terminates at run time on a nil or empty element rather
than statically.

Polymorphic members stay unreadable on purpose. Resolving an i:type back to a type is a
different problem from announcing one, and a member that may hold more than its
declared contract would otherwise read as its declared type and silently lose the
derived members.

Verified: 284 passing on net8.0/9.0/10.0 and 184 on net472, 53 generator tests, no
failures, full solution builds clean.

Still unreadable: inheritance, IsReference, enums, dictionaries, boxed and polymorphic
members, and the format-specific kinds - DateTimeOffset, QName, DateOnly, TimeOnly.
IsReference is the one that needs more than an inverse: a z:Ref can point at an object
the reader has not reached yet, so it needs a fixup pass rather than a straight parse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The third ReadObject slice. 29 of the 79 corpus cases now read back through generated
code and reproduce their fixture, up from 20.

Reading a base chain is not the writer run backwards. The writer recurses, one call per
level, because writing is unconditional. A reader cannot: it is a single loop over a
single monotonically advancing index, and a per-level loop would let the base's own loop
reach a derived member, fail to recognise it and skip it in silence - the members would
vanish and the read would report success. Upstream draws the same distinction, so the
generator flattens the chain base-first the way ReflectionGetMembers does.

An inherited member keeps the namespace of the contract that declares it rather than the
derived contract's. That is not an inference from the fixtures: ClassDataContract builds
MemberNamespaces by Array.Copy from the base before appending its own entries.

A contract that names a descendant in its [KnownType] closure stays unreadable. A member
typed as it declines already through its Candidates, but the root of a document has no
member to carry that, and reading a derived instance through the base's reader would drop
every member the derived contract adds. Merely having a descendant is not enough to
decline: one the contract never names is one the reflection reader would refuse outright,
so declining for it would cost coverage and buy no safety.

The generator's own tests no longer target net472. They compile the emitted code, and the
emitter is entitled to DateOnly, TimeOnly and ReferenceEqualityComparer because
CoreWCF.DataContractSerialization.targets gates the generator off below net8.0 - so
compiling its output against net472 reference assemblies was testing a combination that
cannot occur, and 44 of the 53 failed there. That the generator contributes nothing on
net472 is verified where it is observable: CoreWCF.DataContractSerialization.Tests still
runs there and reports every case unsupported.

Verified: 293 passing on net8.0/9.0/10.0 and 184 on net472, 55 generator tests, no
failures, full solution builds clean.

Still unreadable: IsReference, enums, dictionaries, boxed and polymorphic members, and
the format-specific kinds - DateTimeOffset, QName, DateOnly, TimeOnly. IsReference is the
one that needs more than an inverse: a z:Ref can point at an object the reader has not
reached yet, so it needs a fixup pass rather than a straight parse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fourth ReadObject slice. 37 of the 81 corpus cases now read back through generated
code and reproduce their fixture, up from 29 of 79.

An enum comes back through its own name table rather than Enum.Parse, which would accept
names the contract never declared and numbers it never wrote. Mirroring
EnumDataContract.ReadEnumValue: a flags enum is a space-separated list and an empty one is
legal, since that is how a zero value is written when no member names it, while for a
non-flags enum the empty string is an error rather than zero. A name has to match in full
- comparing only the first count characters would accept a truncated name and return the
wrong member - and an unrecognised one always throws.

A dictionary entry is an ordinary two-member contract on the wire, so it is matched the
way every other one is: forward only, each part at most once, against the entry and part
names the writer already proved against a fixture.

Two corpus cases were added rather than trusting the code. One populated
Dictionary<string, string> reaches none of the branches that matter, so SanityDictionaries
pins an empty map, a missing one, an entry whose Value carries i:nil, a base64 value and a
non-string key in one document; SanityEnumCollections pins a flags enum inside a
collection, which writes "Beta Gamma" and has to come back as both members. A branch no
fixture exercises is a branch that is not verified, however carefully it was written.

Also closes a gap that predates this slice: a byte[] member declined as unreadable even
though the reader has handled base64 all along, because the readability test asked for a
text expression and base64 has none. That branch was dead code. SanityPrimitives and
SanityPrimitiveArrays read as a result.

Verified: 307 passing on net8.0/9.0/10.0 and 188 on net472, 57 generator tests, no
failures, full solution builds clean. On the write side 76 of 81 cases byte-match and the
five skips are the same deliberate exclusions as before.

Still unreadable: IsReference, boxed and polymorphic members, and DateTimeOffset, QName,
DateOnly and TimeOnly. IsReference remains the one that needs more than an inverse: a
z:Ref can point at an object the reader has not reached yet, so it needs a fixup pass
rather than a straight parse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fifth ReadObject slice. 39 of the 82 corpus cases now read back through generated code
and reproduce their fixture, up from 37 of 81.

Neither of these is read from text. A DateTimeOffset is a two-member contract rather than
a value, and its inverse follows DateTimeOffsetAdapter.GetDateTimeOffset: an Unspecified
DateTime is paired with the offset, anything else is converted to it. The writer recorded
UtcDateTime and the offset separately rather than a local time, so treating both cases
alike would shift every value that carries an offset by that offset.

A QName has to resolve a prefix against an element that is still open. The prefix is
declared on the member element and nowhere else, so reading the text and the end tag in
one call - ReadElementContentAsString - pops the scope that defines it and leaves nothing
to resolve against. XmlReaderDelegator.ReadElementContentAsQName splits the read into
start, content and end for exactly that reason, and the generated reader splits it the
same way. The empty name is a shape of its own again: the writer emits no content for it,
so it comes back as an empty element rather than an empty string that parses the same way.

SanityQualifiedNames was added because the only QName in the corpus lives in AllTypes,
which is unreadable for unrelated reasons - and it immediately failed on the write side,
which is the useful kind of failure. A non-null XmlQualifiedName member element carries a
prefix of its own, as NeedsPrefix in ReflectionXmlFormatWriter says, but a null one does
not: the prefix belongs to the path that writes a value, and a null member is written by
WriteNull, which opens the element with whatever prefix is already bound. The generator
had been applying it unconditionally, and AllTypes never caught it because its QName is
non-null. The two halves of the rule are now pinned by two different fixtures.

The three read paths that differed only in how one element yields one value - a member, a
collection item, half a dictionary entry - are now one method, since this slice would have
added a fourth and fifth copy. That also fixes a latent bug in the collection path, which
built its assignment as a conditional expression: in x = c ? default : e the conditional
takes its type from e, so a nil element in a List<int?> would have come back as 0 rather
than null.

Verified: 312 passing on net8.0/9.0/10.0 and 190 on net472, 60 generator tests, no
failures, full solution builds clean. On the write side 77 of 82 cases byte-match and the
five skips are the same deliberate exclusions as before.

Still unreadable: IsReference, boxed and polymorphic members, and DateOnly/TimeOnly.
IsReference remains the one that needs more than an inverse: a z:Ref can point at an
object the reader has not reached yet, so it needs a fixup pass rather than a straight
parse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sixth ReadObject slice. 41 of the 82 corpus cases now read back through generated code
and reproduce their fixture, up from 39. What remains unreadable is object identity and
polymorphism, and nothing else.

These are the one format decided by the runtime rather than by the contract, so the reader
carries the same Environment.Version.Major >= 10 test the writer does. Before .NET 10 the
serializer does not know what these types are and writes a contract with no members - an
empty element that drops the value. Reading default there is not a fallback: it is what the
recorded document says, and it is what the reflection-based reader produces from the same
bytes, which is what keeps the round-trip exact.

The two parses are not symmetric, and following upstream closely matters. DateOnly goes
through ParseExact with AllowLeadingWhite | AllowTrailingWhite. TimeOnly does not:
ReadElementContentAsTimeOnly goes through XmlConvert.ToDateTimeOffset and takes TimeOfDay
from the result. XmlReaderDelegator also defines a ParseTimeOnly that mirrors the DateOnly
one and never calls it - copying that helper instead of the code actually on the path would
have narrowed what this reader accepts, rejecting a Z or an offset the real one takes.

Because the branch is a run-time test rather than a compile-time one, one test verifies
both halves: SanityDateAndTimeOnly round-trips on net8.0 and net9.0 against the lost-value
fixture and on net10.0 against the primitive one.

Verified: 314 passing on net8.0/9.0/10.0 and 190 on net472, 61 generator tests, no
failures, full solution builds clean.

Still unreadable: IsReference, and boxed and polymorphic members. Both need machinery
rather than an inverse - resolving an i:type back to a type without reflection, and a fixup
pass for a z:Ref that points at an object the reader has not reached yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the root

The seventh ReadObject slice, and it turned up a write-side bug. 45 of the 85 corpus cases
now read back through generated code and reproduce their fixture, up from 41 of 82.

Announcing an i:type is a switch on the runtime type. Resolving one is a lookup by name,
and the candidate set is the compile-time known-types closure - so what the serializer does
through reflection is a chain of string comparisons here. Following
XmlObjectSerializerReadContext: no i:type means the declared contract, so the absent name
and the declared name share one branch; and a name that resolves to nothing throws rather
than falling back, because falling back would produce an instance missing every member the
derived contract adds and report success. The attribute is read while the reader still sits
on the element, since the prefix it uses is declared there - the same constraint a QName
has.

A root has no member to carry that decision, so it makes it itself. Adding the read side
exposed that the write side never had one: WriteObject cast the graph to the declared
contract, so a service returning a derived instance through a base-typed contract wrote
only the base members, with no i:type and no error. That is the silent-data-loss failure
this project exists to prevent, and it was on the write path independently of any of the
read work.

Three corpus cases pin it, all byte-exact on the first run. SanityBase.derived-instance
records what a root actually emits - i:type ahead of the namespace declarations, reusing
the prefix already bound to the contract namespace rather than declaring a second one,
which is where a root differs from a member element. SanityPolymorphic covers the member
side with all four shapes at once, because a single derived instance cannot tell "resolved
the name" from "took the only branch": a declared instance carrying no i:type, two
different derived types carrying different ones, and a null carrying none.

The readability rule changes shape as a result. A contract that names a descendant used to
decline outright; now it reads, provided every descendant it names is itself readable.

Boxed members stay write-only. Their candidates are not all contracts - a primitive in an
object member is announced by an XSD name with no contract behind it - so resolving one
needs a second table keyed by those names, the inverse of the BoxedPrimitives table the
writer uses.

Verified: 327 passing on net8.0/9.0/10.0 and 196 on net472, 62 generator tests, no
failures, full solution builds clean. On the write side 80 of 85 cases byte-match and the
five skips are the same deliberate exclusions as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
g7ed6e and others added 8 commits August 8, 2026 23:35
The eighth ReadObject slice. 49 of the 85 corpus cases now read back through generated code
and reproduce their fixture, up from 45 - including AllTypes and AllTypes2, the two widest
contracts in the corpus.

A polymorphic member's candidates are all contracts, so resolving a name means finding a
contract. A boxed member's may be a primitive announced by an XSD name with no contract
behind it, so it needs the inverse of the writer's BoxedPrimitives table. Three things that
table settles and the inverse has to preserve: "byte" is sbyte while "unsignedByte" is byte,
the pair most likely to be quietly corrected into agreeing with the CLR names; char, Guid
and TimeSpan are named in the serialization namespace because XML Schema has nothing to call
them; and a bare object carries neither i:type nor content, so there is nothing to recover
but the fact that something was there.

What the declared type buys is a check. A member declared ValueType cannot hold a string, so
an i:type naming one is refused with a SerializationException rather than surfacing later as
an InvalidCastException out of generated code. Enum and Array members admit no primitive at
all, so an unmatched name there never reaches the XSD table.

Array is the one shape that is not a value: the only array the writer emits is object[], and
it announces no i:type - each item carries its own. The untyped item reader that serves it is
the inverse of WriteAnyType, and it is the same one a collection of anyType items uses.

Two gaps remain that are neither boxed nor object identity, and both are collection
containers rather than values. A jagged int[][] reads its items through the ordinary
collection path, which has no case for an item that is itself a collection. An ArrayList's
items read - they are untyped values like any other - but the container does not, because
the collection reader accumulates into a List<T> and assigns it or its ToArray(), and an
ArrayList is neither.

Verified: 331 passing on net8.0/9.0/10.0 and 196 on net472, 64 generator tests, no failures,
full solution builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ninth ReadObject slice, and the last capability before object identity. 51 of the 86
corpus cases now read back through generated code and reproduce their fixture, up from 49
of 85.

Reading a sequence is the same loop every time - fill a container from the children of an
element, then assign it - and the containers are what differ: an array accumulates into a
List<T> and calls ToArray, a List<T> is assigned as it stands, and an ArrayList is its own
container. That last one is the only one a read cannot infer from the items, which is why
the spec now records it: writing one needs nothing but foreach, so the write side never had
to know. The three read paths that had grown separate copies of that loop are now one
method.

A jagged collection is the same loop nested. The ArrayOf element is the inner collection's
own element - there is no second wrapper - so an item read is that loop one level down over
the innermost items. A null inner row is i:nil like any other missing reference, which makes
{ {1,2}, {}, null } three different documents rather than two.

SanityUntypedCollections exists because the upstream cases covering these two containers are
exactly the two that cannot be read at all: ArrayContainer declares ArrayContainer(bool) and
so suppresses the implicit parameterless constructor, and Array3 has no null inner row.
Without a case of its own the container code would have shipped with no fixture exercising
it, which is the mistake this harness is here to prevent.

That leaves the one place where the generated path is strictly less capable than reflection.
DataContractSerializer allocates without running a constructor; generated code cannot, so
ArrayContainer and PrivateCstor are written byte-exactly and never read. It fails by
declining rather than by producing a wrong graph, so the caller keeps the reflection-based
serializer and nothing is lost but the optimization.

Verified: 336 passing on net8.0/9.0/10.0 and 198 on net472, 66 generator tests, no failures,
full solution builds clean. Every remaining read skip is IsReference or one of those two
constructors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last read capability. 79 of the 86 corpus cases now read back through generated code
and reproduce their fixture, up from 51. Five of the seven that skip have no generated
serializer at all - the write-side exclusions - and the other two have no accessible
parameterless constructor. Read coverage is now write coverage minus exactly those two.

I had this wrong in five previous commit messages, so it is worth stating plainly: a z:Ref
never points forward. The writer assigns an id the first time it writes an instance, so the
element carrying z:Id is written, and therefore read, before any reference to it. There is
nothing to defer and no fixup pass to run. Upstream is unambiguous - GetExistingObject
throws DeserializedObjectWithIdNotFound on an id it has not seen rather than recording it
for later, and the comment beside it says so: BinaryFormatter supports fixing up such
references later, these XmlObjectSerializer implementations do not, hence we throw.

What makes a cycle work is not patching up afterwards but when the instance is recorded. It
goes into the table between construction and the first member read, because an instance
inside its own graph is referred to while it is still being filled in - that is what a cycle
is - so recording it after its members would turn that reference into a lookup that fails.
Upstream calls AddNewObject before deserializing members for the same reason.

So the reader's scope is much the smaller half of the pair: one Dictionary<string, object>,
allocated on first use, one per ReadObject call so nothing survives between documents. The
writer's has to assign ids, track by reference identity and guard by-value recursion; this
one only has to remember.

SanityReferenceNode.cycle proves it byte-for-byte, since writing the recovered graph back
out reproduces the same z:Id and z:Ref pattern only if the identities match. A byte
comparison standing in for an object-identity claim is worth backing up once, so
CyclicGraphTests now also asserts it directly - including the reference that could only come
from the table, where the second node points back at the first while the first is still
being read.

Verified: 365 passing on net8.0/9.0/10.0 and 198 on net472, 67 generator tests, no failures,
full solution builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both halves, because migrating a codebase contract by contract needs both: the fallback
still happens, and it is no longer silent.

Nothing about the fallback changes. GetSerializer still returns null, CanReadObject stays
false, CoreWCF still uses the reflection-based DataContractSerializer, and a build that
would have worked still works. The warnings are diagnostics and nothing else - severity
Warning rather than Error, so NoWarn and EditorConfig work as usual for anyone who has
looked at one and accepted it.

Reporting them at all is a change of mind, and the file that recorded the old decision now
records why. While the generated path was purely an optimization, silence was right:
nothing was lost but speed. It is not only an optimization under Native AOT, where the
switch defaults on precisely because the reflection path is the broken one - there a silent
fallback is a build that looks clean and throws at run time.

COREWCF_0403 fires when no serializer was generated for a listed contract. COREWCF_0404
fires when one is written by generated code but not read by it, which is worth its own id
because it is half a fallback: a service that only returns the contract is unaffected while
one that accepts it is not.

Both are reported on the type the user listed rather than on every contract reachable from
it. A nested contract that cannot be written makes its container unsupported too, so
reporting both would bury the one line they can act on under a cascade of consequences. The
reason text carries the cause instead, nested as far down as it goes.

That reason is why IsReadable became a function returning a string rather than a bool: "it
cannot be read" is not something anyone can act on, and the answer is now shown to a user
rather than only to the emitter. It is computed by the emitter rather than the parser
because readability is a property of the whole contract graph.

The corpus reports seven of them, which is exactly its skip list: five contracts with no
generated serializer and two whose constructor generated code cannot call.

Verified: 365 passing on net8.0/9.0/10.0 and 198 on net472, 69 generator tests, no
failures, full solution builds clean with 0 errors.

While checking how CanReadObject is consumed I found that PartInfo.ReadObject never asks
for the generated serializer - it goes straight to Serializer, so every read runs through
reflection today whatever the generator produced. The read half is built and verified
against the corpus but is not reachable from CoreWCF yet. Recorded as risk 5a; closing it
is one branch in PartInfo mirroring the one WriteObject already has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the gap found while wiring the fallback warnings: PartInfo preferred the generated
serializer for writing but never for reading, so every read ran through reflection whatever
the generator produced.

Two call paths needed it rather than one. ReadObject was the obvious half. IsStartObject was
not: it was being asked of part.Serializer at three call sites, and that property constructs
the reflection-based serializer, which is the thing the generated path exists to avoid. Both
now resolve through a single ReadingSerializer property, and that is not tidiness - the two
have to agree. A part recognised by one serializer and then read by the other is a part read
by something that did not recognise it.

Reading stays gated on CanReadObject, so the two directions remain independent and a
contract can be written by generated code while still being read by reflection. That is the
same shape the COREWCF_0404 warning reports, and it is what makes migrating one contract at
a time possible.

The two-argument ReadObject overload is left as it is. It ignores its serializer parameter
and uses the field, which reads like a bug, but it predates this work - it came over with
the WCF port - and it is unreachable within the repo, so changing it belongs to whoever
looks at it deliberately rather than to this commit.

The seam had no test in either direction before this. The switch defaults off, so nothing in
the suite had ever taken the generated branch, and both the write wiring and now the read
wiring went in unexercised. PartInfoSerializerSelectionTests covers the choice by planting a
recording serializer on the part rather than by turning the switch on: the switch resolves
into a process-wide Lazy that would leak into a concurrently running suite, and its own
resolution order is already covered by GeneratedSerializerSwitchTests.

Verified: 155 passing in CoreWCF.Primitives.Tests with the switch never set, which is the
gate that matters - the default path has to be untouched. Plus 365 on net8.0/9.0/10.0 and
198 on net472 in the serialization tests, and the full solution builds with 0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nproven AOT premise

The diagnostic question is answered by COREWCF_0403 and 0404: both, not either.

The larger one is not answered at all. Everything in the document is verified under a
normal runtime, byte for byte against fixtures - and the premise the work exists for,
that this runs under Native AOT, has never been executed. There is no PublishAot or
trimming infrastructure anywhere in the repository to execute it with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ll it

Everything in this area was verified under a normal runtime, where dynamic code is
available and nothing is trimmed. The premise the work exists for was not verified at all,
and there was no AOT or trimming infrastructure anywhere in the repository to verify it
with. This publishes a real service with PublishAot and calls it over HTTP.

It passes. The generated serializer writes and reads the graph with IsDynamicCodeSupported
false, and the service answers over HTTP with contents that match.

The stages are separate on purpose. Serialization is the last link in a long chain, and a
failure in the host or the dispatcher would otherwise read as "AOT does not work" without
saying what does. The client is a raw HttpClient posting a hand-written envelope, because
System.ServiceModel does not support AOT either and a failure there would say nothing about
the service.

Three findings that were not visible from the unit tests.

The serializer this replaces loses data silently rather than loudly. Given the same
contract in the same binary, the reflection-based DataContractSerializer fails - and how it
fails is the finding. Before the contract types were rooted it did not throw at all: it
wrote a document a quarter of the size, a graph missing most of its members, returned as
though nothing were wrong. With the types rooted it throws NullReferenceException instead.
Silent truncation is the worse of the two and it is what an AOT app would have got, which
is the concrete form of the argument for warning on a fallback rather than falling back
quietly.

CoreWCF needs the contract rooted before the trimmer will keep it. TypeLoader finds
operations by reflecting over the contract interface, and nothing calls those methods
statically - the whole point of a dispatcher is that the call is dynamic. Without a
[DynamicDependency] the interface arrives with zero operations and the host refuses to
start. That is an annotation gap in CoreWCF rather than something an application should
have to know; it is worked around here so the stages after it can be reached.

A warning-free publish is a long way off: 339 trim and AOT warnings, essentially all from
CoreWCF rather than from this area - 207 IL3050, 72 IL2026, 56 assorted reflection-pattern
warnings, and 4 IL3054 for generic recursion aborted in the message filter tables, which
throws if that path is ever reached. They come from the security stack, the channel proxy
and the dispatcher. So a service runs under AOT for this shape without the guarantees a
clean publish would give. Serialization is one of those warnings addressed; the rest are
risk 5.

One thing the app itself taught: an earlier stage read GeneratedSerializerSwitch by name and
got a TypeLoadException, because ILC keeps the code but not the reflection metadata for an
internal type nothing looks up by name. Correct behaviour, and a fair warning about probing
internals in a trimmed app - so the stage now measures the consequence instead.

Not part of dotnet test: it proves something only once published, which is a separate
command. The README beside it says how, including that Windows needs vswhere.exe on PATH for
the ILCompiler to find the MSVC linker.

Verified: the published binary exits 0 with all five stages passing, and the full solution
still builds with 0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…Generators

The emitted code was asserted a line at a time and never shown. That is right for pinning a
rule and wrong for seeing the shape of the whole thing: nobody reviewing a change to this
generator can hold six hundred lines of output in their head from a diff of the emitter.
Five snapshots put the generated code in the repository, where it can be read and where a
change to it turns up in the diff of the pull request that caused it.

The assertions stay. They answer different questions and neither substitutes for the other.
An assertion says why a rule exists - that a QName resolves its prefix before the element
closes - and survives reformatting. A snapshot says what was actually emitted, in full. A
snapshot that changed tells you something moved but not whether it should have; an assertion
passing tells you one line is right and nothing about the other six hundred. And neither
says the output is correct: only the golden-record corpus does that, by comparing bytes
against the real serializer.

Five cases rather than one per feature. Each snapshot carries the shared helpers as well as
its own contract's code, so they overlap heavily and a sixth would mostly repeat the first.
These cover the structurally distinct shapes: a flat contract, inheritance with i:type in
both directions, the container shapes, an IsReference graph, and two contracts that fall
back - the last one because Verify renders diagnostics beside sources, which makes it the
one place the wording of COREWCF_0403 and COREWCF_0404 is visible as a user reads it.

One snapshot serves all three target frameworks. Verify names the received file per
framework so concurrent runs cannot collide, but they are compared against the same verified
file, which is the right arrangement: the generator's output is a function of the contract it
is given rather than of the runtime the test host is on. Confirmed by diffing net8.0 against
net10.0 before relying on it, and now re-checked on every run - three frameworks disagreeing
about a snapshot is exactly the signal worth having.

Two pieces of housekeeping that snapshots need to survive in a repository. The verified files
are pinned to LF in .gitattributes: left to the `text=auto` default a checkout would rewrite
their line endings and every one of them would fail on the machine that did it. And
*.received.* is ignored, since it is the candidate rather than the record.

Verify pulls a newer Microsoft.Bcl.AsyncInterfaces than the repository pins centrally,
overridden in this test project rather than raised for everyone: the pin governs what the
shipping libraries carry.

Verified: 74 passing on net8.0/9.0/10.0, up from 69, no failures, full solution builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
github-actions Bot pushed a commit that referenced this pull request Aug 9, 2026
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

✅ Test & Coverage Report

Result Count
✅ Passed 32,002
❌ Failed 0
⚠️ Skipped 1,332
Total 33,334

Line coverage: 35.9% · Branch coverage: 36.4%

📋 Detailed test report · 📈 Coverage report

Updated for commit 878e002 from PR workflow run. Reports are regenerated on every push to the PR.

…sts in the matrix

Two CI failures on PR CoreWCF#1766, both from target frameworks I never ran locally. net8.0, net9.0
and net10.0 were green on Windows and Linux throughout.

net11.0 failed because two fixtures were missing, not because anything was wrong. The tell
is that ReflectionGoldenRecordTests failed too - the oracle itself. On net11.0 the serializer
writes DateOnly and TimeOnly as primitives, as it does on net10.0, while the baseline fixture
holds the pre-.NET-10 shape that drops the value. Recorded on a net11.0 SDK: exactly two
cases diverge, SanityDateAndTimeOnly and DateTimeOnlyWrapper, and both match the net10.0
shape. The csproj already carried the Fixtures\net11.0 slot; it was empty.

Nothing in the generator needed changing for it. The DateOnly branch is a run-time test of
Environment.Version rather than a compile-time one, which is what makes it right on a runtime
that did not exist when it was written.

net472 failed for a reason that had nothing to do with the tests: every one of them passed.
CI runs `dotnet test <project> -f <tfm>` for every CoreWCF.*.Tests.csproj in each matrix
framework, with no per-project filter, so a project that does not produce a net472 assembly
fails the job with "The test source file ... was not found". The generator tests stopped
producing one when they dropped net472, which they did because the emitted code is entitled
to net6+ types and cannot be compiled against net472 reference assemblies.

CoreWCF.Kafka.Tests already solves this, and its DummyTest.cs even describes the failure
mode: build the project for every framework, but on net472 compile nothing except a single
skipped test, because xunit.v3 fails a run whose assembly contains none. Same pattern here,
with the reason spelled out where the next person will hit it. The net472 assembly is back
and reports one skipped test.

Verified: 365 passing on net8.0/9.0/10.0/net11.0 and 198 on net472 in the serialization
tests; 74 on net8.0/9.0/10.0 in the generator tests with net472 reporting its dummy; full
solution builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
github-actions Bot pushed a commit that referenced this pull request Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant