UnionJsonConverterFactory breaks serialization of any IAtProtoUnion implementor (CanConvert true, CreateConverter null) #46
Labels
No labels
breaking-change
bug
documentation
duplicate
enhancement
good first issue
help wanted
performance
question
wontfix
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET#46
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
AtProtoJsonDefaults.OptionsregistersUnionJsonConverterFactory, whoseCanConvertreturns true for every type assignable toIAtProtoUnionwhileCreateConverterreturnsnull("let the default serializer handle it"). System.Text.Json treats a null return from a claimed factory as an error:So the moment a consumer marks a custom union base with
IAtProtoUnion(as suggested for open unions), all (de)serialization of records containing it throws. The SDK's own union types never hit this because they use[JsonPolymorphic]/[JsonDerivedType]without implementing the marker.Fix options: make
CanConvertreturn false (the attribute-based polymorphism handles unions), or actually implement the converter. Found while building Mise'sRecipeAttributionunion forexchange.recipe.defs#attribution*— Mise works around it by not implementing the marker interface.🤖 On it — follow along in the action run; the reply will link the full session transcript.
Fixed and pushed to branch
claude/issue-46.Diagnosis
Confirmed exactly as reported.
UnionJsonConverterFactory.CanConvertreturned true for anything assignable toIAtProtoUnion, whileCreateConverterreturnednull— which System.Text.Json rejects outright:I reproduced it first with a
RecipeAttribution-shaped union (mirroring Mise'sexchange.recipe.defs#attribution*): 5 tests, all 5 throwing before the change, on both serialize and deserialize, and on the containing record as well as the union itself.The factory was registered in two places, not one —
AtProtoJsonDefaults.CreateOptions()andLexiconTypeRegistry.CreateOptions()— so the plugin/registry path was equally affected.Fix
Took the "make it not claim the type" option, in its strongest form: the factory is deleted rather than neutered. It never had any behaviour — union discrimination comes entirely from
[JsonPolymorphic]/[JsonDerivedType]plusLexiconTypeRegistry.RegisterUnionVariant, exactly as its own comment admitted. ACanConvert => falsefactory would just be dead weight in every options instance. It wasinternal, so this is not a public API change.IAtProtoUnionstays public, now XML-documented as what it actually is: a behaviour-free documentation marker that is optional and has no effect on serialization. Removing it would have broken anyone who took the original advice; leaving it undocumented would leave the next person guessing what it does.Also added a "Union Types" section to
docs/custom-records.md— the gap that produced the misleading advice in the first place. It covers closed unions via attributes, the<nsid>#<defName>discriminator convention, that the marker is optional, and open unions viaRegisterUnionVariant.Verification
Installed the .NET 10 SDK in the container (none was present), then:
dotnet buildacross the solution — succeeded, 0 errors, no new warnings (remaining ones are pre-existingCS1591/NU1902); no brokencrefin the new XML docs.dotnet test tests/ATProtoNet.Tests/— 1000/1000 passed, up from 999. The 6 new tests inSerialization/AtProtoUnionMarkerTests.cscover serialize, deserialize, the union as a root value, out-of-order$typeon a marker union (interaction with the #50 fix), registry options, and the documented open-union runtime-registration round-trip. All of them fail onmain.CHANGELOG.mdupdated under[Unreleased]→Fixedin the same commit.One note for Mise: the workaround (not implementing the marker) was correct and remains correct — it stays correct after this fix too, since the interface is now genuinely inert. You can add it back for documentation value whenever convenient, but nothing requires it.
⏱ 4m 16s · 33 turns · tokens: 40.2k in / 11.8k out (+1198.7k cache-read)
📜 Full session transcript
→ Opened PR: #55