feat!: typed identifiers, AtDatetime and one paginator for com.atproto (1/4 of #119) #154
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
5 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!154
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-119-typed-foundation"
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?
Part of #119 (1/4: foundation + com.atproto).
Lays the foundation the other three parts build on, and converts the
com.atproto.*surface, theAtProtoClientfacade,RecordCollection<T>,SessionandPdsAdminClientto typed identifiers. Parts 2–4 (app.bsky;chat/ozone/site.standard; spaces and streaming) follow the conventions this PR writes down, in parallel.How to review this
The diff is large but mostly mechanical. Read these first; everything else applies them.
New types (the only non-mechanical logic)
src/ATProtoNet/Identity/AtDatetime.csreadonly record struct. Keeps the original text. Strict parser (TryCreate: syntax, calendar,-00:00, negative-year rejection) run against the interop fixtures. Lenient wire path (FromWire). Instants stored as ticks since year 0000 so valid 0000–0009 dates still order. Equality is ordinal on the text; ordering is by instant, with the text as tie-break.src/ATProtoNet/Serialization/IdentityJsonConverters.csAtDatetimeJsonConverter: never rejects a string, rejects non-string tokens, and refuses to writedefault.src/ATProtoNet/Http/Pagination.csnull, empty or already-seen cursor; a cycle A→B→A also ends.src/ATProtoNet/Models/CommonModels.csICursorPage<out T>replacesICursoredResponse.tests/ATProtoNet.Tests/Conventions/TypedIdentifierGuardTests.csExceptions, temporaryPendingConversions. Stale entries in either list fail the build, so parts 2–4 must delete theirs.tools/ATProtoNet.LexiconGenerator/CodeGen/TypeMapper.cs(+CSharpEmitter,LexiconEmitter)IReadOnlyList<T>, and the reverse mapping.Conventions (now in
CONTRIBUTING.md→ Lexicon models and clients, with before/after examples)datetimeformats are typed, neverstring. Lexicon-required fields arerequired; optional ones are nullable.limit→cursor→CancellationToken.IReadOnlyList<T>; methods takeIEnumerable<T>.internal.ICursorPage<T>, withItemsimplemented explicitly so the wire shape does not change.Enumerate*takesint? pageSize = nulland goes throughPagination.Mechanical parts
Lexicon/Com/AtProto/{Repo,Server,Identity,Sync,Admin,Label,Moderation}.string.Emptydefaults replaced byrequired.List<T>replaced byIReadOnlyList<T>.X.Parse("…")."bafy1","bafytest", …) replaced with real ones, because responses now validate them.Non-mechanical call-site decisions
AtProtoClient.ApplyOAuthSessionAsync:OAuthSessionResult.Handlefalls back to the DID when the handle doesn't verify. The typedSession.Handletakes the handle only whenIsHandleVerifiedis set, and ishandle.invalidotherwise. A handle marked verified that doesn't parse throws before any client state changes.AtProtoAuthenticationHandlerbuilt a placeholderSessionwith an empty DID. Its claims then carried that empty DID, becauseResumeSessionAsynckeeps the stored DID. The placeholder now takes the token'ssub, and the handler rejects a token whosesubis not a DID.What changed
AtDatetimeIsValid == false.TryGetValuerecovers near-miss ISO 8601 forms, with no time zone read as UTC, andValuethrows when there is no instant.ParseandTryParseaccept only valid values.Now(),FromDateTimeOffsetandFromDateTimewriteyyyy-MM-ddTHH:mm:ss.fffZin the invariant culture, truncated to milliseconds.ISpanParsable<T>,IComparable<T>,IComparableand the comparison operators.DateTimeKind.Unspecifiedis treated as UTC, asAtProtoJsonDefaults.FormatTimestampalways documented.FormatTimestamp,NowTimestampandXrpcParamsnow all go throughAtDatetime.FromDateTimeOffset(DateTime)compiles through .NET's implicit conversion, which readsUnspecifiedas local time; its docs point toFromDateTimeinstead.AtProtoRecord.CreatedAtisAtDatetime?and is still set to the current time on construction.Pagination
ICursorPage<T>is implemented by every cursored response in these namespaces and byRecordPage<T>:ListRecordsResponse,ListMissingBlobsResponse,ListBlobsResponse,ListReposResponse,ListReposByCollectionResponse,ListHostsResponse,QueryLabelsResponseandGetInviteCodesResponse.Repo.EnumerateMissingBlobsAsync,Sync.EnumerateBlobsAsync,EnumerateReposAsync,EnumerateReposByCollectionAsync,EnumerateHostsAsync,Label.EnumerateLabelsAsyncandAdmin.EnumerateInviteCodesAsync.ListAllRecordsAsyncis renamedEnumerateRecordsAsync.RecordCollection<T>.EnumerateAsyncandEnumerateFromAsyncnow use the paginator.Typed identifiers (models and clients)
StrongRef.UriandCidare typed.Label,SrcandCidare typed andCtsandExpareAtDatetime.Label.Uristaysstring: its Lexicon format isuri, so the subject can be a DID or an AT URI.BlobLink.LinkandCidLink.LinkareCid.RecordCollection<T>,RecordRefandRecordView<T>: identifiers are typed within the existing shapes. #122 redesigns these types.DidisDid?,HandleisHandle?andLatestRepoRevisTid?.GetCollection<T>,QueryAsyncandProcedureAsynctake anNsid.AtUri,CidandDid.SetLabelersstaysstring: its entries can carry;redact.Session:DidandHandleare typed andrequired. Nothing else about the model changes; #124 redesigns it.com.atproto.*:datetimefield is typed.Tid? since,Nsid? lxmandCid? blob.RepoClient.GetRecordAsync(AtUri),GetRecordAsync<T>(AtUri)andDeleteRecordAsync(AtUri).GetInviteCodesResponse.Codesis typedInviteCodeinstead ofJsonElement.PdsAdminClient: typedDid,HandleandAtIdentifierparameters.CreatePdsAccountRequest.Handleis aHandleandCreatePdsAccountRequest.DidaDid?.Conventions applied
ListRecordsAsync,RecordCollection.ListAsyncandListFromAsynctakereversebeforelimitandcursor.ListMissingBlobsAsynctakeslimitbeforecursor.ModerationClient.CreateReportAsynctakes the subject first.CreateRecord,PutRecord,DeleteRecord,ApplyWrites.CreateSession,CreateAppPassword,RequestPasswordReset,ResetPassword,ConfirmEmail,CreateInviteCode,RevokeAppPassword,ReserveSigningKey.UpdateHandle.NotifyOfUpdate,RequestCrawl.AdminDeleteAccount,DisableAccountInvites,EnableAccountInvites,UpdateAccountEmail,UpdateAccountHandle,UpdateAccountPassword,DisableInviteCodes.CreateReport.ApplyWritesAsync,DisableInviteCodesAsync,GetAccountInfosAsync,QueryLabelsAsyncandPostAsynctakeIEnumerable<T>.Generator
atproto-lexgen csharp:did,handle,at-identifier,at-uri,nsid,cid,record-key,tidanddatetimemap to the SDK's types, withusing ATProtoNet.Identity;added only when needed.IReadOnlyList<T>.uri,languageand other formats staystring.atproto-lexgen lexicon: maps the identifier types andIReadOnlyList<T>back to their formats. Records built onAtProtoRecordstill emitcreatedAtas adatetime.Guard test
ATProtoNet,.Admin,.Auth,.Models,.Lexicon.*,.Spaces.*,.Streaming.*and.Server.Spaces.*.did,uri,cid,atas increatedAt,rev,by, and so on) and its type is astringor a string sequence.App.Bsky.*(PR 2);Chat.*,Tools.Ozone.*andSite.Standard.*(PR 3);Com.AtProto.Space*,SimpleSpace*,Spaces.*,Streaming.*,Server.Spaces.*, thesubscribeReposevent models inSyncModels.csandAuth.ServiceAuthGenerator(PR 4).Label.UriSendEmailRequest.SubjectInviteCode.ForAccountandCreatedBy, andAccountCodes.Account(the PDS writesadmin)DisableInviteCodesAsync(accounts)SetLabelers(labelerDids)Breaking changes and migration
Did.Parse("did:plc:…"),Nsid.Parse("com.example.todo.item"),RecordKey.Parse("self"),Cid.Parse("bafy…")andAtUri.Parse("at://…")wherever a string literal met a typed parameter.string.XrpcResponseFormatException.AtDatetime:AtDatetime.Now(),ParseorFromDateTimeOffset..Valueor.TryGetValue, and.ToString()returns the text.AtProtoRecord.CreatedAtisAtDatetime?.ListRecordsAsync,ListAsync/ListFromAsync,ListMissingBlobsAsyncandCreateReportAsyncno longer compile. Reorder them or pass the arguments by name.ListAllRecordsAsyncis nowEnumerateRecordsAsync.int? pageSize = null. PasspageSize: 100to keep the oldRecordCollectionpage size.ICursoredResponse→ICursorPage<T>, andList<T>→IReadOnlyList<T>: copy a list you need to mutate ([.. page.Records]).Session: setDidandHandlewith typed values. An unverified OAuth handle now reads ashandle.invalid.All of these are in
CHANGELOG.mdunderBreaking changes, withAddedentries (AtDatetime,ICursorPage/enumerators, the AT URI overloads) andFixedentries (the repeated-cursor loop,FormatTimestampwithUnspecified, and the auth handler's emptydidclaim).Verification
main).AtDatetimeTests:datetime_syntax_valid,datetime_syntax_invalidanddatetime_parse_invalidinterop fixtures: strict accept/reject, plus a byte-for-byte JSON round trip of all of them.Unspecified, UTC and localDateTimes.default, equality versus ordering, and theISpanParsablecontract.PaginationTests:EnumerateRecordsAsyncagainst a server that always returns the same cursor ends after 2 requests; before, it looped forever.EnumerateBlobsAsyncandRecordCollection.EnumerateAsyncare covered too.TypedRepoClientTests: typed request bodies and query strings, the AT URI overloads and their rejection of a URI without an rkey, an invalid identifier surfacing asXrpcResponseFormatException, and the reorderedCreateReportAsync.TypedIdentifierGuardTests: the guard, the staleness checks on both lists, and the name classifier.usingadded only when needed, and the reverse mapping from a typed record.handle.invalid. A verified handle that doesn't parse throws and leaves the client untouched.subis rejected before the network call.Deliberately deferred or flagged
subscribeReposevent models (FirehoseMessage,CommitEvent,RepoOp, … inLexicon/Com/AtProto/Sync/SyncModels.cs) are the models the firehose reads. Converting them pulls inFirehoseVerifier,TypedFirehoseConsumerandSpaceSyncer, so they stay on PR 4's pending list by type name.Auth.ServiceAuthGenerator(ServiceDid,lxm) is used almost only byServer.Spaces, so it is on PR 4's pending list.Paginationonce their responses implementICursorPage<T>.DidDocumentand the PLC models, includingGetRecommendedDidCredentialsResponseandSignPlcOperationRequest(#120).RecordRef/RecordViewredesign (#122).stringon purpose:PdsAdminOptions.AdminIdentifier(bound from configuration),GetServiceAuthAsync(aud)(a DID ordid#service),ReserveSigningKeyResponse.SigningKeyandCreateAccountRequest.RecoveryKey(did:keywith no Lexicon format).[Obsolete]forwarders for the old string signatures orListAllRecordsAsync. This is a clean break, per the owner's decision on #119.RecordReffor a URI without an rkey: it now throwsXrpcResponseFormatExceptioninstead of carrying a nullRecordKey.requiredon response models means a server that omits a Lexicon-required field now fails deserialization. Before, the field was silently empty.Lexicon/Site/Standard/StandardSiteClient.cs(PR 3) parses at itsRepoClientcall sites; its public signatures are unchanged.AtProtoClient's Bluesky helpers buildapp.bskyrecords (PR 2's models) unchanged.StandardSite*Tests,UnknownFieldTests,GalleryEmbedTestsandAtProtoClientUpdateProfileTestsnow uses valid CIDs and TIDs.Review follow-up
ApplyOAuthSessionAsyncguessed handle verification from whether the value parsed. It now uses the authoritativeOAuthSessionResult.IsHandleVerified, asAtProtoOAuthServicedoes: verified → the handle, unverified →handle.invalid. A verified handle that doesn't parse is anArgumentExceptioninstead of a silent downgrade.handle.invalid, the DID fallback also becomeshandle.invalid, and a verified-but-invalid handle throws with the client untouched.AtProtoClientFactoryTestsnow setsIsHandleVerified = trueon its token data, because it asserts the real handle.🤖 Generated with Claude Code
Part 1 of 4 of the typed public API. Lays the foundation the later parts build on and converts the com.atproto surface: - AtDatetime, the Lexicon datetime: keeps its text so records re-serialize byte for byte (CID-stable), reads leniently, is strict when created from code, and is checked against the atproto-interop-tests fixtures. FormatTimestamp now treats DateTimeKind.Unspecified as UTC, as documented. - ICursorPage<T> on every cursored com.atproto response, and one internal paginator that stops on a null, empty or repeated cursor; a server that repeated its cursor used to drive an endless request loop. ListAll* is Enumerate*, with int? pageSize = null, and the missing enumerators exist. - Typed Did/Handle/AtIdentifier/AtUri/Nsid/Cid/RecordKey/Tid/AtDatetime in CommonModels, RecordCollection, the AtProtoClient facade, Session, PdsAdminClient and Lexicon/Com/AtProto/{Repo,Server,Identity,Sync,Admin, Label,Moderation}; one parameter order; IReadOnlyList<T> outputs and IEnumerable<T> inputs; single-use request DTOs internal. - atproto-lexgen maps string formats to the typed identifiers and arrays to IReadOnlyList<T>. - A reflection guard test fails on any identifier-carrying public member typed as string, with a temporary allow-list for the areas parts 2-4 convert, and CONTRIBUTING.md records the conventions they follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>@claude /review — the diff is large and mostly mechanical. Please read these files in full from the branch, since the non-mechanical logic lives there:
AtDatetime(new type and its JSON converter), the internal paginator plusICursorPage<T>, the reflection guard test,AtProtoJsonDefaults.FormatTimestamp(Unspecified-kind handling), and the LexiconGeneratorTypeMapperchanges. The description has a How to review this section.🤖 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: comment — notes only, nothing blocking.
This is a large, carefully-executed mechanical conversion, and the non-mechanical pieces called out in the description hold up under review:
AtDatetime's strict/lenient split, calendar math, and instant/text ordering are internally consistent (verified the day-counting formula and theYear1Ticksoffset by hand);Pagination.EnumerateAsynccorrectly stops on null/empty/repeated cursors including cycles; theAtDatetimeJsonConverternever rejects a string on read and refusesdefaulton write; theTypedIdentifierGuardTestsheuristic, exception list and pending-list staleness checks are sound; the generator'sTypeMapper/LexiconEmitterround-trip identifier formats correctly via reflection; and theAtProtoAuthenticationHandlerchange correctly threads the token'ssubthrough as the DID rather than trusting a stale placeholder.One real gap:
AtProtoClient.ApplyOAuthSessionAsyncderives handle-verification status by trying to parse the handle string, instead of using theIsHandleVerifiedflag the OAuth client already computed for exactly this purpose (see finding). Everything else — parameter reorders, internalized DTOs,IReadOnlyList<T>/IEnumerable<T>conversions, theICursorPage<T>wiring, the AT URI overloads and theirArgumentExceptionon a recordless URI, the doc/sample updates — is mechanical and consistent with the stated conventions.⏱ 2m 38s · 25 turns · tokens: 133.3k in / 12.4k out (+3084.6k cache-read)
📜 Full session transcript
@ -734,3 +752,2 @@{Did = oauthSession.Did,Handle = oauthSession.Handle,Did = Did.Parse(oauthSession.Did),ApplyOAuthSessionAsyncinfers whether the OAuth handle verified by trying to parseoauthSession.Handleas aHandle, instead of checking the explicitoauthSession.IsHandleVerifiedflag the OAuth client already computed for this exact purpose.OAuthSessionResult.IsHandleVerifiedis documented for this (OAuthClient.cs:793), and the sibling code path insrc/ATProtoNet.Blazor/Authentication/AtProtoOAuthService.cs:379does it the direct way:result.IsHandleVerified ? result.Handle : "handle.invalid". Here it'sHandle.TryParse(oauthSession.Handle, out var handle) ? handle : InvalidHandle— this only works today because the unverified fallback value happens to always be the DID string (which can't parse as aHandle). It silently diverges from the authoritative signal: ifIsHandleVerifiedisfalsebutHandleisn't literally the DID (a futureOAuthClientchange, a different placeholder shape), or a verified handle for some reason failsHandle.TryParse, this produces the wrong result, and the one new test for this (AtProtoClientApplyOAuthTests.ApplyOAuthSessionAsync_UnverifiedHandleFallingBackToTheDid_IsHandleInvalid) only covers the DID-shaped fallback case, not the flag itself. PreferoauthSession.IsHandleVerified ? Handle.Parse(oauthSession.Handle) : InvalidHandle.🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
✅ Verdict: approve — no blocking issues found.
Good to merge. The only non-mechanical logic is sound:
AtDatetime— I tracedTryCreateagainst every line of the vendoreddatetime_syntax_valid/syntax_invalid/parse_invalidfixtures (length/character gates,-00:00rejection,ticks < 0for negative years, leap-year arithmetic including year 0, fraction-digit padding, offsets to ±23:59).TryGetValue's year-0000 and >±14h-offset normalization is correct and overflow-safe.FromWire'sdddd-prefix check plusAssumeUniversalkeeps culture-shaped junk out while recovering near-miss ISO 8601, as designed. The converter's never-reject-read / throw-on-default-write split matches the docs, andXrpcParams'sDateTime/DateTimeOffsetrewrites are behavior-identical to the old code (Unspecified → UTC).Pagination— the single cursor loop is right: repeated cursor, cycle and empty-cursor all terminate (with HTTP-level tests proving the old infinite loop is gone), and items are yielded before the stop check.subto be a DID before the PDS round trip, and seeding the placeholder session with it, fixes the empty-did-claim issue without weakening the delegated validation.ExceptionsandPendingConversionswork as described, and the permanent exceptions each carry a reason.Repo/Sync/Admin/Identity/Label/Moderationmodels and clients,RecordCollection,RecordRef/RecordPage,PdsAdminClient,Session, the facade's Bluesky helpers andstatic readonlyNSIDs, implicit→ string?conversions being null-safe for optional parameters,Tid/Cidquery-param binding) — all consistent.Two minor nits, non-blocking:
ListRecordsResponse.RecordsandListMissingBlobsResponse.BlobsdeclareIReadOnlyList<…> { get; init; } = []rather thanrequired, unlike their siblings (ListBlobsResponse.Cids,ListReposResponse.Repos,GetInviteCodesResponse.Codes). A server omitting the Lexicon-required list now yields an empty page (and, forlistRecords, an empty walk) instead of failing deserialization — slightly at odds with the "stricter responses" note in the changelog and the CONTRIBUTING rule. Worth aligning while the surface is being touched anyway.LabelClient.EnumerateLabelsAsynccallsuriPatterns.ToList()before the null-tolerantAddAll, so a null sequence would surface as NRE instead ofArgumentNullException/silently-empty. The parameter is non-nullable by annotation, so this is cosmetic at most.⏱ 8m 47s · 42 steps · $0.1711 · tokens: 315.8k in / 25.4k out (+3701.5k cache-read) · Go: 5h 7% · wk 98% · mo 82%
📜 Full session transcript
Addressed the review finding in
974c93b:ApplyOAuthSessionAsyncnow decides the handle byoauthSession.IsHandleVerified(verified → parsed handle; unverified →handle.invalid). A verified-but-unparseable handle throws before any state changes. New tests cover verified, unverified non-DID-shaped, unverified DID fallback, and verified-unparseable (client left untouched).@claude /review
a0159fccec974c93b958🤖 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.
Re-review: the one blocking issue from my previous pass is fixed correctly.
ApplyOAuthSessionAsyncnow derives the typedHandledirectly fromOAuthSessionResult.IsHandleVerifiedrather than inferring verification by trying to parse the string (src/ATProtoNet/AtProtoClient.cs): unverified →handle.invalid, verified →Handle.Parse, and a verified-but-unparseable handle now throwsArgumentExceptionbefore any client state is touched (did/handleare computed before_refreshLockis even acquired). This matches the sibling path inAtProtoOAuthService.csand the PR's stated intent, and is exercised by new cases inAtProtoClientApplyOAuthTests.cs(verified kept, unverified well-formed handle → invalid, DID-shaped fallback → invalid, verified-but-invalid throws) plusAtProtoClientFactoryTests.cssettingIsHandleVerified = truefor its typed-handle assertion.I also spot-checked the surrounding non-mechanical pieces referenced in the PR description for consistency with the current branch state:
RecordRef.From(nsid, uri, cid)inRecordCollection.csmatches its call site inAtProtoClient.UpdateProfileAsync, andAtProtoAuthenticationHandler'sTryPreValidateJwtcorrectly threads the token'ssubthrough as the DID (validated as aDidbefore use) with ahandle.invalidplaceholder, rejecting tokens whosesubisn't a DID before any PDS round-trip. Nothing new here raises a concern.No other issues found. Everything else in this large mechanical conversion (typed identifiers,
AtDatetime, the pagination loop,ICursorPage<T>, internalized DTOs, parameter reorders) was already reviewed and holds up; this pass only needed to confirm the follow-up fix, which it does cleanly. Approving.⏱ 0m 44s · 16 turns · tokens: 98.7k in / 3.8k out (+1672.6k cache-read)
📜 Full session transcript
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
✅ Verdict: approve — no blocking issues found.
Good to merge. The blocker from the previous round is resolved:
ApplyOAuthSessionAsyncnow uses the authoritativeOAuthSessionResult.IsHandleVerifiedinstead of inferring verification by parsing the handle string. Unverified →handle.invalid, verified → the parsed handle, verified-but-unparseable →ArgumentExceptionthrown before the refresh lock and before any client state is touched. This matches the sibling path inAtProtoOAuthServiceand is exercised by the four new cases inAtProtoClientApplyOAuthTests(verified kept, unverified well-formed handle → invalid, DID fallback → invalid, verified-but-invalid throws and leaves the client untouched), withAtProtoClientFactoryTestsupdated to setIsHandleVerified = true. No regressions found in the areas reviewed previously (AtDatetime, the pagination loop, the converter,ICursorPage<T>wiring, the guard test).Two minor, non-blocking notes carried over from my earlier pass, both cosmetic:
ListRecordsResponse.RecordsandListMissingBlobsResponse.BlobsdeclareIReadOnlyList<…> { get; init; } = []rather thanrequired, unlike their siblings (ListBlobsResponse.Cids,GetInviteCodesResponse.Codes), so a server omitting the Lexicon-required list yields an empty page instead of failing deserialization. Worth aligning in a later pass.LabelClient.EnumerateLabelsAsynccallsuriPatterns.ToList()before the null-tolerantAddAll, so a null sequence surfaces as NRE rather thanArgumentNullException; the parameter is non-nullable by annotation, so this is cosmetic at most.⏱ 1m 18s · 14 steps · $0.0384 · tokens: 108.1k in / 3.9k out (+673.7k cache-read) · Go: 5h 7% · wk 98% · mo 82%
📜 Full session transcript