AtProtoRecord subclasses serialize a stray 'type' property — $type JsonPropertyName is not inherited through overrides #49
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#49
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?
AtProtoRecorddeclares the discriminator as an abstract property with[JsonPropertyName("$type")]on the BASE member. System.Text.Json does not inherit attributes through property overrides, so every consumer subclass following the documented pattern (public override string Type => "com.example.todo.item";— the README's headline example) serializes as"type": "…"(camelCase policy) instead of"$type": "…".Observed in the wild with Mise: records created via
RecordCollection<T>.CreateAsynccarried BOTH"$type"(injected by the LexiconTypeRegistry path) and a stray"type"property — polluting records other apps read. Consumers not registered in the registry presumably get only"type"and produce records with no valid$typeat all.Fix options: make
Typenon-abstract (virtual with a backing NSID passed via ctor), or document that subclasses must re-declare[JsonPropertyName("$type")]on the override, or add a contract modifier that renames the member onAtProtoRecord-derived types. Mise works around it by re-declaring the attribute on each override.🤖 On it — follow along in the action run; the reply will link the full session transcript.
Fixed and pushed to
claude/issue-49.What I found
Reproduced it first — and the cause is a bit worse than the issue describes. It isn't just that the
[JsonPropertyName("$type")]fails to reach the override; System.Text.Json also doesn't collapse the abstract base member and the override into one contract property. Both survive, so a record following the README pattern serializes as:So the stray
"type"and the correct"$type"both come fromAtProtoRecorditself — theLexiconTypeRegistrypath wasn't injecting anything. That also means the "consumers not in the registry get no valid$type" worry doesn't apply: they always got a valid$typeplus the stray sibling.The existing
AtProtoRecord_SerializesWithDollarTypetest missed this because it only assertedContains("$type")and used rawJsonSerializer.Serialize(todo)(no camelCase policy).The fix
Went with the third option in the issue — a contract modifier — since it repairs already-compiled consumer code with no source change on their side.
AtProtoJsonDefaults.ApplyRecordTypeDiscriminator(JsonTypeInfo)(new public API) collapses every contract property mapping to theAtProtoRecord.Typevirtual slot down to one, renamed$typeand kept first in the object.AtProtoJsonDefaults.OptionsandLexiconTypeRegistry.CreateOptions()— soRecordCollection<T>,RepoClient, and everything throughXrpcClientare fixed automatically. Public so apps that serialize records with hand-builtJsonSerializerOptionscan add it toDefaultJsonTypeInfoResolver.Modifiers; documented indocs/custom-records.md.getter.GetBaseDefinition().DeclaringType) rather than the member name, so an unrelatednew-shadowingTypemember is left alone. Mise's workaround (re-declaring the attribute on the override) stays safe — it's deduped rather than colliding with the base member.One implementation note worth flagging: my first cut of the slot check matched every property declared on
AtProtoRecord, silently droppingcreatedAt. Caught by the repro, fixed, and covered bySerialize_PlainOverride_PreservesOtherRecordProperties.Verified
$type: direct, boxed asobject(the actualCreateRecordRequest.Recordpath), through the registry options, the attribute workaround, and unrelatedType-named members.tests/ATProtoNet.Tests/Serialization/RecordTypeDiscriminatorTests.cs(10 tests) + tightened the existing test to useAtProtoJsonDefaults.Optionsand assert the stray"type"is absent. I confirmed these are real regressions by short-circuiting the modifier — 6 fail without the fix, pass with it.dotnet test tests/ATProtoNet.Tests/ --configuration Release), anddotnet build -c Releasesucceeds with no new warnings on any touched file.CHANGELOG.mdupdated under[Unreleased](Addedfor the new public modifier,Fixedfor the bug).One thing I did not change:
JsonSerializer.Serialize(record)with stock options still emits both properties, since the modifier lives in the options. Closing that completely would need option 1 (non-abstractTypewith a ctor-supplied NSID), which trades away the compile-time error you currently get for a subclass that forgets to declare its type — I left that call to you rather than making it unilaterally.⏱ 7m 57s · 45 turns · tokens: 69.2k in / 27.7k out (+2556.1k cache-read)
📜 Full session transcript
→ Opened PR: #57