• v0.7.0 861106cb88

    v0.7.0
    All checks were successful
    CI / pds-integration (push) Successful in 32s
    CI / build-and-test (push) Successful in 1m0s
    Release / release (push) Successful in 1m7s
    Stable

    Grandiras released this 2026-09-28 14:05:19 +00:00 | 15 commits to main since this release

    A large, deliberately breaking release. Identifiers are typed everywhere; sessions, session stores and errors each have one model; Lexicon unions are open and keep data the SDK does not model; the stream consumers share one core with Sync 1.1 verification; the OAuth client is hardened and its hosted login moved into ATProtoNet.Server; and inbound service auth replaces the bearer handler. The SDK also catches up with the protocol: the Spaces read/write split, group chats, Ozone reports and queues, label signing, Lexicon resolution and the current app.bsky surface.

    Upgrading from 0.6? Start with the migration guide (docs/migrating-to-0.7.md), which walks through every breaking change below with before-and-after code.

    Breaking changes

    • ATProtoNet no longer depends on Microsoft.Extensions.Http or Microsoft.Extensions.Options — ASP.NET Core apps are unaffected. Migration: an app outside ASP.NET Core that used IHttpClientFactory, IOptions<T> or LoggerFactory only through the SDK references those packages itself (#110)
    • The EF Core stores are their own package again: ATProtoNet.Server.EntityFrameworkCore — the session, replay, space and Sync 1.1 stores leave ATProtoNet.Server; namespace, types and tables are unchanged. Migration: reference the package wherever one of them is used (#136)
    • The OAuth cookie login moves from ATProtoNet.Blazor to ATProtoNet.Server — so any ASP.NET Core app can use it; the Blazor package still brings it along. Migration: using ATProtoNet.Server.Authentication; for the ATProtoNet.Blazor usings, and AddAtProto().WithOAuth() (#136)
    • AddAtProto() returns an IAtProtoBuilder — WithLifetime, WithOAuth, WithClientFactory, With…SessionStore and WithHealthCheck replace AddAtProtoScoped, AddAtProtoAuthentication, AddAtProtoServer, AddAtProto<TStore> and AddAtProtoEfCoreTokenStore. Migration: follow the guide's table (#136)
    • The client factory's default session store is in memory, with a startup warning — AddAtProtoServer() wrote tokens to disk unasked. Migration: WithFileSessionStore() for the old behaviour, WithEfCoreSessionStore<TContext>(), WithSessionStore<T>(), or WithInMemorySessionStore() (#136)
    • One named HttpClient per registration — every AtProtoClient of AddAtProto() sends with "ATProtoNet", exposed as IAtProtoBuilder.HttpClient, and ServiceCollectionExtensions is AtProtoServiceCollectionExtensions. Migration: customize AddAtProto().HttpClient (#136)
    • Every options class goes through IOptions<T> and is validated at startup — a bad value fails StartAsync with an OptionsValidationException, code callbacks run after configuration binding, and every AddAtProtoIdentity / AddAtProtoSpaces callback applies. Migration: catch that exception (#136)
    • services.AddAtProtoPdsAdmin(PdsAdminOptions) is removed — it duplicated the other overloads. Migration: services.AddAtProtoPdsAdmin(url, adminPassword), plus services.Configure<PdsAdminOptions>(…) for the other settings (#180)
    • Aspire: AddAtProtoClient returns the IAtProtoBuilder, and AtProtoClientSettings is gone — its keys bind to AtProtoClientOptions, and DisableResilience has nothing left to disable. Migration: builder.AddAtProtoClient(configure: o => …) for configureSettings: s => … (#136)
    • Aspire hosting: the settings both PDS servers share are generic — one AtProtoPdsHostingExtensions method each (WithHostname, …), on AtProtoPdsContainerResourceBase, replaces the per-server overloads. Migration: recompile; write an explicit static call as pds.WithHostname(…) (#116, #180)
    • AtProtoClientBuilder is removed: one constructor builds the client — new AtProtoClient(options, httpClient, sessionStore, logger), every argument optional. Migration: With… settings become options or constructor arguments; WithLoggerFactory(f) is logger: f.CreateLogger<AtProtoClient>() (#122)
    • AtProtoClient.PdsUrl / SetPdsUrl(string) are ServiceUrl / SetServiceUrl(Uri) — the client holds its service URL instead of writing HttpClient.BaseAddress. Migration: client.SetServiceUrl(new Uri(url)); ServiceUrl is a Uri with a trailing slash (#117)
    • A supplied HttpClient's BaseAddress is ignored — AtProtoClient starts at AtProtoClientOptions.InstanceUrl, and PdsAdminClient sends to PdsAdminOptions.Url. Migration: put the service URL in the options (#117)
    • XrpcClient is internal — MaxRateLimitRetries moves to AtProtoClientOptions.RateLimit.MaxRetries, and BaseUrl, IsAuthenticated, UpdateDPoPNonce and the body-less QueryAsync are gone. Migration: call custom methods through client.QueryAsync / ProcedureAsync or client.Transport (#117)
    • SetLabelers is one params IEnumerable<string> method — Migration: calls compile unchanged; recompile (#122)
    • AtProtoClientOptions.OAuth and OAuthOptions.DefaultPdsUrl are removed — nothing read either. Migration: delete the assignments; OAuth is configured on the OAuthClient (#132)
    • Identifier parsing is strict — Parse throws and TryParse returns false for every value the specs and the atproto-interop-tests fixtures reject, including any Cid but a base32 CIDv1 (DAG-CBOR or raw, SHA-256), and JSON reads fail alike. Migration: read legacy data with TryParse (#114)
    • Identifier types are sealed records — Did, Handle, AtIdentifier, Nsid, Tid, RecordKey, Cid, AtUri, SpaceUri and SpaceRecordUri keep their source shape, so this is a binary break only. Migration: recompile (#114)
    • AtUri components are typed — Collection is Nsid?, RecordKey is RecordKey?, and AtUri.Create takes (AtIdentifier, Nsid?, RecordKey?). Migration: AtUri.Create(repo, Nsid.Parse(collection), RecordKey.Parse(rkey)); uri.Collection?.Value where a string was used (#114)
    • Typed identifiers across com.atproto.*, the client, RecordCollection<T> and PdsAdminClient — Did, Handle, AtUri, Nsid, Cid, RecordKey, Tid and the rest wherever the Lexicon says so. Migration: pass returned values through; parse literals where they enter your code (#119)
    • Typed identifiers in app.bsky.* — its models and clients use the identifier types and AtDatetime, and RichTextBuilder.Mention takes (Handle, Did). Migration: pass returned values through, and parse literals (AtIdentifier.Parse("alice.bsky.social")) (#119)
    • Typed identifiers in chat.bsky.*, tools.ozone.* and site.standard.* — StandardSiteClient also takes an AtIdentifier and a RecordKey, and its listings return a typed RecordPage<T>. Migration: parse literals; read entry.Value directly instead of deserializing it (#119)
    • Typed identifiers across Spaces and the space server — a space is a SpaceUri, a record in one a SpaceRecordUri, a participant a Did; the string overloads and ToSpaceUri() / ToRecordUri() are gone. Migration: read .Uri; parse literals once (options.ServiceDid = Did.Parse(…)) (#119)
    • Typed identifiers in the firehose and Jetstream models — events carry Did, Cid, Tid, Nsid, RecordKey and AtDatetime, RepoOp.Action is the RepoOpAction enum, and RKey is renamed Rkey. Migration: compare with RepoOpAction.Create, not "create"; WantedDids = [Did.Parse(…)] (#119)
    • Lexicon datetime fields are AtDatetime — AtProtoRecord.CreatedAt and every other datetime field of the typed models. Migration: set with AtDatetime.Now(), Parse or FromDateTimeOffset; read .Value, TryGetValue or ToString() (#119)
    • AtProtoJsonDefaults.FormatTimestamp and NowTimestamp are removed — AtDatetime writes the same text. Migration: AtDatetime.FromDateTime(value).ToString() and AtDatetime.Now().ToString() (#180)
    • Parameters follow one order: subject, required inputs, filters, limit, cursor — ListRecordsAsync, RecordCollection<T>.ListAsync, CreateReportAsync, GetTimelineAsync, ListNotificationsAsync, the Spaces listings and others. Migration: reorder positional calls, or name the arguments (#119)
    • New Lexicon parameters shift positional arguments — SearchPostsAsync takes tags for tag, and GetFollowersAsync, GetListsAsync, MuteActorAsync, ListConvosAsync, LoginAsync and others gain inputs before paging or the token. Migration: name the arguments after the first (#123, #124, #128)
    • ListAllRecordsAsync is EnumerateRecordsAsync, and enumerators take int? pageSize = null — the server's default, where RecordCollection<T> used 100; ListAllSegmentsAsync is EnumerateSegmentsAsync. Migration: rename; pass pageSize: 100 to keep the old size (#119)
    • Cursored responses derive from CursorPage<T>, replacing ICursoredResponse — sealed records with a public Items (ICursorPage<T>); model lists are IReadOnlyList<T>, and GetListResponse.Items is Members. Migration: copy a list you need to change ([.. page.Records]) (#119, #180)
    • Single-use request bodies are internal — request types only one method built (CreateRecordRequest, CreateSessionRequest, SendMessageRequest and many more), and the never-sent DeleteChatAccountRequest is removed. Migration: call the method, which takes their fields as parameters (#119)
    • ProfileViewBasic ⊂ ProfileView ⊂ ProfileViewDetailed, and GetSessionResponse ⊂ SessionResponse, by inheritance — so is ProfileView also matches a ProfileViewDetailed. Migration: recompile, and put the most specific type first in a switch or chained is (#180)
    • atproto-lexgen csharp emits typed identifiers and IReadOnlyList<T> — Lexicon string formats become the SDK's identifier types and AtDatetime, space declarations included, and lexicon maps them back. Migration: regenerate, then parse literals (#119, #141)
    • One session model: AtProtoSession, with PasswordSession and OAuthSession — immutable records replacing Session, OAuthSessionResult and AtProtoTokenData, with a typed, required Did and Handle; client.OAuthSession is gone. Migration: pattern-match client.Session (#119, #124)
    • ApplyOAuthSessionAsync is replaced by ApplySessionAsync(session, oauthClient) — it installs either kind of session and persists to the client's own store; ResumeSessionAsync takes an AtProtoSession. Migration: ApplySessionAsync(session, oauth) on a client constructed with the store (#124)
    • CompleteAuthorizationAsync returns an OAuthAuthorizationResult, and RefreshTokensAsync is RefreshAsync(session) — the result carries the OAuthSession and AppState, and owns nothing disposable. Migration: var (session, _) = await oauth.CompleteAuthorizationAsync(…) without using (#124, #180)
    • One persistence interface: IAtProtoSessionStore — it replaces ISessionStore and IAtProtoTokenStore, and the stores are InMemoryAtProtoSessionStore, FileAtProtoSessionStore and EfCoreAtProtoSessionStore<T>, which read 0.6 data. Migration: rename; a custom store implements the new members (#124)
    • ServerClient no longer changes the client's session — CreateSessionAsync / CreateAccountAsync return tokens without installing them, and RefreshSessionAsync / DeleteSessionAsync take the refresh JWT. Migration: client.LoginAsync, CreateAccountAndLoginAsync, LogoutAsync (#124)
    • AutoRefreshSession refreshes on demand, and the refresh timer is opt-in — before a request whose token is about to expire and once after a rejection; BackgroundRefresh adds the old timer. Migration: drop a setting that only turned the timer off, since that now disables refreshing (#124)
    • LogoutAsync throws when the service-side sign-out fails — after removing the session locally, and for an OAuth session installed without its OAuthClient. Migration: catch XrpcException / OAuthException for a best-effort sign-out (#124)
    • AtProtoOAuthServerOptions.ClaimsFactory takes an OAuthSession — Migration: read session.Did.Value and session.Handle.Value where result.Did and result.Handle were read (#124)
    • One XRPC exception: XrpcException replaces AtProtoHttpException — it derives from AtProtoException, not HttpRequestException; Error (was ErrorType) is never null; 429 and 401 have their own subtypes. Migration: catch (XrpcException ex) when (ex.Is(XrpcErrors.X)) (#117)
    • Hosted endpoints throw the core ATProtoNet.Http.XrpcException — ATProtoNet.Server.Xrpc.XrpcException is gone, and its status (like SpaceVerificationException's) is an HttpStatusCode. Migration: using ATProtoNet.Http;, and HttpStatusCode.NotFound for StatusCodes.Status404NotFound (#117)
    • Every SDK exception derives from AtProtoException, and OAuthException.ErrorCode is Error — one catch (AtProtoException) covers every SDK failure. Migration: recompile, and read ex.Error (#117)
    • A response that does not match its type throws XrpcResponseFormatException — instead of a raw JsonException or InvalidOperationException, from every XRPC call and typed record read. Migration: catch XrpcResponseFormatException (#117)
    • Calls that need a session throw XrpcAuthenticationException without one — with AuthenticationRequired and before sending, instead of InvalidOperationException. Migration: catch (XrpcAuthenticationException ex) when (ex.Is(XrpcErrors.AuthenticationRequired)) (#122)
    • One RecordRef and one RecordView<T> for every write and typed read — sealed records built by constructor; CreateRecordResponse, PutRecordResponse and GetRecordResponse leave the public API. Migration: declare RecordRef where those were used, and use the constructors (#122)
    • AtProtoRecord.CreatedAt is no longer set on construction — RecordCollection<T>.CreateAsync stamps it when unset, so a record read without createdAt is written back without one. Migration: set CreatedAt yourself before PutAsync or a RepoClient write (#122)
    • The Bluesky helpers move to client.Bsky and take typed subjects — PostAsync(RichText, PostOptions?), LikeAsync(StrongRef), RepostAsync and FollowAsync(Did) return a RecordRef; DeleteRecordAsync(AtUri) replaces the undo methods. Migration: client.Bsky.LikeAsync(posted.ToStrongRef()) (#122)
    • UpdateProfileAsync moves to client.Bsky and takes an edit callback — UpdateProfileAsync(Action<ProfileRecord>) replaces the four optional parameters and keeps every field you do not touch. Migration: client.Bsky.UpdateProfileAsync(p => p.DisplayName = "Alice") (#115, #122)
    • RichTextBuilder.Build() returns RichText — instead of a tuple, with empty rather than null Facets. Migration: it still deconstructs; test facets.Count instead of null (#122)
    • Embed.RecordView is renamed RecordEmbedView — Migration: post.Embed is RecordEmbedView quote (#122)
    • CreatePdsAccountRequest is removed — PdsAdminClient.CreateAccountAsync takes the Lexicon's CreateAccountRequest. Migration: new CreateAccountRequest { … } (ATProtoNet.Lexicon.Com.AtProto.Server) with the same properties (#122)
    • BlobLink is removed: BlobRef.Ref is a CidLink — Migration: CidLink.FromCid(cid) (#122)
    • Custom XRPC takes XrpcParams, typed procedure inputs and XrpcCallOptions — QueryAsync<TOut>(nsid, XrpcParams?, …) and ProcedureAsync<TIn, TOut>(nsid, input, …) replace anonymous-object parameters and ProcedureAsync<T>. Migration: new XrpcParams { { "limit", 25 } }; name options: (#117, #122, #180)
    • Blob, repository and chat-export downloads return XrpcStreamResponse — the stream with its ContentType and ContentLength; disposing it releases the response. Migration: await using var blob = await client.Sync.GetBlobAsync(did, cid); and read blob.Content (#117, #123)
    • XRPC endpoints declare their NSID once, as a static Nsid — IXrpcEndpoint.Nsid is static abstract, and [XrpcEndpoint] is removed. Migration: public static Nsid Nsid { get; } = Nsid.Parse("…"); in place of the attribute and the instance property (#121)
    • AddXrpcEndpointsFromAssembly registers every class implementing IXrpcEndpoint — and registration rejects a handler with no or several endpoint interfaces, or a second one for an NSID. Migration: keep handlers that must not be mapped out of scanned assemblies (#121)
    • MapXrpcEndpoints() returns the /xrpc RouteGroupBuilder — so .RequireAuthorization() and the like apply to every endpoint. Migration: make a Map… call chained onto its result on the app instead, or it maps under /xrpc (#121)
    • The SDK's Lexicon unions are open, with an Unknown* variant — a $type the SDK does not model reads as UnknownEmbed, UnknownFacetFeature and so on instead of throwing. Migration: add an Unknown* arm, or a discard, to every switch over a union; the compiler does not flag a missing one (#115)
    • Lexicon models derive from LexObject — as does AtProtoRecord, whose ExtensionData keeps fields a model does not declare. Migration: recompile; a record type with its own [JsonExtensionData] member drops it (#115)
    • AtProtoJsonDefaults.Options is read-only — Migration: register union variants on LexiconTypeRegistry.Instance, or copy the options with new JsonSerializerOptions(AtProtoJsonDefaults.Options) (#115)
    • LexiconTypeRegistry is reduced to union variants — the record-type registry, CreateOptions(), LoadPluginsFromAssembly, [LexiconPlugin], the constructor and IAtProtoUnion are gone; closed unions refuse variants. Migration: LexiconTypeRegistry.Instance, LoadPlugin<T>(), [AtProtoUnion(…)] (#115, #133)
    • Lexicon unions that were JsonElement are typed open unions — FeedViewPost.Reason, ThreadgateRecord.Allow, PostgateRecord.EmbeddingRules, RecordEmbedView.Record, the relationships and the preferences. Migration: match the variant types; an unknown one's Raw is the old JsonElement (#123)
    • Chat messages, log entries and message embeds are typed unions — GetMessagesResponse.Messages and ConvoView.LastMessage are ConvoMessages, and ConvoLogEntry is the base of one type per event. Migration: pattern-match (msg is MessageView m) instead of reading $type (#128)
    • Lexicon references that were JsonElement are typed — PostView.Threadgate, BlockedPost.Author, ViewerState.MutedByList, StarterPackView.Feeds, LabelerView.Creator, TeamMember.Profile and others; TeamMemberProfile is removed. Migration: read the typed properties (#123)
    • PostView writes its $type — it derives from the new PostEntry union base, as GeneratorView and the other union members already did. Migration: none for reading; expect the $type in JSON you write from a PostView (#127)
    • ProfileViewDetailed.AssociatedChat is replaced by Associated — associatedChat was never on the wire, so it was always null. Migration: read profile.Associated?.Chat?.AllowIncoming (#123)
    • Chat names follow the Lexicon — CanConvo is CanChat, ListConvosAsync takes readState for readOnly, ConvoView.Opened and AcceptConvoResponse.Convo are removed, and UpdateAllReadAsync returns an UpdateAllReadResponse. Migration: CanChat, readState: "unread" (#123)
    • Ozone's review queue is QueryStatusesAsync — QuerySubjectsAsync and QuerySubjectsResponse called a method that never existed. Migration: QueryStatusesAsync(new SubjectStatusFilter { … }), and read SubjectStatuses (#123)
    • SignatureClient.SearchAccountsAsync takes signature values and returns account views — values is IEnumerable<string>, results are AccountInfo, and AccountResult is removed. Migration: pass the SigDetail.Values (#123)
    • Notification methods drop priority and seenAt — ListNotificationsAsync takes reasons instead, and GetUnreadCountAsync drops priority; upstream ignores one and rejects the other. Migration: remove the arguments (#123)
    • AtProtoScopes.PermissionSets holds only published permission sets — six constants naming unpublished sets are removed, and ManageNotifications is app.bsky.authManageNotifications. Migration: DeleteContent, ManageNotifications, ManageModeration or FullApp; recompile (#123)
    • Ozone models match their Lexicons — RecordViewDetail.BlobCids is Blobs, the label and tag event lists and ModEventReport.ReportType are required, and ModEventMuteReporter.DurationInHours is int?. Migration: read Blobs[i].Cid; set the lists, empty if unchanged (#123, #131)
    • One moderation-subject union, ModerationSubject — in ATProtoNet.Lexicon.Com.AtProto.Moderation, replacing ReportSubject and the Ozone copy and typing report and status subjects; RecordSubject.Cid is required. Migration: import the namespace; ModerationSubject for ReportSubject (#122)
    • VideoClient.UploadVideoAsync uploads in parts and waits for processing — it returns the completed JobStatus, throws VideoUploadException for a failed job, and takes VideoUploadOptions? before the token. Migration: drop your polling loop, or call UploadVideoInOneRequestAsync (#127)
    • One immutable DidDocument — ATProtoNet.Auth.OAuth.DidDocument and DidService are removed; Identity.DidDocument is init-only, its entries are DidDocumentServices, and GetHandle() / GetPdsEndpoint() return a Handle? / Uri?. Migration: object initializers; GetPdsEndpoint()?.OriginalString (#120)
    • DidDocument.GetSigningKey / GetVerificationKey return null for key material that does not decode — instead of throwing FormatException, as does SpaceAuthority.GetSigningKey's fallback. Migration: TryGetVerificationKey tells a malformed entry from an absent one (#180)
    • DidResolutionException replaces PlcException and DidWebException — every resolver throws it, network failures included, with a DidResolutionErrorKind (Tombstoned is Deactivated). Migration: catch (DidResolutionException ex) when (ex.Kind == DidResolutionErrorKind.NotFound) (#120)
    • DID resolvers implement IDidResolver — ResolveAsync(Did) replaces ResolveDidAsync(string), and SpaceCredentialProvider takes an IDidResolver. Migration: await resolver.ResolveAsync(Did.Parse(did)); pass a CachingDidResolver (#120)
    • PlcClient takes an explicit directory URL and typed arguments — it no longer reads BaseAddress, methods take a Did, PlcOperation replaces PlcData, and PlcOperationBuilder is typed too. Migration: new PlcClient(httpClient, new Uri("https://plc.directory")) (#120)
    • AuthorizationServerDiscovery and OAuthClient.Discovery are internal — OAuthClient walks the discovery chain itself, with the resolver OAuthOptions.IdentityResolver supplies. Migration: resolve an identity with an IIdentityResolver (ResolveAsync(AtIdentifier.Parse(handle))) (#120, #180)
    • OAuthClient takes its HttpClient from the options, and follows the identity fetch policy — OAuthClient(options, logger); without OAuthOptions.HttpClient, requests go to public HTTPS addresses only and fail as OAuthException. Migration: AllowPrivateNetworks for a local PDS (#120, #132)
    • StartAuthorizationAsync returns an OAuthAuthorizationRequest and takes OAuthAuthorizationOptions — AuthorizationUrl (a Uri), State and ExpiresAt instead of a tuple, and pdsUrl is ServerUrl. Migration: var (url, state, _) = …; redirect to url.AbsoluteUri (#132)
    • OAuthClient.GetPendingAuthorizationState and OAuthAuthorizationState are removed — they exported the PKCE verifier without the DPoP key. Migration: keep pending logins across instances with OAuthOptions.StateStore (#132)
    • AtProtoScopes.Rpc and Include refuse an audience that is not a service — aud must be a DID with a service fragment, or * for rpc. Migration: append the fragment, for example with AtProtoScopes.BlueskyAppView (#132)
    • IdentityAction and AtProtoScopes.Identity(attr, action) are removed — the permission spec dropped the action parameter. Migration: AtProtoScopes.Identity(attr) (#132, #180)
    • AtProtoOAuthService's constructor is internal — WithOAuth() builds the service from the container's identity resolver, state store, server and refresh coordinator. Migration: resolve it from dependency injection instead of constructing it (#120, #132, #136, #180)
    • The hosted login's HTTP and identity settings move to the shared registrations — AtProtoOAuthServerOptions.HttpClient, HttpClientTimeout and HandleResolutionTimeout are removed. Migration: AddAtProtoIdentity(o => …) for the timeout; configure the client AtProtoOAuthExtensions.HttpClientName (#180)
    • IOAuthClientProvider is removed; the client factory takes the OAuthClient — WithOAuth() registers the login's client as the OAuthClient singleton, and TryGetClient() is the Client property. Migration: with your own OAuth flow, register your OAuthClient as a singleton (#136)
    • AtProtoOAuthService.CompleteCallbackAsync returns an AtProtoOAuthCallbackResult — RedirectUrl, IsRelay and Did, instead of a string the caller had to vet. Migration: redirect to result.RedirectUrl (#136)
    • The client factory, the Blazor widgets and sign-out act only for the OAuth login's user — they ignore identities the login did not issue, service auth's included. Migration: a principal you build for the factory needs AtProtoClaimTypes.AuthMethod set to oauth (#136)
    • The development loopback client takes its callback from the server's address — the plain HTTP loopback address fixed at startup, not the first request; without one the login fails. Migration: bind an address such as http://127.0.0.1:5000, or set BaseUrl (#136)
    • The Blazor components act as the signed-in user, and FeedView.FeedSource is a FeedSource — through AtProtoUserClientAccessor, registered by AddAtProtoBlazor(). Migration: call AddAtProtoBlazor(); FeedSource.Author(AtIdentifier.Parse("alice.bsky.social")) for "author:alice.bsky.social" (#136)
    • The bearer-JWT AtProtoAuthenticationHandler is removed — with AtProtoAuthenticationOptions and AuthenticationBuilder.AddAtProto(). Migration: authenticate other services with AddAtProtoServiceAuth(…) and [RequireServiceAuth], and users with the OAuth cookie login (#136)
    • ServiceAuthGenerator takes a Did, and CreateToken requires an Nsid lxm — per the 2026 service auth revision; audience must be a DID with an optional service fragment. Migration: new ServiceAuthGenerator(Did.Parse(…), key); pass the NSID of the method called (#119, #125)
    • ISpaceReplayStore is generalized into IJtiReplayStore — InMemoryJtiReplayStore, EfCoreJtiReplayStore<T> and JtiReplayEntity on table AtProtoJtiReplay replace the Space…Replay… names. Migration: rename, and add an EF migration for the renamed table; dropping the old one loses nothing (#125)
    • VerifiedServiceAuth moves to ATProtoNet.Server.Authentication — a class with init-only properties instead of a positional record, with the token's lxm as Method and its KeyId, TokenId, IssuedAt and ExpiresAt. Migration: read properties instead of deconstructing (#125)
    • notifyWrite must be signed by its writer, and more strictly — iss must be the account whose repo advanced, and a token without jti, lxm or iat, with a fragment in iss, or with a kid other than #atproto is refused. Migration: sign as the writer through ISpaceAccountSigner (#125, #133)
    • ISpaceServiceAuthVerifier and SpaceServiceAuthVerifier are removed — notifyWrite verifies its service auth with ServiceAuthVerifier under SpaceServerOptions.ClockSkew. Migration: verify service auth elsewhere with ServiceAuthVerifier or AddAtProtoServiceAuth() (#113, #133, #180)
    • Firehose construction leaves AtProtoClient — CreateFirehoseClient, CreateFirehoseConsumer and RelayUrl (options, builder and Aspire settings) are removed. Migration: new TypedFirehoseConsumer(new TypedFirehoseConsumerOptions { ServiceUrl = "wss://bsky.network" }) (#122)
    • FirehoseClient, FirehoseConsumer, FirehoseFrame and JetstreamClient are removed — TypedFirehoseConsumer, LabelStreamConsumer and JetstreamConsumer read every stream, reconnecting, and are not IDisposable. Migration: ConsumeAsync(cursor) for SubscribeAsync (#126, #180)
    • Stream consumers share StreamConsumerOptions and a StreamReconnectPolicy — Reconnect replaces ReconnectDelay and MaxReconnectAttempts, and the cursor settings sit on CursorStreamConsumerOptions. Migration: Reconnect = new() { MaxAttempts = null } for MaxReconnectAttempts = -1 (#126, #180)
    • A consumer whose reconnect attempts run out throws EventStreamException — instead of completing, and cancelling the token ends every consumer's enumeration normally. Migration: catch EventStreamException, or set MaxAttempts = null to reconnect forever (#126)
    • IFirehoseCursorStore is IStreamCursorStore, with ValueTask methods — shared by every cursor-keeping consumer; InMemoryFirehoseCursorStore is InMemoryStreamCursorStore. Migration: rename, and return ValueTask from a custom store (#126)
    • FirehoseEventParser keeps Parse(ReadOnlyMemory<byte>) — Parse(FirehoseFrame) and TryParse are removed, and an error frame throws EventStreamException. Migration: FirehoseEventParser.Parse(bytes) (#126)
    • Firehose verification is RepoSyncVerifier — FirehoseVerifier, VerificationResult and the options' Verifier and VerifySignatures are removed, and CollectionFilter is an IReadOnlySet<Nsid>. Migration: SyncVerifier = new RepoSyncVerifier(); VerifyCids still checks CIDs alone (#126, #180)
    • Firehose messages follow the current subscribeRepos Lexicon — HandleEvent and TombstoneEvent are gone, Seq and Time move to a new FirehoseEvent base, and TooBig, Rebase and Blobs are [Obsolete]. Migration: (message as FirehoseEvent)?.Seq; handle #identity and #account (#126)
    • Jetstream uses RepoOpAction — JetstreamOperation is removed, and JetstreamCommitEvent.Operation is the firehose's enum. Migration: rename JetstreamOperation to RepoOpAction (#126)
    • One JetstreamException — it replaces JetstreamConnectException and JetstreamArchiveException and derives from EventStreamException, and EventStreamError replaces JetstreamStreamError in OnStreamError. Migration: catch JetstreamException (#126)
    • JetstreamDictionaryClient is folded into JetstreamArchiveClient — as GetZstdDictionaryAsync(id). Migration: new JetstreamArchiveClient(url).GetZstdDictionaryAsync() (#126)
    • JetstreamEventParser keeps ParseFrame(ReadOnlyMemory<byte>, JetstreamProtocol) — the other Parse and ParseFrame overloads are removed. Migration: ParseFrame(Encoding.UTF8.GetBytes(json), protocol).Event (#126)
    • Unused Jetstream members are removed — JetstreamPlanStats, JetstreamSnapshotPlan.Stats, JetstreamConsumer.IsConnected and Dispose, and JetstreamReplayConsumer.PinnedTipSeq; JetstreamSegmentReader.DecodeBlock is internal. Migration: decode with DecodeBlockFrame (#126)
    • JetstreamReplayConsumer is configured by its options alone — the (options, archiveClient) constructor and JetstreamArchiveOptions.AfterSeq are removed. Migration: set the archive settings on JetstreamArchiveOptions, and pass the resume position to ReplayAsync(afterSeq) (#180)
    • MerkleSearchTree only accepts valid MST keys, and Create rejects duplicates — Add and Create throw ArgumentException for a key that is not collection/rkey, and for a repeated key rather than building a corrupt tree. Migration: key the tree by repo paths (#111)
    • Unused repository helpers are removed or internal — CreateFromEntries, DecodeToNode, TryValidate, DagCborEncoder.ComputeCid and CarBlock.DataLength go; the MST node types are internal. Migration: MerkleSearchTree.Create, CidComputation.ComputeForDagCbor, Data.Length, Decode (#111, #180)
    • DagCborDecoder.Decode throws FormatException for all malformed input — it threw InvalidOperationException or leaked CborContentException, and so does MerkleSearchTree.Deserialize for a tree too deep. Migration: catch FormatException (#111)
    • simplespace separates read and write policies, and putMember replaces addMember — following upstream's read/write split (bluesky-social/atproto#5496), which 0.6 failed against. Migration: pass readPolicy: and writePolicy: for policy:, and PutMemberAsync(space, did, read: true, write: true) for AddMemberAsync (#113)
    • SimpleSpaceClient.CheckUserAccessAsync takes a required access — before clientId, as the Lexicon now requires. Migration: CheckUserAccessAsync(space, user, SimpleSpaceAccess.Read, clientId) (#113)
    • The simplespace store and record carry both policies and per-member access — SimpleSpaceRecord takes ReadPolicy and WritePolicy, ISimpleSpaceStore has PutMemberAsync / GetMemberAsync, and SpaceNsids.AddSimpleSpaceMember is PutSimpleSpaceMember. Migration: implement both in a custom store (#113)
    • EF Core simplespace schema: Policy is split and members gain access flags — ReadPolicy and WritePolicy replace Policy, and members gain Read and Write. Migration: a migration that copies Policy into both columns and defaults the flags to true; see docs/spaces.md (#113)
    • ISpaceAccessPolicy also decides writes — SpaceAccessRequest.Access is SpaceAccessKind.Read or Write, and ISimpleSpaceManagingAppClient.CheckUserAccessAsync takes the kind too. Migration: a custom policy handles Write without an app perimeter, or it refuses every writer (#113)
    • Space server endpoint handlers and query-parameter types are internal — NotifyWriteEndpoint, AddSimpleSpaceMemberEndpoint and the other handlers, their bases and *Parameters types; the Add… registrations map them as before. Migration: implement your own IXrpcEndpoint (#113, #133)
    • The space server resolves DIDs through an IDidResolver — a keyed CachingDidResolver set by SpaceServerOptions.DidCache replaces ISpaceDidDocumentResolver and DidDocumentCacheLifetime. Migration: register a custom one under SpaceServerExtensions.DidResolverKey; set DidCache.StaleAfter (#120)
    • SpaceSyncer takes an IDidResolver — instead of a signing-key delegate, and ResolveSigningKeyAsync is removed; SpaceAuthority.GetHostEndpoint and GetServiceEndpoint return Uri?. Migration: new SpaceSyncer(space, store, new CachingDidResolver()) (#120)
    • Space server constructors take new optional parameters — AddSpaceAuthority<T> gains serviceAuthKey, SpaceCredentialVerifier options, the notifier and managing-app client an ISpaceAccountSigner; SpaceCredentialIssuer is not IDisposable. Migration: name later arguments; dispose the key yourself (#133)
    • ISpaceCredentialIssuer is removed — credential signing uses SpaceCredentialIssuer over the in-process key given to AddSpaceAuthority. Migration: resolve SpaceCredentialIssuer; an out-of-process signer has no replacement in 0.7 (#180)
    • Space server token types carry only what they establish — the Verified… token records drop unused members, DPoPProof keeps only KeyThumbprint, and both BuildRequestUri methods are gone. Migration: auth.Delegation.Space, credential.Space.Authority (#133, #180)
    • Test-only public members are removed or internal — SpaceTokens.TryParse and Verify(type, jwt, …), SpaceToken.ToSpaceUri(), DPoPProofValidator.ProofType, ClaimsSpaceCallerResolver.DidClaimType and SimpleSpaceAuthorityStore.Inner. Migration: Verify(SpaceTokens.Parse(type, jwt), …), AtProtoClaimTypes.Did (#180)
    • Space and SimpleSpace models follow the open-union pattern — SimpleSpaceUserPolicy and SimpleSpaceAppAccess are open, so a newer variant reads as Unknown… instead of failing getSpace, and the models derive from LexObject. Migration: add an Unknown… (or _) arm to a switch over a policy (#133)
    • The EF Core space and replay stores no longer run on the EF Core in-memory provider — they write with ExecuteUpdate / ExecuteDelete, which it does not translate. Migration: test on SQLite in memory (Mode=Memory;Cache=Shared, connection held open) (#133)
    • One startup warning for in-process stores; WarnOnInMemoryStores and MaxClientMetadataBytes are removed — it covers the replay store AddAtProtoSpaces() and AddAtProtoServiceAuth() fall back to. Migration: register InMemoryJtiReplayStore yourself to keep it quietly (#133, #180)
    • atproto-lexgen publish publishes to a PDS, and migrate is removed — publish writes com.atproto.lexicon.schema records, and its --output, --baseline, --assembly and --no-bump options and the Migrations types are gone. Migration: gate changes with diff --strict (#130, #180)
    • Unused atproto-lexgen helpers are removed — CSharpEmitter.Emit(LexiconDocument), LexiconEmitter.JsonOptions and five TypeMapper helpers. Migration: EmitAll([document]), LexiconJson.WriteOptions, and TypeMapper.SplitRef for ParseTypeValue (#130)

    Added

    • TidGenerator — mints strictly increasing, never-repeating TIDs per the spec, thread-safe with an injectable TimeProvider; Tid.Next() and RecordKey.NewTid() use a shared instance (#114)
    • Generic parsing for identifiers — every identifier type implements IParsable<T>, ISpanParsable<T> and IComparable<T>, so minimal-API binding and generic T.Parse code accept them; Cid gains Codec, Digest and ToBytes() (#114)
    • AtDatetime — the Lexicon datetime type: it keeps the text it was read with, so a record written back keeps its CID, reads leniently (IsValid, TryGetValue) and is strict when created from code (#119)
    • Open unions and unknown-field round-tripping for your own Lexicons — mark a union base [AtProtoUnion(typeof(UnknownX))] and derive models from LexObject; see docs/custom-records.md (#115)
    • app.bsky.embed.gallery and new profile record fields — GalleryEmbed and GalleryView as post and recordWithMedia embeds, and ProfileRecord.Pronouns, Website, Labels and JoinedViaStarterPack (#115)
    • Per-call XRPC options — XrpcCallOptions sets Proxy, AcceptLabelers, Headers and a Timeout for one call, so concurrent callers on a shared client no longer race on SetProxy / SetLabelers (#117)
    • AtProtoClientOptions.UserAgent and RateLimit — the User-Agent sent with every request, and XrpcRateLimitOptions (MaxRetries, MaxDelay) for 429 handling (#117)
    • XrpcErrors — constants for the generic and common com.atproto.* XRPC error names, for XrpcException.Is (#117)
    • XRPC procedures with no input or a binary body — IXrpcProcedure<TOutput>, IXrpcProcedureVoid, and IXrpcBlobProcedure<TOutput>, which receives the unbuffered body as an XrpcBlobInput (#121)
    • XRPC handler attributes apply to their endpoint — [Authorize], [AllowAnonymous], [EnableRateLimiting] and any other attribute on a handler class become its endpoint's metadata (#121)
    • Enumerators and a public paginator — Enumerate* walks the timeline, author feed, actor likes, followers, follows, list members, notifications, messages and bookmarks, and Pagination.EnumerateAsync any other cursored method (#119, #127, #180)
    • AT URI overloads — RepoClient.GetRecordAsync / DeleteRecordAsync, SpaceClient.GetRecordAsync / DeleteRecordAsync and the StandardSiteClient getters also take the record's AtUri or SpaceRecordUri (#119)
    • A drift test against the upstream Lexicons — every XRPC call's NSID and method, every model's JSON names and the known-values constants are checked against a pinned snapshot of bluesky-social/atproto and standard.site (#123)
    • Current app.bsky fields — profile views gain Pronouns, Associated, Verification and Status, and posts, feeds, embeds, notifications, graph views, labelers and video jobs gain the fields their Lexicons added, with new NotificationReasons and StandardLabelValues (#123)
    • Current chat, com.atproto, site.standard and Ozone fields — chat member views and declarations, DescribeServerResponse.BlobUploadLimit, DocumentRecord.Contributors, Ozone event and subject-status details, and the missing queryEvents, querySets and listMembers filters (#123)
    • Permission sets — AtProtoScopes.PermissionSets.DeleteContent, FullChatClient, StandardSiteFull and StandardSiteSocial (#123)
    • AtProtoClient.SessionChanged — raised when a session is created, refreshed, expires or is signed out, with the new and the previous session (#124)
    • Taken-down accounts can sign in — LoginAsync and CreateSessionAsync take allowTakendown, for a session limited to migrating or exporting the account (#124)
    • CreateAccountAndLoginAsync and TryRestoreSessionAsync — create an account and install its session, or install the session the store holds for a DID without a request (#124)
    • OAuth revocation — OAuthClient.RevokeAsync(session) revokes a session at its authorization server (RFC 7009) (#124)
    • Signing in through an entryway lands on the account's PDS — login and refresh follow the #atproto_pds endpoint of the account's own DID document when it is HTTPS (#124)
    • Ozone reports and queues — client.Ozone.Report covers tools.ozone.report (queries, assignment, typed activities, closing, and live and daily statistics, the live ones as QueueStats), and client.Ozone.Queue covers tools.ozone.queue; see docs/ozone.md (#131, #180)
    • Ozone settings, safelink rules, verifications and account history — client.Ozone.Setting, Safelink, Verification and Hosting (#131)
    • Ozone moderation lookups and scheduled actions — batch repo, record and subject lookups returning the open ModerationSubjectView union, account timelines, reporter stats, and scheduling and cancelling actions (#131)
    • The Ozone event types the SDK lacked — appeals, priority scores, age assurance, credential revocation, scheduled takedowns, and the account, identity and record events Ozone records for every subject (#131)
    • Granular report reasons — ReportReasons gains the tools.ozone.report.defs#reason* values upstream prefers, and each coarse com.atproto reason names its replacement (#131)
    • Group chats — client.Chat.Group covers chat.bsky.group: groups, members, join links and join requests; conversations and members carry a typed Kind, and a JoinLinkEmbed shares a group (#128)
    • Conversation endpoints and message fields — unread counts, conversation requests, members, locking, the kind and lockStatus filters, reactions, replies, typed system messages, and constants for the known values (#128)
    • Chat status, notification preferences and moderation — ChatActorClient.GetStatusAsync, client.Chat.Notification, and client.Chat.Moderation for moderation services (#128)
    • Verified record reads — SyncClient.GetVerifiedRecordAsync and RecordProof.Verify check a sync.getRecord proof against the repository's signed commit, returning a VerifiedRecord or proof of absence (#129)
    • SyncClient.GetBlocksAsync and RepoClient.ImportRepoAsync — fetch repository blocks by CID, and upload a repository CAR when migrating an account (#129)
    • AdminClient.SearchAccountsAsync and UpdateAccountSigningKeyAsync — and PdsAdminClient.SearchAccountsAsync for PDSs that serve it, such as Tranquil (#129)
    • AtProtoClient.Temp — com.atproto.temp.*: handle availability with suggestions, the signup queue, scope references, phone verification and credential revocation (#129)
    • site.standard.graph.recommend — RecommendRecord and the StandardSiteClient recommendation methods (#129)
    • Bookmarks and drafts — client.Bsky.Bookmark and client.Bsky.Draft manage the account's private bookmarks and post drafts; a bookmark's item is a PostEntry, the new open union of post views (#127)
    • Multipart video upload — VideoClient.StartUploadAsync, UploadPartAsync, FinishUploadAsync, GetUploadStatusAsync and AbortUploadAsync, which UploadVideoAsync drives (#127)
    • FeedClient.SearchPostsV2Async — app.bsky.feed.searchPostsV2, with every filter in a PostSearchFilters (#127)
    • Notification preferences and activity subscriptions — GetPreferencesAsync / PutPreferencesV2Async, activity subscriptions, UnregisterPushAsync and NotificationDeclarationRecord (#127)
    • Actor and graph records — StatusRecord, ContentVisibilityDeclarationRecord, VerificationRecord and ReferenceListOptOutRecord (#127)
    • Graph membership and starter pack search — GetListsWithMembershipAsync, GetStarterPacksWithMembershipAsync and SearchStarterPacksV2Async (#127)
    • Threads, link cards, feed feedback and age assurance — GetPostThreadV2Async, GetEmbedExternalViewAsync, SendInteractionsAsync (with ServiceProxy.BskyFeedGenerator) and client.Bsky.AgeAssurance (#127)
    • Record types name their collection — a record implementing IAtProtoRecord declares static Nsid Collection, so client.GetCollection<PostRecord>() needs no NSID; the SDK's records and atproto-lexgen output implement it (#122)
    • RecordCollection<T>.FindAsync / FindFromAsync — the record, or null on RecordNotFound (#122)
    • A public XRPC transport: AtProtoClient.Transport — an IXrpcTransport that a sub-client for another Lexicon builds on, sharing the client's session, refresh and errors; see docs/custom-xrpc.md (#122)
    • Identity resolution API — IDidResolver, IHandleResolver and IIdentityResolver; ResolveAsync(AtIdentifier) returns a bidirectionally verified ResolvedIdentity, and IdentityResolver.CreateDefault() builds the stack (#120)
    • CachingDidResolver — a bounded cache for any IDidResolver, with stale-while-revalidate, remembered failures, one fetch per DID, invalidation and an optional IDistributedCache (#120)
    • AddAtProtoIdentity() — registers the resolvers as singletons, optionally over the registered IDistributedCache (#120)
    • resolveIdentity, resolveDid and refreshIdentity — on IdentityClient, with IdentityInfo and the matching XrpcErrors (#120)
    • PLC export — PlcClient.ExportAsync pages the directory's /export, and StreamExportAsync follows /export/stream (#120, #180)
    • A configurable DNS-over-HTTPS endpoint — IdentityResolverOptions.DnsOverHttpsUrl; null resolves handles over HTTPS only (#120)
    • Event-stream error frames are a typed error — an op = -1 frame is an EventStreamException with Error and IsRetryable, reported to OnStreamError, and consumers reconnect after a retryable one (#126)
    • Label streams and chat moderation events — LabelStreamConsumer reads subscribeLabels, and ChatModerationEventConsumer reads chat.bsky.moderation.subscribeModEvents into typed events (#126)
    • Jetstream timestamp cursors — JetstreamCursor.FromTimestamp builds a v2 cursor that seeks by time, so a stored v1 cursor resumes a v2 consumer (#126)
    • IRecordEvent — one record change from either stream: JetstreamCommitEvent implements it, and CommitEvent.GetRecordEvents() splits a commit into decoded records (#126)
    • OnEventDropped — every consumer reports the events it skips, with a StreamDropReason and the cursor (#126)
    • Jetstream archive probes and health check — ProbeSegmentAsync, ProbeBlockAsync and GetHealthAsync, and AddHealthChecks().AddJetstream(url) in ATProtoNet.Server (#126)
    • Inbound service auth: AddAtProtoServiceAuth() and ServiceAuthVerifier — services built on ATProtoNet.Server accept calls carrying service auth tokens, checked as the 2026 spec requires; require it with [RequireServiceAuth], and tune it through AtProtoServiceAuthOptions.Verifier (#125, #180)
    • XrpcMethodMetadata and JtiReplayDbContext — the NSID each mapped XRPC endpoint serves, for accepting service auth on routes you map yourself, and the replay table on a context of its own (#125)
    • AtProtoClaimTypes — constants for the claims the OAuth login and service auth issue: Did, Handle, HandleVerified, PdsUrl, AuthMethod, LexiconMethod and Audience (#136)
    • Confidential OAuth clients (private_key_jwt) — OAuthClientKey and OAuthOptions.ClientKeys sign every request with a client assertion, which earns longer sessions; OAuthSession.ClientKeyId survives key rotation (#132)
    • A store for pending OAuth authorizations — IOAuthStateStore, in memory and bounded per requester by default, or DistributedCacheOAuthStateStore, which requires Protect / Unprotect (or StoreSecretsUnencrypted), so a login can complete on another instance (#132)
    • OAuthAuthorizationOptions.RequesterIdFor and AppState — a requester id from a remote address, IPv6 grouped by its /64, and application data carried through the flow and handed back at completion (#132)
    • OAuthOptions.RequestTimeout — bounds a whole pushed-authorization, token, refresh or revocation exchange, retry and body read included (#132)
    • A process-wide DPoP nonce cache — every client shares each server's latest nonce, so first requests skip a use_dpop_nonce round trip (#132)
    • Scope presets built on permission sets — AtProtoScopes.Presets, and the service audiences AtProtoScopes.BlueskyAppView and BlueskyChat (#132)
    • OAuth sign-in can start at an entryway — a URL that serves no protected-resource metadata is tried as an authorization server, as upstream atproto#4896 allows (#132)
    • Lexicon resolution — LexiconResolver resolves an NSID through its _lexicon DNS record and verifies the published schema against its commit proof; also CachingLexiconResolver, client.Lexicon.ResolveLexiconAsync and AddAtProtoIdentity registration (#130)
    • Permission-set checks — ResolvePermissionSetAsync and ResolveIncludeScopeAsync confirm that the set an include: scope names exists before an app requests it (#130)
    • atproto-lexgen permission sets and lint — csharp emits a class per permission-set with an Include(aud) helper, and lint checks documents against the Lexicon and permission specs (#130)
    • atproto-lexgen publish and resolve on the network — publish lints, signs in, skips unchanged schemas, refuses breaking changes unless --force and prints the _lexicon TXT records; resolve fetches and verifies published schemas (#130)
    • Refresh coordination for clients that share a session store — ISessionRefreshCoordinator (InProcessSessionRefreshCoordinator, AtProtoClientOptions.RefreshCoordinator) makes clients of one account share a refresh instead of spending the refresh token twice; WithClientFactory() registers one (#136)
    • Confidential clients in the hosted OAuth login — AtProtoOAuthServerOptions.ClientKeys, and ServeClientMetadata has MapAtProtoOAuth serve the client metadata and the public key set (#136)
    • PostCard likes and reposts, and ProfileCard loads by Actor — as the signed-in user, when no callback or profile is given (#136)
    • Default styles for the Blazor components — the opt-in stylesheet _content/ATProtoNet.Blazor/atproto-components.css (#136)
    • AddAtProtoClient(connectionName:) — takes the PDS URL from the connection string an Aspire WithReference(pds) supplies (#136)
    • Label signing and verification — LabelSigner signs per the label spec, byte for byte like @atproto/ozone, and LabelVerifier checks against the issuer's #atproto_label key (DidDocument.LabelKeyId), reporting a LabelVerificationStatus (#135)
    • Verified label streams — LabelStreamConsumerOptions.Verifier verifies every streamed label into LabelsEvent.Verification (#135)
    • Serving labels — QueryLabelsEndpoint serves queryLabels over your ILabelSource, signing on the way out, and LabelStreamFrames encodes subscribeLabels frames (#135)
    • Sync 1.1 firehose verification: RepoSyncVerifier — runs the sync spec's checklist on #commit and #sync (limits, CIDs, signature, operations inverted onto prevData), keeping each repository's state in an IRepoSyncStateStore, in memory or EF Core (#134)
    • Sync 1.1 mode for TypedFirehoseConsumer — SyncVerifier delivers only events that verify and chain, and with Resync a broken repository is refetched, verified as a RepoSnapshot and delivered as a RepoResyncEvent (#134)
    • Tap client — TapClient reads Tap's /channel with explicit acknowledgements, reconnecting, and calls its admin endpoints (#134)
    • Tap webhook receiver — MapTapWebhook(pattern, (evt, ct) => …) receives Tap's webhook deliveries, checks the admin password, and acknowledges an unreadable event with a warning (#134, #180)
    • ISpaceAccountSigner — signs space service auth as the account it speaks for, as the reference authority and managing apps require; an authority with a dedicated #atproto_space key passes its #atproto key to AddSpaceAuthority (#133)
    • SpaceTokens.Verify(SpaceToken, …) — verifies a token already returned by SpaceTokens.Parse, for verifiers that read iss and kid first (#118)
    • Public API tracking — every package carries PublicAPI.Shipped.txt / PublicAPI.Unshipped.txt, checked by the public API analyzers, so an undeclared API change fails the build (#139)
    • A migration guide for 0.7 — docs/migrating-to-0.7.md covers every breaking change, with compiled before-and-after code (#138)

    Changed

    • Shared build settings are set once — target framework, nullable, implicit usings and package metadata come from Directory.Build.props; published metadata is unchanged apart from the core package's dependencies (#110)
    • Aspire hosting: one base for both PDS container resources — AtProtoPdsContainerResource and AtProtoTranquilPdsContainerResource derive from AtProtoPdsContainerResourceBase, so one AppHost helper covers either; environment and manifest are unchanged (#116)
    • MerkleSearchTree.SerializeProof returns the reference covering proof — including the nodes on the paths to each key's neighbours, which relays need to invert a #commit (#111)
    • MerkleSearchTree.Validate checks that a loaded tree is canonical — it rebuilds the tree and throws InvalidOperationException when the root differs (#111)
    • Faster DAG-CBOR, BLAKE3, LtHash and did:key verification — the decoder writes JSON directly, BLAKE3 and LtHash use hardware vectors, and AtProtoCrypto.VerifySignature caches parsed keys; output is unchanged (#111, #112)
    • Identifier conversions are null-safe, and SpaceUri / SpaceRecordUri serialize as strings — a null identifier converts to a null string, and the space URIs round-trip through JSON like every other identifier (#114)
    • A union variant always writes its $type — also outside union position, as the data model allows, so a $type read there is written back (#115)
    • Minted JWTs allocate far less and share one jti format — DPoP proofs, service auth and space tokens are written straight to UTF-8, and each carries a random jti from the system CSPRNG in the same hex form (#118)
    • Compressed responses and bounded connection lifetimes — every HttpClient the SDK creates or registers decompresses responses, recycles pooled connections so DNS changes are picked up, and bounds connecting (#117)
    • JSON responses are deserialized as they arrive — a large page no longer allocates a large-object-heap buffer, and RecordCollection<T> reads each record straight into T (#117, #122)
    • Rate-limit waits are capped — a 429 asking for longer than RateLimit.MaxDelay throws XrpcRateLimitException at once, Retry-After dates are read, and retries carry jitter (#117)
    • atproto-proxy and atproto-accept-labelers are sent without a session too — and the session calls themselves are never proxied (#117)
    • The SDK sets User-Agent per request — the Aspire client and the OAuth login no longer add one to the HttpClient, and SpaceReaders share their provider's HttpClient (#117)
    • Service base URLs with a query or fragment are refused — InstanceUrl, SetServiceUrl, PdsAdminOptions.Url, the PLC directory and space host URLs throw ArgumentException (#117)
    • Unmatched /xrpc/{nsid} requests answer with an XRPC error — 501 MethodNotImplemented for an unserved NSID, 405 with Allow, and 400 InvalidRequest for a malformed one, as the reference xrpc-server does (#121)
    • Stream and space readers check identifiers where they read them — a frame, commit, index entry, token or query parameter whose identifier does not parse is dropped or refused (#119)
    • Deprecated upstream, now [Obsolete] — SyncClient.NotifyOfUpdateAsync, ListNotificationsResponse.Priority, GetSuggestedFollowsByActorResponse.IsFallback, and StandardLabelValues.Gore, ContentWarning and NotAvailable (#123)
    • DisposeAsync is the primary way to dispose AtProtoClient — it waits for a token exchange under way to be stored, Dispose returns at once, and the client disposes only what it created (#124)
    • A session store that fails to write no longer discards a successful refresh — the refreshed session stays installed, and the error is logged (#124)
    • Signing out of the hosted login revokes the OAuth session — /atproto/logout revokes it at the authorization server before removing it (#124)
    • Identity resolution is cached wherever it repeats — RepoSyncVerifier, SpaceCredentialProvider and the space server resolve through a CachingDidResolver, refetching once when a cached key fails, as the sync spec requires (#120)
    • Handle resolution fails closed on a conflict, and OAuth login asks only a handle's own authorities — two DIDs in DNS, or DNS and HTTPS disagreeing, fail, and the bsky.social AppView is no longer asked (#120)
    • Streaming spends less CPU per event — filtered consumers skip decoding dropped commits, a verified commit is parsed once, and the Jetstream archive filters rows before decoding them (#126)
    • Cursors are saved in the background — every CursorPersistInterval events, off the read loop (#126)
    • A v2 JetstreamConsumer skips the replay of its start cursor — the server replays ?cursor=N inclusively (#126)
    • Service auth signatures are checked as the reference implementation checks them — alg must match the key, high-S signatures are accepted, and a key a cached DID document lacks is found by one refresh (#125)
    • Space endpoints that authenticate their own callers opt out of host authorization — a group-wide RequireAuthorization() or RequireServiceAuth() no longer blocks the credential, repo-host and notifyWrite endpoints (#125)
    • An OAuth session's ServiceEndpoint is the account's PDS — also for a login started from a server URL, and a refresh follows a PDS move (#132)
    • OAuth sign-in fetches less — metadata is cached briefly, the callback reuses the identity resolved at the start, and a PDS's protected-resource metadata is read once (#132, #136)
    • The transitional scopes are documented as legacy — transition:* remains the default for now (#132)
    • The client factory caches each account's imported DPoP key — per-request clients are much cheaper, and a signed-out session takes its key with it (#136)
    • FileAtProtoSessionStore reads without a lock and writes under a lock per account (#136)
    • ATProtoNet.Blazor no longer declares browser support — it runs on the server, on top of ATProtoNet.Server (#136)
    • ATProtoNet.Server depends on nothing beyond the ASP.NET Core shared framework — EF Core moved to its own package, and StackExchange.Redis and the resilience handler are gone (#136)
    • The client factory resolves the login's OAuthClient when a client first needs it — resolving the factory before the server starts no longer builds the loopback client (#136)
    • The hosted login's HttpClient comes from IHttpClientFactory — the named client AtProtoOAuthExtensions.HttpClientName, under the identity fetch policy, so logging and telemetry handlers see its requests (#136)
    • Per-user clients take UserAgent and RateLimit from AddAtProto(o => …) — they were created with default options (#136)
    • atproto-lexgen: the null type and space collections — csharp reports a null definition as removed from the language, and leaves non-NSID space collections out with a warning (#130)
    • atproto-lexgen lexicon and diff --assembly load the assembly in its own context — bound to its own dependencies, and diff reports unreadable files instead of skipping them (#130)
    • Space credentials and client attestation keys are cached — a repo host verifies a credential's signature once until it expires, and attestation keys are cached per client_id with rate-limited refetches (#133)
    • SpaceCredentialProvider mints one credential per space at a time — concurrent callers share one mint, and replaced credentials release their keys (#133)
    • The EF Core space stores update in one statement, and the replay stores sweep in the background (#133)
    • SpaceSyncer recovery buffers the repo once, and the space verifiers are activated by the container — so a registered TimeProvider reaches them (#133)
    • Integration tests use one enum-driven [RequiresFact] and provision their own accounts — ATPROTO_REQUIRE_INTEGRATION=1 covers every requirement, and CI runs the PDS suites on every pull request (#137)
    • Every await in the libraries calls ConfigureAwait(false) — enforced by CA2007, which matters for hosts with a synchronization context (#139)
    • The documentation's C# samples are compiled — tests/ATProtoNet.DocSnippets fails the build at the Markdown line of a sample that drifts from the API (#138)
    • CidComputation.DecodeCidString and TryDecodeCidString accept only canonical base32 — as Cid.Parse does (#180)
    • One loop reads every stream — Jetstream and the Tap channel reconnect through the shared loop and send the SDK's User-Agent, and an abnormal close fails with an EventStreamException carrying its reason (#180)
    • Bodies from untrusted senders go through one bounded reader — the Tap webhook, record proofs, repository downloads and space recovery (#180)
    • Signature checks against a DID document share one refetch-once path — and SpaceCommitVerifier.Verify returns false for a curve the platform lacks (#180)
    • Every bounded cache evicts the least recently used entry (#180)
    • Faster JSON models, XRPC calls, Jetstream parsing and CAR reading — identifiers and datetimes are validated from UTF-8 without regular expressions, and their parts are built on first use; what is accepted and produced is unchanged (#190)

    Fixed

    • Rate-limit back-off follows the client's clock — 429 retry delays ran on the system clock rather than the clock the client's session refresh uses (#187)
    • A scoped IJtiReplayStore no longer stops the host under scope validation — the startup warning reads the registrations instead of resolving the store (#180)
    • atproto-lexgen --version reports the tool's real version — instead of a hard-coded 1.0.0 (#110)
    • The managing-app check follows the Lexicon — checkUserAccess sends user and access (it sent did, so every ManagingAppPolicy space refused everyone) and addresses the app's full service identifier (#113)
    • notifyWrite admitted any writer — the authority now evaluates the write policy, and answers 403 to a refused writer, which it neither records nor forwards (#113)
    • Write notifications reach their subscribers — an accepted notifyWrite is forwarded to the services registered with registerNotify, each addressed by the identifier it registered (#113)
    • Write notifications to an authority on an ordinary PDS were dropped — #atproto_space_host falls back to #atproto_pds, and inbound notifications accept every way the authority is addressed (#113)
    • Malformed simplespace and notifyWrite input is refused up front — a rev that is not a TID, a missing hash, or a null policy (#113)
    • docs/testing-spaces.md claimed there is no spaces container image — the alpha is ghcr.io/bluesky-social/atproto:pds-spaces-alpha, pinned by digest on the page (#113)
    • MST root CIDs now match every other implementation — null links, empty subtrees and incremental edits were encoded differently, so repos written with MerkleSearchTree were rejected elsewhere; roots are pinned against the reference vectors (#111)
    • MerkleSearchTree.Deserialize reads real repositories — the null links other implementations write threw (#111)
    • DAG-CBOR sorts non-ASCII keys by their UTF-8 bytes — keys of equal length were compared in UTF-16 order, which differs for characters above U+FFFF (#111)
    • SpaceRepoCar.Verify reports a malformed index block as SpaceRepoVerificationException (#111)
    • Tid.Next() is monotonic and unique — values minted in the same millisecond came out of order and could collide, RecordKey.NewTid() included (#114)
    • Identifier types accepted invalid values — every line of the interop syntax fixtures now parses or fails as specified, and an invalid identifier in JSON fails as a JsonException with its path (#114)
    • One unknown embed or union variant no longer fails a whole page — a single gallery post made timeline, feed, thread and search calls throw for the entire page (#115)
    • Signed labels deserialize — Label.Sig did not read the {"$bytes": …} form (#115)
    • UpdateProfileAsync no longer deletes profile data — it rebuilt the record from four fields; it now edits the stored record and writes with swapRecord (#115)
    • Unknown record fields survive a read-modify-write (#115)
    • Registered union variants take effect — AtProtoJsonDefaults.Options consults the registry (#115)
    • $bytes is unpadded base64, as the data model specifies — Jetstream sync events and archive rows accepted only padded input, and DagCborDecoder wrote padding (#115, #126)
    • Malformed keys fail consistently — a did:key or multikey that is not a compressed point throws FormatException, and an undefined KeyCurve throws rather than meaning K-256 (#112)
    • DPoP proofs are ES256 and carry no userinfo — a K-256 key is refused rather than labelled ES256, and the generator and validator normalize htu the same way (#118)
    • Changing the service URL or re-applying a session on a used HttpClient works — both wrote BaseAddress, which .NET forbids after the first request (#117)
    • User-Agent no longer grows on a shared HttpClient — it is set per request (#117)
    • Query parameters and stamped timestamps are culture-invariant — dates go out as ISO 8601 UTC and enums by their JSON names, and createdAt no longer follows the current culture's calendar (#117)
    • Uploads send from the stream's current position — instead of rewinding it, and a non-seekable stream that needs a retry fails clearly (#117)
    • RecordCollection<T>.ExistsAsync answers false only for RecordNotFound (#117)
    • A failed blob or repository download no longer leaks its HTTP response (#117)
    • XRPC query parameters bind by their declared type — a single value binds to a collection, and identifier parameters are validated by their parsers (#121)
    • Every XRPC endpoint failure answers with the XRPC error envelope — 500 InternalServerError without the exception's message, 413 PayloadTooLarge, and InvalidRequest for a wrong Content-Type (#121)
    • Enumerators stop on a repeated cursor — a server repeating its cursor drove an endless loop in the record, Spaces and Jetstream archive enumerators (#119)
    • Properties under names the Lexicons never used were always empty — canConvo, associatedChat, blobCids and the readOnly filter (#123)
    • Calls that did not match their Lexicons — the Ozone review queue called a nonexistent method, SearchAccountsAsync POSTed a query, and SearchPostsAsync and ListNotificationsAsync sent parameters the service refuses (#123)
    • Two procedures sent no body — DeactivateAccountAsync and UpdateAllReadAsync now send their JSON input (#123)
    • Five chat methods and the chat export read the wrong response shape (#123)
    • Seven permission-set constants named sets nobody published (#123)
    • Sign-out revokes the session — LogoutAsync sent deleteSession the access JWT, which the Lexicon refuses, and never revoked an OAuth session (#124)
    • A failed OAuth refresh is no longer stored as a token — an error response became an empty access token (#124)
    • ResumeSessionAsync refreshes an expired session on a fresh client (#124)
    • Installing a session replaces all of the previous one — a password login after an OAuth session kept refreshing through OAuth, and raw ServerClient calls changed the transport's session (#124)
    • The client refreshes after ExpiredToken or a DPoP invalid_token challenge — and resends once, only as the same account (#124)
    • Concurrent refreshes share one exchange — single-use OAuth refresh tokens were spent more than once (#124)
    • App-password sessions refresh by the token's exp — not on a fixed timer (#124)
    • Dispose no longer blocks waiting for a timer refresh (#124)
    • A permanent reporter mute no longer fails a page of Ozone events (#131)
    • DID resolution failures stay within the Spaces error contracts — they escaped as PlcException, JsonException or HttpRequestException, and one failing subscriber could stop deleteSpace (#120)
    • DID documents that DID Core allows parse — an @context given as a string, and a structured serviceEndpoint (#120)
    • Collection-filtered firehose consumers no longer replay history after a restart — every event moves the cursor, and the final cursor is saved when enumeration ends (#126)
    • A Jetstream frame with a non-numeric time_us or seq is skipped — instead of throwing out of the parser (#126)
    • Malformed exp, iat or jti answer 401, not 500 — tokens and DPoP proofs with out-of-range times or unusable jtis are refused as malformed (#125)
    • The OAuth callback completes after a restart or on another instance — it no longer needs the process that started the login (#132)
    • Stored OAuth sessions refresh after a restart — the client factory gets the login's OAuthClient from the options alone (#136)
    • FeedView, PostCard, ProfileCard and ComposePost render — they injected a client nobody registered; FeedView reloads only when needed, and ComposePost counts graphemes (#136)
    • OAuth requests dispose their responses and read errors correctly — a pushed request is retried only on use_dpop_nonce, and a non-JSON error is an OAuthException (#132)
    • The 0.4.0 notes claimed "Sync v1.1 support" — they now say "Sync v1.1 message fields"; this release adds the verification (#134)
    • Indefinite-length CBOR is refused in CAR headers, commits and MST nodes (#134)
    • SpaceSyncer verifies commits against the author's #atproto key — not a #atproto_space key (#133)
    • Space type declarations refuse a wildcard collection clearly — as proposal 0016 requires (#133)
    • services.AddAtProto() registers one configured AtProtoClient — it handed out a new client for bsky.social on every resolution (#136)
    • The PDS health check reports its registered failure status, and registering it twice no longer breaks /health (#136)

    Removed

    • samples/BlazorOAuthSample — folded into samples/ServerIntegrationSample, which runs the same OAuth login and adds server-side access (#110)
    • RedisSpaceReplayStore and AddAtProtoRedisSpaceReplayStore(), and with them the StackExchange.Redis dependency — EfCoreJtiReplayStore<T> remains the shared store. Migration: copy the short RedisJtiReplayStore from docs/spaces.md and register it as the IJtiReplayStore (#136)
    • Microsoft.Extensions.Http.Resilience and the Aspire client's resilience handler — a retrying handler below the SDK resends POSTs and spent DPoP proofs, and the SDK retries 429 itself. Migration: RemoveAllResilienceHandlers() on the SDK's clients under Aspire service defaults (#136)

    Security

    • The GitHub ↔ Forgejo sync workflows no longer run event text as shell — issue, PR and comment fields reached run: scripts through ${{ }}, so anyone on the public mirror could run commands with the sync tokens; they now pass through env:. Rotate FORGEJO_TOKEN and GH_MIRROR_TOKEN (#108)
    • Mirrored pull requests can no longer reach repository secrets — sync-prs.yml fetches a PR into a bare repository instead of checking it out, and refuses to mirror a PR that changes .forgejo/ or .gitea/ (#108)
    • Crafted CAR and DAG-CBOR input no longer crashes the process — a deeply nested CAR header overflowed the stack; nesting is capped and parsing uses CborReader (#111)
    • MST prefix lengths are range-checked — a crafted node drove a huge allocation before any check (#111)
    • CarReader validates every length and CID it reads — and rejects CIDv0 blocks, which bypassed the codec check (#111)
    • MerkleSearchTree.Deserialize rejects malformed trees — invalid or out-of-order keys, shared subtrees and empty inner nodes (#111)
    • JWT and JWK base64url decoding is strict — only the URL-safe alphabet, correct padding and zero trailing bits, so a token has one spelling (#118)
    • XRPC error bodies are read up to a cap — a service could answer an error with a body of any size (#117)
    • One SSRF policy for every identity fetch — did:web, PLC, handle and DNS-over-HTTPS requests are HTTPS-only, refuse private addresses after DNS, follow no redirects, cap bodies and time out; IdentityResolverOptions.AllowPrivateNetworks opts out (#120)
    • OAuth and the space server's outbound requests no longer reach private addresses — metadata, pushed-authorization, token and revocation requests follow the fetch policy, as does the space server's client, and a URL breaking its rules is refused before sending (#120, #132)
    • Single-use space tokens could be replayed just after expiry — every verifier now keeps the jti until the token stops being accepted (#125)
    • Identifiers no longer parse with a trailing line break — $-anchored patterns accepted did:plc:abc followed by a line feed (#125)
    • A did:web document must name exactly the DID it was fetched for — a case-insensitive comparison let one signer present several identities (#125)
    • OAuth refresh and code-exchange responses are checked against a freshly resolved issuer — and the expected DID, the atproto scope and DPoP binding; tokens failing a check are revoked (#132)
    • Authorization server metadata is held to the AT Protocol profile — exact issuer, HTTPS endpoints, required PAR and iss, and a PDS and authorization server that name each other (#132)
    • Anyone could block every OAuth login — pending logins are limited per requester, with a total bound that displaces rather than refuses (#132)
    • Reading an authorization server's response is bounded — OAuthOptions.RequestTimeout covers the whole exchange, and connection failures are OAuthExceptions (#132)
    • A login could be completed by anyone holding the callback URL — /atproto/login binds the pending authorization to an HttpOnly cookie that the callback requires, and returnUrl must be a local path (#132)
    • A failed login no longer puts exception messages in the login URL — the redirect carries an error code. Migration: a login page of your own reads error as a code (#136)
    • A service auth caller could drive an account's stored OAuth session — the client factory and sign-out now honour only the OAuth login's identity (#136)
    • The OAuth callback re-checks the stored login state — a return URL that is not local is dropped, and a malformed origin fails the login (#136)
    • PostCard links only http and https URLs — a javascript: URL in an external embed ran script in the application's origin (#136)
    • An unredeemed relayed login is revoked — tokens waiting on a loopback relay code are revoked when it expires or is evicted (#136)
    • The hosted login's own HttpClient never uses a proxy — so the address the fetch policy checks is the real target; behind an egress proxy, configure the named client AtProtoOAuthExtensions.HttpClientName (#132)
    • Malformed #atproto_space and #atproto_space_host entries are errors, not fallbacks — per proposal 0016, instead of silently using #atproto / #atproto_pds (#133)
    • Space tokens must name their key, and only that key is tried — Migration: an authority signing with a dedicated key sets SpaceServerOptions.CredentialKeyId = "#atproto_space" (#133)
    • Space DPoP proofs are ES256 only, and the exchange's proof carries no ath — as proposal 0016 and the reference require (#133)
    • SpaceRepoCar.Verify checks what the reference checks — exact index keys, DAG-CBOR links, and strict DAG-CBOR record blocks, so each value has one byte form (#133)
    • SpaceSyncer.RecoverAsync bounds a repo download — SpaceSyncer.MaxRepoSize refuses a larger repository before anything is replaced (#133)
    • A signed commit with a malformed link no longer ends a Sync 1.1 consumer — it is an invalid event (#180)
    Downloads