feat(serialization)!: forward-compatible models — open unions and unknown-field round-tripping #149
No reviewers
Labels
No labels
breaking-change
bug
documentation
duplicate
enhancement
good first issue
help wanted
performance
question
wontfix
No milestone
No project
No assignees
3 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!149
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-115-open-unions"
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?
Closes #115.
Lexicon unions are open by spec and Lexicons grow optional fields, but the SDK's unions were closed and its models dropped unknown fields. One unrecognized
$typefailed the whole response, and read-modify-write lost data. This PR puts the fix in one shared layer that later PRs can build on. "Unions and unknown fields" indocs/custom-records.mddocuments the pattern.Open unions
[AtProtoUnion](Serialization/AtProtoUnionAttribute.cs) marks an abstract union base. Variants are still declared with[JsonDerivedType], so the discriminators stay next to the base.Unknown{Base}variant, a sealed subclass implementingIUnknownUnionVariant(Type+Raw, with a(string, JsonElement)constructor).[AtProtoUnion(Closed = true)]keeps strict behavior.InvalidOperationExceptionthe first time it is used.AtProtoUnionConverterFactory(Serialization/AtProtoUnionConverter.cs) reads and writes these bases.$type, wherever it sits (the appview often puts it last). It then resolves in order: the declared variants, then the registry (checked on every miss, so late registration works), thenUnknown{Base}holdingJsonElement.ParseValue. A closed union throwsJsonExceptioninstead.JsonMarshal.GetRawUtf8Value+WriteRawValue, so number formatting, escapes and whitespace survive byte for byte. A known variant is written through its own contract.IgnoreUnrecognizedTypeDiscriminators: that flag falls back to the abstract base and loses the discriminator. The contract modifierLexiconTypeRegistry.ApplyUnionContractsmakes the converter approach work:PolymorphismOptionsfrom[AtProtoUnion]bases, because STJ refuses polymorphism metadata on a type with a custom converter.$typeproperty that is written but never read. A variant therefore always writes its discriminator first, and an incoming$typeis matched and skipped, so it never allocates or lands inExtensionData.[JsonPolymorphic]bases.AllowOutOfOrderMetadataProperties = truestays, for plain[JsonPolymorphic]types. Tests cover a trailing$typefor both kinds, and a JSON-escaped$typeproperty name.EmbedBase,EmbedView,FacetFeature,ThreadNode,ReportSubject, and Ozone'sModEventTypeandModerationSubject(Ozone emits 11 event types the SDK doesn't model). Each gets anUnknown*variant.ApplyWriteOperation, the only closed union upstream.app.bsky.embed.gallery(GalleryEmbed/GalleryImagewith the openGalleryItemunion,GalleryView/GalleryViewImagewith the openGalleryViewItemunion), registered onEmbedBaseandEmbedView. That coversfeed.post#embed,feed.defs#postView.embed, andrecordWithMedia#media/#view.media, since both use those bases.embed.record#viewRecord.embedssits insideRecordView.Record, which is still a rawJsonElement, so it is covered once the lexicon-fidelity issue types it asList<EmbedView>.$typeunion base is an[AtProtoUnion]with a valid shape, and that onlyApplyWriteOperationis closed. The exceptions are Spaces andFirehoseMessage; see "Deferred".Unknown fields
LexObject(Models/LexObject.cs) provides[JsonExtensionData] IDictionary<string, JsonElement>? ExtensionData. It staysnullunless an undeclared field shows up.AtProtoRecordand every SDK model of a Lexicon record, view or object, in App.Bsky, Chat, Com.AtProto, Site.Standard, Tools.Ozone andModels/CommonModels.cs.*Request/*Responseenvelopes, the fixed data-model shapesBlobRef/BlobLink/CidLink,IdentityModels, the firehose frame models, and Spaces.$typehandling:ApplyRecordTypeDiscriminatorworks with extension data unchanged. The record's$typeis a get-only contract property, so an incoming$typeis matched and skipped rather than routed toExtensionData. Tested: no duplication, and a mismatched incoming$typeis dropped while the record's own wins on write.PostRecordetc.) behave the same way through their get-onlyType.LexiconTypeRegistry/AtProtoJsonDefaultsAtProtoJsonDefaults.Optionsnow carries the union factory and modifier bound toLexiconTypeRegistry.Instance. Registered variants therefore take effect in every client, inRecordCollection<T>, and in JetstreamGetRecord<T>(), which now uses these options.MakeReadOnly(). The wrong XML remark is gone, and the new one points to copying:new JsonSerializerOptions(AtProtoJsonDefaults.Options)keeps all of the behavior above.RegisterRecordType/GetRecordType/RecordTypes)_pluginsCreateOptions()[LexiconPlugin]+LoadPluginsFromAssembly(which did nothingLoadPlugin<T>()doesn't)IAtProtoUnion(its test becameCustomUnionTests, the custom open-union test)ILexiconPlugin+LoadPlugin<T>(). That is still a meaningful packaging convention for "variants my package adds to unions it doesn't own".RegisterUnionVariantnow rejects a non-union base, and a discriminator already declared or registered for another type.Bytes
Label.Sighas[JsonConverter(typeof(LexBytesJsonConverter))]. The test uses themod.bsky.appqueryLabelsshape with a 64-byte unpadded$bytes.LexBase64.Decode(padding optional) is used byLexBytesJsonConverter,JetstreamEventParserandJetstreamSegmentReader. The latter two usedConvert.FromBase64String, which rejects the unpadded base64 the data model specifies.DagCborEncoder.PadBase64was left alone as instructed.UpdateProfileAsyncTask<RecordRef> UpdateProfileAsync(Action<ProfileRecord> update, CancellationToken).app.bsky.actor.profile/self(or starts from an empty record withcreatedAtwhen there is none).swapRecord: <cid>.InvalidSwapit re-reads and re-runs the callback, up to 3 attempts in total, then rethrows.ExtensionDataincluded, are written back unchanged.ProfileRecordgainsPronouns,Website,Labels(SelfLabels) andJoinedViaStarterPack.Func<ProfileRecord, ProfileRecord>: the models are classes, andLexObjectmust be a class because users writeclass TodoItem : AtProtoRecord. Sowithis unavailable, and aFuncwould force callers to copy every field andExtensionDataby hand.ProfileRecord's properties are therefore settable, and the callback is anAction:p => p.DisplayName = "x". Settingnullclears a field, which the old API could not do.AtProtoClient.cs.Breaking changes (all in CHANGELOG with migration notes)
[JsonPolymorphic]becomes[AtProtoUnion], the bases derive fromLexObject, and newUnknown*variants appear. Migration: add anUnknown*or_arm toswitches; the compiler won't flag it.LexObject, a binary break. A consumer record type with its own[JsonExtensionData]must drop it.UpdateProfileAsync: new signature, andProfileRecordsetters change frominittoset. Migration:UpdateProfileAsync(displayName: "x")becomesUpdateProfileAsync(p => p.DisplayName = "x").AtProtoJsonDefaults.Optionsis read-only. Migration: copy it, or register variants on the registry.LexiconTypeRegistryremovals as listed above, plusIAtProtoUnionand[LexiconPlugin].$typealso outside union position, for exampleRecordWithMediaEmbed.Record. The data model allows it, and a$typeread there is now written back rather than dropped.Verification
Build:
dotnet build --no-incrementalfor the whole solution gives 0 warnings, 0 errors.Unit tests: 1775 passed, 0 failed, 5 skipped (1780 total). New tests:
OpenUnionTests: every open union reads an unknown variant asUnknown*and re-serializes byte for byte, both through the base and as its own type. Also: an unknown embed inside a timeline page, an unknown thread node among replies, an unknown Ozone event, closed-union rejection, missing or non-object$type,null,$typeleading on write and never inExtensionData, read-only and copied options, plain options, a JSON-escaped$typename, and the assembly-wide guard.GalleryEmbedTests: a timeline page built from the upstream appview snapshots (packages/bsky/tests/views/__snapshots__/posts.test.ts.snap, "embeds gallery" and "embeds gallery with record") deserializes. Also: the gallery record reads and round-trips (JsonElement.DeepEquals), and a new gallery embed writes the Lexicon shape.UnknownFieldTests: unknown fields at every nesting level of aPostRecord,AtProtoRecordsubclasses (no$typeduplication),ProfileRecordwith all upstream fields plus an unknown one, and view extension data.AtProtoClientUpdateProfileTests(fake PDS): unknown and all other fields preserved withswapRecordsent,nullclears a field, retry after oneInvalidSwap, give up after 3, create without a guard, and unauthenticated.CustomUnionTests/LexiconTypeRegistryTests: the custom open union, registration on custom and SDK unions throughAtProtoJsonDefaults.Options, late registration, plain[JsonPolymorphic]registration, conflicts, plugins, and a misdeclared base.Siground-trip,LexBase64vectors, and a Jetstream unpadded$bytesregression.Timeline deserialization benchmark: BenchmarkDotNet 0.14, in-process, 5 warmup + 20 iterations, Ryzen 5 7600X, .NET 10.0.12. It deserializes
FeedResponsewithAtProtoJsonDefaults.Options, using the performance auditor's fixture generator.origin/main)author.associated,post.bookmarkCount,viewer.bookmarked), 199 KBJsonSerializer.Deserialize, which allocates its own read-stack frames (about 0.5 KB per embed). There is no public STJ API that avoids it.JsonElementin a per-object dictionary. It shrinks as the lexicon-fidelity work models those fields. Before this PR, a page with a single gallery post didn't deserialize at all.Deliberately deferred
Lexicon/Com/AtProto/Space/*andSimpleSpace/*unions and models are untouched. A parallel PR owns them, and the guard test excludes them.FirehoseMessage: stays a strict[JsonPolymorphic]union, and its frame models don't derive fromLexObject. These are event-stream frames transcoded from CBOR byFirehoseEventParser(owned by a parallel PR). That parser deliberately returnsnullfor unknown frame types, as consumers should skip them, so anUnknown*variant would change its contract.JsonElement(FeedViewPost.Reason,PostView.Threadgate,RecordView.Record→viewRecord.embeds, …) are not retyped here; that belongs to the lexicon-fidelity issue.atproto-lexgenstill emits[JsonPolymorphic]unions.docs/lexicon-codegen.mdnow says how to make one open. Switching the emitter to[AtProtoUnion]plus an unknown variant is a follow-up.LexiconTypeRegistry.Instance). A per-client registry or options (research WP3.3) would needAtProtoClientOptionsplumbing throughXrpcClientandRecordCollection.DagCborEncoder.PadBase64is not unified withLexBase64; a later PR does that.Edits outside this issue's core files
src/ATProtoNet/Streaming/JetstreamEventParser.cs,JetstreamSegmentReader.cs: the base64 padding fix only, as permitted.src/ATProtoNet/Streaming/JetstreamModels.cs:GetRecord<T>()called the removedLexiconTypeRegistry.CreateOptions()and now usesAtProtoJsonDefaults.Options.src/ATProtoNet/RecordCollection.cs:AtProtoRecord : LexObjectplus a remark.tests/ATProtoNet.Tests/Streaming/JetstreamV2EventParserTests.cs: one regression test.🤖 Generated with Claude Code
Lexicon unions are open by spec and Lexicons grow optional fields, but the SDK's unions were closed and its models dropped what they did not declare: one gallery post failed a whole timeline page, an unknown Ozone event type failed queryEvents, and UpdateProfileAsync deleted profile fields. - [AtProtoUnion] bases with an Unknown{Base} variant that keeps the raw object and writes it back byte-for-byte; closed unions stay strict. A converter factory plus a contract modifier replace STJ polymorphism, which cannot fall back without losing the discriminator. - LexObject.ExtensionData on AtProtoRecord and the SDK's Lexicon models, so undeclared fields survive deserialize -> serialize. - AtProtoJsonDefaults.Options consults LexiconTypeRegistry.Instance and is frozen; the registry's dead parts (record types, plugin scanning, CreateOptions) and IAtProtoUnion are removed. - app.bsky.embed.gallery, Label.Sig as Lexicon bytes, one padding-tolerant base64 decoder for LexBytesJsonConverter and Jetstream. - UpdateProfileAsync(Action<ProfileRecord>) edits the stored record in place, writes with swapRecord and retries on InvalidSwap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>@claude /review
d3a54d82d78d84327e18🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
✅ Verdict: approve — no blocking issues found.
This is a large, carefully constructed PR and it holds up well under review. The core mechanism —
AtProtoUnionShapereflecting the[AtProtoUnion]attribute once per base,AtProtoUnionConverter<TBase>scanning a copy of the reader for$type(leaving the original reader untouched for the real deserialize), andLexiconTypeRegistry.ApplyUnionContractsstrippingPolymorphismOptionsfrom union bases while injecting a leading get-only$typeproperty on variants — is internally consistent. I traced the read/write paths forEmbedBase,GalleryItem/GalleryViewItem,ThreadNode,FacetFeature,ReportSubject/ModerationSubject, and the closedApplyWriteOperation, and didn't find a case where a declared variant, a registered variant, and the unknown fallback resolve inconsistently between read and write.JsonMarshal.GetRawUtf8Value(used inUnknownUnionVariant.WriteRaw) is a real API — confirmed via web search it lives inSystem.Runtime.InteropServicesand was added in .NET 9, so it's available on this repo's net10.0 target; the file correctly hasusing System.Runtime.InteropServices;.UpdateProfileAsync's retry loop (AtProtoClient.cs) is correct: three total attempts, re-reading and re-running the callback each time, rethrowing on the thirdInvalidSwap. The create path (no existing profile) correctly passes a null CID as "no swap guard". The contract-modifier ordering (ApplyRecordTypeDiscriminatorthenregistry.ApplyUnionContracts) doesn't double-add a$typeproperty, sinceApplyUnionContractsonly adds one when the type is an actual union variant (AtProtoUnionShape.FindOwnerreturns non-null), which plainAtProtoRecordsubclasses never are.I didn't find correctness bugs worth blocking on. Two minor, non-blocking observations:
LexiconTypeRegistry.RegisterUnionVariantdoesn't reject registering a variant against a closed[AtProtoUnion(Closed = true)]base (onlyApplyWriteOperationtoday) — the read path inAtProtoUnionConverter<TBase>.Readchecks the registry before theshape.Closedcheck, so a registered variant would silently work even though the Lexicon says the union is closed. This is presumably intentional (a consumer overriding closed-ness deliberately) but it's undocumented and untested; worth a one-line note in the XML docs onRegisterUnionVariantorClosed.Mediaproperty onRecordWithMediaEmbedand the generalEmbedBase/GalleryItemunions have no runtime restriction preventing e.g. aRecordEmbedfrom being assigned asMedia— but this mirrors the pre-existing lack of enforcement (previously just a doc comment), so it's not a regression.Overall the design matches the PR description precisely, the breaking changes are all documented with migration notes in
CHANGELOG.md, and the docs (custom-records.md,lexicon-codegen.md) were updated consistently with the code.⏱ 2m 13s · 4 turns · tokens: 92.9k in / 11.8k out (+355.5k cache-read)
📜 Full session transcript
8d84327e188003a20cb4Rebased onto main to resolve a CHANGELOG-only conflict (#146 merged).
verify-rebase: the PR's patch outsideCHANGELOG.mdis byte-identical between the approved head8d84327eand the new head8003a20c. CI is green on the new head (and 2473 unit tests pass locally), so the approval carries over under the maintainer-approved policy for CHANGELOG-only rebases. The reviewer's closed-union registration note is tracked for the follow-up that migrates the Space unions (#133).