fix(lexicon)!: wire fidelity against upstream lexicons, enforced by a drift test #159
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
4 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!159
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-123-lexicon-fidelity"
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 #123.
The hand-written Lexicon models had drifted from upstream. This PR adds a drift test against a pinned upstream snapshot and fixes everything it found: the issue's checklist and the wire bugs listed in its comment.
How to review this
tests/ATProtoNet.Tests/Lexicon/Upstream/:LexiconDriftTests.cs: the checks.LexiconDriftTests.Allowlists.cs: every deliberate difference, each with a reason. A test fails when an entry no longer matches anything, so entries cannot go stale.UpstreamLexicons.cs: the snapshot loader.SdkSource.cs: finds the XRPC call sites and NSID literals insrc/.lexicons/: the snapshot, 404 files, vendored unchanged. It holds bluesky-social/atproto2e583a4(2026-09-24, currentmain) forapp.bsky,chat.bsky,com.atprotoandtools.ozone, plus the 8site.standard.*schema records the standard.site authority publishes, including its two permission sets.README.md: the commit, the record CIDs, the fetch date, the licenses (MIT/Apache-2.0 and MIT) and what each check does.tests/ATProtoNet.Tests/Lexicon/Upstream/refresh.sh <atproto commit>(needs curl, tar and jq), update the README tables from its output, and run the unit tests. Each failure is an upstream change: model it, or add a commented allow-list entry.tests/ATProtoNet.Tests/Lexicon/WireFidelityTests.cs, the Ozone tests andTypedBskyClientTests:DeactivateAccountAsyncandUpdateAllReadAsyncnow send their JSON body,{}when every field is empty.MuteConvoAsync,UnmuteConvoAsyncandUpdateReadAsyncunwrap{convo}.AddReactionAsyncandRemoveReactionAsyncunwrap{message}. Their return types are unchanged.ExportAccountDataAsyncstreams JSON Lines instead of parsing them as JSON.SearchAccountsAsyncis now a GET.queryStatusesreplacesquerySubjects.listConvossendsreadState.searchPostssends onetagparameter per tag.listNotificationsno longer sendsseenAtorpriority.tests/ATProtoNet.Tests/Serialization/LexiconUnionTests.cs. The fixtures are upstream-shaped, and several are captured frompublic.api.bsky.app, which puts$typelast.Drift test
XrpcCalls_ExistUpstreamWithTheirHttpMethodNsidLiterals_NameUpstreamDefsnsid/nsid#defstring literal insrc/names an upstream def. This caught the invented permission sets andquerySubjects.LexiconModels_EachResolveToAnUpstreamObject$type, its union discriminator, its name (FooResponse→foo#output,FooRequest→foo#input,FooRecord→ recordfoo, otherwise a unique deffooin the namespace), or an explicitDefOfentry.LexiconModels_JsonPropertyNamesExistOnTheirUpstreamDef[JsonPropertyName]exists on that def.LexiconModels_DeclareEveryPropertyOfTheirUpstreamDefPermissionSetConstants_ArePublishedPermissionSets/PublishedPermissionSets_EachHaveAConstantAtProtoScopes.PermissionSetsis exactly the published set.KnownValueConstants_MatchUpstreamAllowlists_AllStillMatchConventions/LexiconJsonNameTestsrequires an explicit camelCase[JsonPropertyName]on every public Lexicon model property. It and the drift test replace 54 DTO-only serializer tests that only serialized a model to check its property names:ConvoModelsTestsandLabelerModelsTests: deleted.StandardSiteModelTests: deleted. Its one client test moved toStandardSiteClientTests.CommonModelsTests,SyncModelsTests(non-firehose only),OzoneClientTestsandChatActorClientTests.Tests that cover converters, unions or data-model shapes (
BlobRef, signed labels, identifiers) stay.Fixes the drift test demanded
canConvo→canChat, plusConvo.readOnly→readState.associatedChat→ typedAssociated(ProfileAssociated) on all three profile views.ConvoView.Openedremoved.AcceptConvoResponseis{rev}only.RecordViewDetail.BlobCids→Blobs(BlobViews).tools.ozone.moderation.queryStatusesreplaces the inventedquerySubjects. It takes all ~35 filters as aSubjectStatusFilter,TakendownandAppealedare booleans, and it returnsSubjectStatuses.tools.ozone.signature.searchAccountsis a GET withvalues[].AccountResultis replaced byAccountInfo(admin.defs#accountView).deactivateAccount:deleteAfter.updateAllRead:statusin,updatedCountout.exportAccountData: JSONL, returned as anXrpcStreamResponse.listNotificationsseenAtis removed.ListNotificationsResponse.Priority,IsFallbackandSyncClient.NotifyOfUpdateAsyncare[Obsolete].priorityparameter is removed, because a parameter cannot carry[Obsolete](see Flagged).ManageNotificationsis fixed toauthManageNotifications. Added:DeleteContent,FullChatClient,StandardSiteFullandStandardSiteSocial.Hide,WarnandBot.[Obsolete]:Gore,NotAvailable(duplicatesNoUnauthenticated) andContentWarning, which is neither a global value nor one mod.bsky.app applies.Spam,ImpersonationandMisleadingstay, allow-listed as mod.bsky.app labels.Addedsection. It covers posts, profiles, preferences, notifications, graph, labeler, video, chat,com.atproto,site.standardand Ozone, including thequeryEvents/querySets/listMembersfilters andverifierDid.Unknown*fallback:ReasonRepost/ReasonPin) and skeleton reason;RecordView.Record(EmbeddedRecordView:#viewRecord, the not-found, blocked and detached placeholders, andGeneratorView/ListView/LabelerView/StarterPackViewBasic);getRelationshipsentries;ModerationSubjectgainsmessageRef/convoRef.ThreadgateViewBlockedAuthorViewerStateFeedsCreatorChatMemberView.AssociatedListRecord.LabelsTeamMember.Profile(ProfileViewDetailed)Breaking changes (migration)
ProfileViewDetailed.AssociatedChat→Associated?.Chat.CanConvo→CanChat;readOnly:→readState: "unread"; dropConvoView.OpenedandAcceptConvoResponse.Convo.UpdateAllReadAsyncreturnsUpdateAllReadResponse(awaits still compile; recompile).ExportAccountDataAsyncreturnsXrpcStreamResponse: readContentline by line and dispose it.QuerySubjectsAsync/EnumerateSubjectsAsync/QuerySubjectsResponse→QueryStatusesAsync/EnumerateStatusesAsync(SubjectStatusFilter)/QueryStatusesResponse.SearchAccountsAsync(IEnumerable<SigDetail>)→SearchAccountsAsync(IEnumerable<string> values);AccountResult→AccountInfo.ListNotificationsAsync/EnumerateNotificationsAsynclosepriorityandseenAtand gainreasons;GetUnreadCountAsynclosespriority.SearchPostsAsync(tags),GetFollowers/FollowsAsync(sort),GetListsAsync(purposes),MuteActorAsync,CreateReportAsync(modTool),DeactivateAccountAsync(deleteAfter), theQueryEventsAsyncfilters,ListMembersAsyncandQuerySetsAsync. Name the arguments.AtProtoScopes.PermissionSets: the invented constants are removed andManageNotificationshas a new value. UseDeleteContent,ManageNotifications,ManageModerationorFullApp, and recompile.JsonElementare typed.Relationship.Type/NotFoundActor.Typeare removed, and the four record-embed views now write$type. Match on the variant types; an unknown variant'sRawis the oldJsonElement.BlobCids→Blobs; theModEventLabel/ModEventTaglists arerequired.Verification
mainwith #157.DefOfmappings;InfoEventseq/time, #126;getProfile,getAuthorFeed(reasonPin/reasonRepost, a quote embed) andgetRelationshipswere captured frompublic.api.bsky.appon 2026-09-25. The standard.site permission sets were fetched from the authority's PDS.Deferred and flagged
HandleEvent/TombstoneEventand theCommitEvent.TooBig/Rebase/Blobsobsoletions are left to #126. They are allow-listed with#126, and so isInfoEventinheritingseq/time.ConvoView.kind/lastReaction,ChatMemberView.kind,MessageView.reactions/replyTo,MessageInput.replyTo, the typed log entries, andlistConvoskind/lockStatusare union-typed group-chat features. They are allow-listed with#128. The scalar chat fields (allowGroupInvites,relatedProfiles, memberviewer/createdAt/verification) are added here. Overlap with #127:JobStatus.failureCodeand the four new job states are added here, as this issue lists them.ReportReasonslacks the granulartools.ozone.report.defs#reason*values. They are allow-listed as a prefix.priorityparameters are removed, not[Obsolete]: C# cannot obsolete a parameter, and an obsolete overload would have been ambiguous with the new signatures. Upstream ignores the parameter.createSession.allowTakendownis on the internalCreateSessionRequest, but no public method sets it. Exposing it means touching the session methods #124 owns.SpaceRecordView.Path(spaces) is a computed property with no[JsonIgnore], even after #157, so it is serialized as"path". The convention test allow-lists it; the one-line fix ([JsonIgnore]) is left to the spaces owner.GetLabelerServicesResponse.ViewsstaysJsonElement.LabelerViewcan derive from only one union base, and it now belongs to the record-embed union.FeedReplyRef.Root/ParentstayJsonElement. The issue does not list them, andNotFoundPost/BlockedPostalready belong to the thread union.Edits outside this issue's area
ServerClient.DeactivateAccountAsync(the body).ServerModels.cs:CreateSessionRequest.AllowTakendown,DescribeServerResponse.BlobUploadLimitand a new internalDeactivateAccountRequest.Auth/OAuth/AtProtoScopes.cs: onlyPermissionSetsand theIncludedoc example.SyncModelsTestslost only its non-firehose DTO tests; #157's typed firehose tests are kept unchanged.TypedIdentifierGuardTests.Exceptionsgained entries for the new string members and lost theQuerySubjectsAsyncones.🤖 Generated with Claude Code
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
⏳ Held on the OpenCode Go window — the 5-hour window is at 2% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-25T03:54:33.092Z) — nothing to re-trigger.
@claude /review — the description has How to review this. The diff includes a 404-file vendored lexicon snapshot, which is data; skip it. Please read the drift tests in
tests/ATProtoNet.Tests/Lexicon/Upstream/and the behavioural fixes the description lists (wrapped chat outputs,queryStatuses, the GETsearchAccounts, request bodies, JSONL export) in full.🤖 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, well-scoped drift-fidelity PR, and it holds up under review. I spot-checked a representative cross-section of the claimed fixes against both the diff and the actual branch content (
origin/issue-123-lexicon-fidelity), not just the PR description:chat.bsky.convounwrapping:MuteConvoAsync/UnmuteConvoAsync/UpdateReadAsyncnow correctly unwrap the internalConvoOutput { convo }, andAddReactionAsync/RemoveReactionAsyncunwrapMessageOutput { message }— matches the described wire shapes.UpdateAllReadAsync/DeactivateAccountAsyncnow always send a JSON body viaProcedureAsync(..., body), which serializes the request DTO (producing{}when every field is null) rather than sending no body at all — consistent with the PDS rejecting bodyless requests.ExportAccountDataAsyncnow returnsXrpcStreamResponseviaXrpcClient.DownloadAsync, which implements bothIDisposable/IAsyncDisposablecorrectly, matching the JSONL streaming fix and theawait usingpattern shown indocs/chat.md.GraphModels.Relationship's newBlocking/BlockedBy/BlockingByList/BlockedByListfields match the vendored upstreamapp.bsky.graph.defs#relationshipschema field-for-field, including descriptions.XrpcParams.AddAll(used for the newtags,purposes,reasons,values[]parameters) already exists inXrpcClient/XrpcParams, so the new call sites compile against real infra rather than something invented for this PR.FeedReason,ThreadgateRule,PostgateEmbeddingRule,EmbeddedRecordView,RelationshipEntry,Preference) consistently follow the existing[AtProtoUnion(typeof(UnknownX))]/IUnknownUnionVariantpattern already used elsewhere in the codebase, with eachUnknownXvariant preserving the rawJsonElementand discriminator.The scope creep into
#124's files (DeactivateAccountAsyncbody,CreateSessionRequest.AllowTakendown,DescribeServerResponse.BlobUploadLimit,AtProtoScopes.PermissionSets) is explicitly called out and kept surgical, as described. The deferred items (#126 firehose, #128 group chat, #131 report reasons) are consistently allow-listed rather than silently skipped, and the drift test's own design (each allow-list entry must still match something, or the suite fails) is a solid anti-staleness mechanism.I did not find a correctness, security, or omission issue worth blocking on. Given the size of the diff (the tool truncated it), I was not able to review every single line of every model file, but the sampled areas — including the ones most likely to hide bugs (unwrapping response shapes, body-sending fixes, union discriminators, and positional-argument reshuffles) — are all consistent with both the PR description and the actual upstream Lexicon snapshot vendored in the branch.
⏱ 1m 25s · 21 turns · tokens: 95.6k in / 8.0k out (+2238.3k cache-read)
📜 Full session transcript