• v0.6.0 7f672c0827

    v0.6.0
    All checks were successful
    CI / pds-integration (push) Successful in 26s
    CI / build-and-test (push) Successful in 56s
    Release / release (push) Successful in 1m3s
    Stable

    Grandiras released this 2026-08-21 14:07:52 +00:00 | 0 commits to main since this release

    Breaking changes

    • AtProtoScopes.Repo(...) now throws ArgumentException for RepoAction.None (Issue #94) — the scope grammar has no marker for an empty action list, so RepoAction.None previously emitted repo:<nsid>: a full create/update/delete grant, the opposite of what the caller asked for. Migration: drop the repo: scope entirely if no record writes are needed, or name the narrowest action that is (Create, Update, Delete). RepoAction.All and partial combinations are unaffected

    Added

    • Spaces: the permissioned data protocol (Issue #89) — client-side support for permissioned data (proposal 0016): the familiar AT Protocol shape — DID authority, per-user repos, Lexicon-typed records — behind an access perimeter called a space. This is an alpha proposal with no security review, and it provides access control, not confidentiality: the data is not end-to-end encrypted and every service handling it can read it

      • SpaceUri / SpaceRecordUriat://{authority}/space/{type}/{skey}[/{author}/{collection}/{rkey}]. Authority splits in two: the URI's authority gates access, the record's author DID signed it. Neither may be a handle
      • LtHash — the homomorphic set hash a permissioned repo commits to in place of an MST root. Order-independent, so a write is one cheap pass over the lanes; verified lane-for-lane against the reference implementation
      • BLAKE3 in XOF mode, implemented from scratch since .NET ships none and the SDK carries no third-party cryptography. Checked against 27 reference test vectors
      • SpaceRepoCommit / SignedSpaceCommit / SpaceCommitVerifier — deliberately not a rebroadcastable proof: the signature covers only the commit context, and the digest is bound to it by a symmetric MAC, so a leaked commit proves nothing to a third party. Context encoding and MAC match the reference implementation byte-for-byte
      • SpaceRepoCar — the two-root CAR form getRepo serves (signed commit, then a DAG-CBOR path→CID index, then the record blocks). Verify authenticates the whole thing in one pass; excludeValues yields an index-only CAR for diffing against a local copy
      • SpaceCredentialProvider / SpaceReader / SpaceTokens — the credential exchange over two independent axes: which user (a delegation token from their PDS) and which application (a self-signed client attestation). A credential is DPoP-bound rather than a bearer token, since it reads a whole space and is presented to every host in it; cached per space and renewed ahead of expiry
      • SpaceSyncer / SpaceRepoCursor / ISpaceRepoStore — there is no relay for permissioned data, so an application pulls from each repo host directly. Comparing digests rather than tracking individual operations is what makes sync self-healing: dropped writes, compacted oplogs, and corrupted copies all surface as a mismatch and repair by full download
      • com.atproto.space.* on AtProtoClient.Space and com.atproto.simplespace.* on AtProtoClient.SimpleSpace — the full endpoint surface, plus the baseline space-management implementation every PDS must support. listRepos returns the writer set (accounts that have written), which is the sync boundary and not an access-control list; readers are never enumerated at the protocol level
      • AtProtoScopes.Space(...) with SpaceAction / SpaceManagespace: OAuth scopes granted by space type, authority defaulting to self. Read also confers getDelegationToken and therefore the whole space, while ReadSelf reaches only the holder's own repo — the right grant for an export tool
      • SpaceAuthority (resolves #atproto_space / #atproto_space_host with fallbacks, so any ordinary account works as an authority), SpaceTypeDeclaration, and LexBytesJsonConverter for the {"$bytes": "…"} wrapper
      • docs/spaces.md, samples/SpacesSample, and 212 unit tests pinning every cryptographic construction against the reference implementation's own outputs
    • Spaces: server-side support in ATProtoNet.Server (Issue #91) — the other half of #89: the ASP.NET Core layer that lets a .NET service act as a space authority, a repo host, or both. Registered with AddAtProtoSpaces() plus AddSpaceAuthority<T>(key) / AddSimpleSpace<T>() / AddSpaceRepoHost<T>(), mapped by the ordinary MapXrpcEndpoints(). The three register separately so a service that only needs to verify takes AddAtProtoSpaces() alone

      • DPoPProofValidator — six checks: signature against the proof's own jwk, that key's thumbprint against the credential's cnf.jkt, ath against the credential presented, htm/htu against the request as received, a recent iat, and an unseen jti. htu is compared with query and fragment stripped per RFC 9449 §4.3; a non-absolute htu is refused rather than string-compared, and alg is pinned to the key's curve
      • SpaceDelegationTokenVerifieraud must equal spaceHostAud(spaceDid) for the authority in the token's own sub, so a token minted for another authority cannot be presented here. The jti is consumed last, only after every other check passes
      • SpaceCredentialVerifier — the signer is resolved from the space URI, not the credential's iss, so only a space's own authority can mint credentials for it
      • SpaceClientAttestationVerifier — resolves client_id to its client-metadata.json, follows jwks_uri, and verifies against the key the kid names (trying every published key would let one compromised key be laundered through another). The fetch is https-only and bounded by MaxClientMetadataBytes
      • ISpaceReplayStore — single-use enforcement keyed on (iss, jti, exp), with an in-process default. Replace it in a multi-instance deployment, or a replay is caught only by the instance that saw the original
      • SpaceServerOptions.MaxSingleUseTokenLifetime (default five minutes) caps how far ahead an inbound single-use token's exp may sit, bounding both replay window and replay-store occupancy. SpaceServerOptions.PublicBaseUrl is not optional behind a reverse proxy, since DPoP htu is compared against the request as received
      • The endpoint surface: getSpaceCredential, listRepos, registerNotify, unregisterNotify, notifyWrite (authority side); getRecord, listRecords, getLatestCommit, getRepo, listRepoOps, getBlob, listBlobs (repo side, over an ISpaceRepoHost). RepoNotFound deliberately does not distinguish a silent member from a non-member — saying more would leak membership
      • com.atproto.simplespace.* — all seven administration methods over an ISimpleSpaceStore, plus SimpleSpaceAccessPolicy: a user perimeter (MemberListPolicy / PublicPolicy / ManagingAppPolicy) and an app perimeter (OpenAppAccess / AllowListAppAccess, evaluated against the attested client ID). A ManagingAppPolicy whose app is unreachable refuses, since failing open would turn an outage into an open space
      • SpaceWriteNotifier — fans notifyWrite / notifySpaceDeleted out with service auth, best-effort by design because the syncer's listRepos sweep is the correctness guarantee. EnsureAuthoritySubscribedAsync auto-registers a space's authority on first write, which is what populates the writer set
      • Key resolution reads every verification-method type the SDK understands (see #98), so an authority is not accepted or refused based on whether a token carried a kid
      • 105 new unit tests including a TestServer end-to-end pass; docs/spaces.md gains a Serving a space section
    • Spaces: durable and multi-instance implementations of the space server stores (Issue #102) — #91 shipped all three stores in-memory, which is right for a test host and wrong for anything else

      • RedisSpaceReplayStore — the replay store was a correctness gap across instances, not a durability one: two replicas behind a load balancer each accepted the same delegation token. Consuming a token is one atomic SET key value NX EX ttl, with the entry's TTL being the token's own remaining lifetime. Registered with AddAtProtoRedisSpaceReplayStore(); adds a StackExchange.Redis dependency to ATProtoNet.Server
      • EfCoreSimpleSpaceStore<TContext> — a member list is the one piece of space state that cannot be rebuilt, so a restart that lost it lost the space's access control. Policy unions are stored as their Lexicon JSON, so a new variant needs no schema change
      • EfCoreSpaceAuthorityStore<TContext> — writer set and notification registrations, with DeclareSpaceAsync / MarkDeletedAsync. Pagination is by DID and evaluated by the database, so ordering and cursor comparison agree under any collation
      • EfCoreSpaceReplayStore<TContext> — for deployments with no Redis; the primary key is the replay check. A failed save is confirmed against the table before being reported as a replay, so a storage fault does not masquerade as one. Expired rows are swept opportunistically, at most once a minute
      • All four take an IDbContextFactory<TContext> and sit in ATProtoNet.Server.EntityFrameworkCore alongside the token store. Use SpaceDbContext or call SpaceDbContext.ConfigureSpaceModel() from your own context
      • AddAtProtoSpaces() now warns at startup while the replay and simplespace stores are in-process defaults; suppress with SpaceServerOptions.WarnOnInMemoryStores = false
    • Spaces: integration tests against a real permissioned-data PDS (Issue #93) — #89's 212 unit tests all stubbed the HTTP layer, proving the SDK agrees with a reading of the spec, not that a server accepts what it sends. 26 tests in tests/ATProtoNet.IntegrationTests/ now talk to a live space host behind a [RequiresSpacesFact] gate (ATPROTO_TEST_SPACES=true), so CI is unaffected

      • SpaceNetworkFixture provisions three accounts (authority, member, outsider) through the admin API, since a space is a three-party arrangement a stub cannot tell apart. ATPROTO_PLC_URL points DID resolution at the test network's own directory
      • SpaceCredentialTests — the two-hop exchange end to end, then the refusals: replayed token, wrong space, wrong key, wrong host, credential presented as a bearer token, and SpaceDeleted on renewal
      • SpaceRepoSyncTests — the CAR round trip verified against a real server's own commit and index, plus incremental sync, cursor resumption, divergence detection, and full-recovery fallback
      • SimpleSpacePolicyTests — non-member refusal, #allowList refusal, attestation retry, revocation at renewal, and the repo boundary between two accounts on one host
      • docs/testing-spaces.md covers standing a host up. No PDS release serves com.atproto.space.* yet — it lives on bluesky-social/atproto#5187, which these tests were run against
    • atproto-lexgen understands "type": "space" Lexicon definitions (Issue #92) — previously a space-type Lexicon produced an empty file and no diagnostic

      • JSON → C#: a space definition emits a static holder (com.atmoboards.forumForumSpace) exposing Nsid, a SpaceTypeDeclaration Declaration, and Key / Name / LocalizedNames / Collections forwarders. A declaration missing a required field still emits compiling code and says what it substituted
      • C# → JSON: atproto-lexgen lexicon emits a space definition for every static SpaceTypeDeclaration, taking the NSID from a sibling Nsid constant, so declarations round-trip. Ambiguous or unattributable declarations are reported through the new LexiconEmitter.Warnings
      • Diffing: atproto-lexgen diff compares declarations. Because a bare space: grant resolves its collection set when the grant is evaluated, adding a collection widens every existing grant (reported) and removing one narrows them (breaking); key changes are breaking, name changes are not
      • An unrecognized definition type is now a WARN naming the NSID and type instead of an empty file
    • XRPC handler routing: named errors and binary responses (Issue #91) — XrpcException(error, message, statusCode) thrown from a handler is written as the {"error", "message"} body XRPC clients branch on rather than escaping as a 500, and carries a Headers dictionary for things like WWW-Authenticate. IXrpcBlobQuery<TParams> is the counterpart of IXrpcQuery<,> for non-JSON output encodings (getBlob, getRepo, the CAR methods), streaming rather than buffering. Query-parameter binding failures now answer InvalidRequest, and XrpcBooleanConverter binds Lexicon boolean parameters

    • Jetstream v2 is now supported alongside v1 (Issue #83) — the second wire protocol (atproto proposal 0015) serves at /xrpc/network.bsky.jetstream.subscribeEvents and differs from v1 in nearly every particular: a self-describing envelope, flat commit fields, collections/dids/kinds filters, a sequence-number cursor, a sync event kind, and out-of-band #info/error frames. JetstreamClient and JetstreamConsumer speak both, selected by JetstreamConsumerOptions.Protocol

      • Protocol defaults to JetstreamProtocol.V1, so existing configurations are unchanged. JetstreamEndpoints names the hosts: UsEast / UsWest (v2) and LegacyUsEast1 / LegacyUsEast2 / LegacyUsWest1 / LegacyUsWest2
      • WantedKinds — the v2 kinds filter. A collection filter constrains commit events only, so a commits-only stream needs WantedKinds = [JetstreamEventKind.Commit]; combining WantedCollections with a WantedKinds that excludes Commit now throws before the socket opens
      • JetstreamSyncEvent — a repo resynchronization marker (v2 only); handle it as you would an account deletion
      • JetstreamEvent.Cursor — the sequence number and v2 resume position, unaffected by operator timestamp imports. JetstreamEvent.Timestamp exposes TimeUs as a DateTimeOffset
      • Cursor handling follows the protocol: v2 cursors replay inclusively, so ReconnectRewind is ignored, and an event with no sequence number is not persisted
      • A rejected subscription is no longer retried in a loop — v2 validates before the WebSocket upgrade, and JetstreamConnectException (with StatusCode / IsRetryable) surfaces CursorTooOld, UnknownZstdDictionary, and malformed filters. The consumer persists progress and rethrows rather than silently skipping the gap
      • OnInfo / OnStreamError for v2's advisory and terminal frames; JetstreamDictionaryClient fetches the versioned zstd dictionary and reads its ID from the dictionary's own header. Setting only one of ZstdDictionaryId / Decompressor now throws
      • JetstreamEventParser.ParseFrame(json, protocol) returns a JetstreamFrame; the existing Parse(...) overloads still read v1. Both wires stay forward-tolerant
      • JetstreamV2Tests checks the protocol against a live instance, gated by [RequiresJetstreamFact] (ATPROTO_TEST_JETSTREAM=true); needs no PDS and no credentials. Documented in docs/jetstream.md
    • Jetstream v2 archive: replay and snapshot (Issue #85) — the HTTP archive alongside the live tail, so an indexer gets the records that already exist and every new one with no gap at the seam. JetstreamReplayConsumer.ReplayAsync() backfills history and cuts over into the live tail in a single await foreach

      • JetstreamSegmentReader — decoder for the sealed segment format (.jss): fixed header, length-prefixed zstd frames, columnar block body. ReadRowsAsync / ReadEventsAsync stream block by block rather than materializing segments that run to hundreds of megabytes. JetstreamArchiveRow exposes the untouched CBOR so a mirror stays byte-auditable; ToEvent() projects to the live tail's JetstreamEvent. Verified against Jetstream's own golden fixtures
      • JetstreamArchiveClient — typed wrappers for planSnapshot, listSegments, getSegment, getBlock with bearer auth, Range, and ETags. The endpoints are metered in response bytes, so a 429 waits out exactly its Retry-After and DownloadSegmentAsync resumes from the byte offset it stopped at. JetstreamArchiveException carries StatusCode / Error / RetryAfter / IsRetryable, so a revoked key is not retried
      • The plan loop pins the tip: the first planSnapshot's sealedTipSeq is the ceiling for the whole backfill, so the range cannot float mid-download. A page that fails to advance while the ceiling is ahead is re-planned with backoff MaxStalledPlanAttempts times (default 5) and then fails, rather than leaving a permanent silent gap. Downloads run DownloadParallelism deep but decode in plan order; since the planner works from bloom filters, the exact filters are re-applied to what was decoded
      • The cutover is inclusive and deduplicated. If the backfill outran the socket's 36-hour lookback, the refused connect sends the consumer back into the plan loop (up to MaxCutoverAttempts) rather than skipping the gap
      • JetstreamConsumerOptions.Archive (new JetstreamArchiveOptions) configures it all; filters and CursorStore are shared with the live tail, so one store spans both phases and a restart resumes the backfill
      • IJetstreamBlockDecompressor is a separate seam from IJetstreamDecompressor on purpose: segment blocks carry no dictionary. The SDK still bundles no zstd. Segment checksums are exposed rather than recomputed (they are xxh3 metadata checksums, and a compaction rewrites them)
      • JetstreamArchiveTests gated by [RequiresJetstreamArchiveFact] (ATPROTO_JETSTREAM_API_KEY), deliberately small since the endpoints bill real bytes. New samples/JetstreamReplaySample
    • JetstreamEventParser.ParseFrame(ReadOnlyMemory<byte>, JetstreamProtocol) (Issue #87) — a no-copy counterpart to the span overload for callers already holding the frame on the heap. JsonDocument cannot parse a span without copying it; the memory overload reads in place, taking ~600 bytes per event off JetstreamClient's receive loop

    • Tranquil PDS is now supported alongside the reference Bluesky PDS (Issue #78) — Tranquil is a community PDS (single Rust binary; passkeys, 2FA, SSO, did:web accounts, granular OAuth scopes, a web UI) and a superset of the reference server

      • AddAtProtoTranquilPds(name, port?, tag?) (ATProtoNet.Aspire.Hosting) — adds the container plus a {name}-postgres server and {name}-db database, since Tranquil keeps repositories in PostgreSQL. WithDatabase(database) / WithDatabaseUrl(url) point it at an existing one and drop the generated resources. Tranquil is handed a postgres:// URI, not an ADO.NET connection string
      • WithAtProtoTranquilPds(pds) wires a project to the container exactly as WithAtProtoPds does for the reference server
      • Configuration methods on AtProtoTranquilPdsContainerResource: WithAdminAccount, WithDevelopmentMode, WithHostname, WithHandleDomains, WithJwtSecret, WithDPoPSecret, WithMasterKey, WithPlcRecoveryKey, WithBlobVolume, WithBlobBindMount, WithS3BlobStorage, WithPlcUrl, WithCrawlers, WithReportService, WithBlobUploadLimit, WithInviteCodeRequired, WithEmail. WithPlcRecoveryKey takes a public did:key rather than the reference server's hex private key, because Tranquil adds it only to the rotation keys
      • PdsAdminClient can authenticate as an administrator account, not just with a server-wide admin password: PdsAdminOptions.Authentication (new PdsAdminAuthentication enum, default AdminPassword) and PdsAdminOptions.AdminIdentifier. Under AdminAccount the client signs in lazily on first use, reuses the session, and re-authenticates once if the server later rejects it. New EnsureAdminSessionAsync() does the same for callers using the raw Admin / Server clients
      • AddAtProtoPdsAdmin() binds the two new configuration keys and fails at host build time when AdminAccount is selected without an identifier
      • The administrator account is not created for you, by design — Tranquil flags the first account on an empty instance as admin, so the application creates it once with PdsAdminClient.CreateAccountAsync. The handle defaults to pdsadmin.{hostname} (not admin., which Tranquil reserves)
      • Running locally the container gets the relaxations a development instance needs (INVITE_CODE_REQUIRED=false, DISABLE_ACCOUNT_VERIFICATION_GATE=true, rate limiting off, SERVER_HOST=[::]) and none of them when publishing; without them no account could be created or signed in. WithDevelopmentMode(false) turns the set off
      • Secrets are generated at 48 alphanumeric characters and persisted to user secrets locally; unlike the reference PDS's hex secrets these are shapes an Aspire manifest generate block can describe, so a published deployment produces its own
      • Documented in docs/managed-pds.md, docs/aspire.md, docs/api-reference.md, docs/architecture.md
    • The core package's public API is now fully XML-documented (Issue #72) — ATProtoNet emitted 1114 CS1591 warnings, so most of the public surface arrived in IntelliSense with an empty tooltip. All 1114 members now carry a <summary>. Insert-only apart from one cref fix

    Changed

    • Performance and memory pass over the streaming, repository, and CID hot paths (Issue #87) — no public behaviour changes. Measured on a 4693-byte #commit frame, a 1000-entry MST, and a 1000-block CAR, tiered compilation disabled (median of three runs):

      Operation Time before → after Allocated before → after
      FirehoseEventParser.Parse (one #commit) 147 µs → 22 µs (6.6×) 102 KB → 29 KB (3.5×)
      MerkleSearchTree.Create (1000 entries) 3.44 ms → 0.61 ms (5.7×) 743 KB → 161 KB (4.6×)
      MerkleSearchTree.Get × 1000 3.10 ms → 0.10 ms (31×) 362 KB → 0
      CarReader.FindBlock × 1000 6.10 ms → 0.03 ms (187×) 40 KB → 0
      CidComputation.EncodeCidToString 88 ns → 67 ns (1.3×) 432 B → 144 B ()
      MerkleSearchTree.Serialize (1000 entries) 961 µs → 903 µs 1348 KB → 1190 KB (1.13×)
      • FirehoseEventParser transcodes DAG-CBOR straight into the JSON the models bind from, replacing five passes over every frame (and a deep clone at every nesting level) with one. The CAR blob never becomes a UTF-16 string
      • CarReader.FindBlock builds a deferred CID index on first use instead of scanning every block, which made a repo walk quadratic
      • MerkleSearchTree.Get / TryUpdate no longer call List.IndexOf on the entry they are iterating, and Get / Remove no longer compute an unused SHA-256 per node visited; lookups now allocate nothing
      • MerkleSearchTree.Create hashes each key once instead of once per layer, and partitions on index bounds rather than copying entry ranges into fresh lists
      • CidComputation.EncodeCidToString builds the string in one pass instead of three allocations; CarBlock.CidHex uses Convert.ToHexStringLower
      • The FirehoseClient / JetstreamClient WebSocket read loops take a single-receive fast path instead of staging every message through a MemoryStream; multi-frame messages still spool
      • CarReader.FromStreamAsync parses the buffer it already filled instead of copying the whole CAR onto the large object heap; FirehoseVerifier.ExtractSignedView writes into one exact-sized array (WriteMapHeader now takes a Span<byte>, alongside a new MapHeaderLength)
      • AtProtoJsonDefaults.Options is initialized by the type initializer rather than a racy ??=, which could give concurrent first calls their own reflection-derived contract cache. It stays mutable until first use
      • XrpcClient joins the atproto-accept-labelers header once when the subscription is set; DagCborEncoder counts arrays without materializing a list and sorts keys in place; Did.Method slices instead of Split(':')
    • Codebase cleanup sweep: 323 net lines removed from src/, no intended behaviour change beyond the fixes listed below — Lexicon clients build query parameters with a new internal XrpcParams builder at 78 call sites instead of hand-rolled dictionaries (this is what fixes the comma-joining bug); XrpcClient's eight near-identical overloads delegate to one SendAsync pair; RecordCollection's */*From pairs no longer duplicate bodies; AtProtoClient's four delete-by-AT-URI bodies, two relay-URL guards, and three new Session { … } blocks collapse to helpers. Dead code removed: XrpcQueryBuilder.BuildQueryString, its ToDictionary alias, ModerationClient.AddListParams, and an unreachable AdminClient list

    • Session.With(...) added — returns a copy with selected fields replaced, so a token rotation need not restate nine untouched fields. Additive

    • AtProtoHttpException.ResponseBody is now init-settable — additive; no existing signature changed

    • 21 Lexicon client classes no longer take an ILogger they never wrote to. Their constructors are internal, so this is not a public API change; ServerClient and PdsAdminClient, which do log, are unchanged

    • LoginForm default copy now says "username" instead of "handle" (Issue #80) — the field label defaults to "Username" and the hint to "Your Atmosphere account username — your PDS is detected automatically.". Same split as Issue #32: the community's word in the copy, the protocol's word in the code. Nothing below the copy changed (HandleLabel / HandlePlaceholder / HandleHint, the handle parameter, the atproto-handle input id are all as they were), so this is not source- or binary-breaking. Tests asserting on the old strings must update or pass explicit values

    • ATProtoNet.Aspire.Hosting now depends on Aspire.Hosting.PostgreSQL (Issue #78) — Tranquil PDS does not run without PostgreSQL. AppHosts using only AddAtProtoPds are unaffected apart from the extra restore

    • CS1591 is now a build error on all four documented packages (Issue #72), so a new undocumented public member fails the build. TreatWarningsAsErrors stays false

    • All NuGet dependencies updated to their latest stable versions (Issue #73) — --outdated, --deprecated, and --vulnerable --include-transitive all come back empty. Notably System.Formats.Cbor 9.0.4 → 10.0.10, moving the last package off the .NET 9 line, plus the Microsoft.Extensions.*, EF Core, and resilience packages. No source change was needed

    • Test suite migrated from xunit 2.9.3 to xunit.v3 3.2.2 (Issue #73) — xunit 2.x is deprecated on nuget.org with no non-deprecated 2.x release, so a version bump could not clear it. The migration was small: four IAsyncLifetime implementations return ValueTask, and five custom Fact/Theory subclasses forward [CallerFilePath]/[CallerLineNumber]. No test was rewritten. xUnit1051 is NoWarned with a comment, since adopting TestContext.Current.CancellationToken at ~260 call sites belongs in its own change

    • CI actions updated (Issue #73) — the GitHub mirror workflows move to actions/checkout@v7 and actions/setup-dotnet@v6. The Forgejo workflows pin no actions

    Fixed

    • A space created through com.atproto.simplespace is now one its authority answers for (Issue #105) — ISimpleSpaceStore and ISpaceAuthorityStore held separate state and nothing bridged them, so a service registered the documented way minted credentials correctly but refused every write notification for its own spaces with SpaceNotFound. The writer set could therefore never be populated, and since it is the sync boundary, no syncer could find anything. deleteSpace had the mirror gap: listRepos kept returning the writer set instead of SpaceDeleted. AddSpaceAuthority<T>() now wraps the store in the new SimpleSpaceAuthorityStore whenever an ISimpleSpaceStore is registered, in either order. Existence and deletion are read from the space-management store rather than copied, so there is no second write to keep in step. Spaces the simplespace store has never heard of fall through to the inner store
    • SpaceSyncer reported Partial forever for a member who had never written to the space (Issue #99) — a missing commit was read as "the page stopped short of the head", but an account with no repo state produces one too, so the pass applied nothing, advanced nothing, and reported the outcome documented as "sync again to continue". Partial is now reported only when the pass has somewhere left to go; a page with neither operations nor a cursor is SpaceSyncOutcome.NoRepo. A cursor already standing at a revision takes the existing repair path instead. No signature changed and no enum member was added
    • DID document signing keys are read from the legacy verification-method types too (Issue #98) — both places the SDK pulled a signing key required type == "Multikey", so against a document publishing EcdsaSecp256k1VerificationKey2019 / EcdsaSecp256r1VerificationKey2019 the key came back absent: no permissioned commit could be verified, and FirehoseVerifier treated every commit from such an account as unverifiable. The encodings differ by more than the type string (multicodec-tagged compressed bytes vs a bare uncompressed point), so the point is now compressed and re-tagged with the curve the type names. plc.directory serves Multikey, so production did:plc was never affected; dev-env networks and hand-written did:web documents publish the legacy form. New public API: VerificationMethod.ToDidKey(), DidDocument.GetSigningKey(), DidDocument.GetVerificationKey(fragment), AtProtoCrypto.FormatDidKey(...), AtProtoCrypto.CompressPublicKey(...)
    • DAG-CBOR map keys were sorted bytewise rather than length-first — DRISL orders keys by length and only then by bytes, so {"b":…,"ab":…} must encode b first. Every CID the SDK computed for a record whose keys spanned more than one length was therefore wrong — including any app.bsky.feed.post carrying both text and createdAt — so it would not match the CID the rest of the network computed. DagCborDecoder's matching validation would have rejected valid blocks. Regression-pinned against a post fetched from a live PDS
    • DPoP proofs named the full request URL in htu — RFC 9449 §4.2 requires query and fragment stripped, so every proof for a request carrying a query string — which is every XRPC query — named an htu no conforming resource server would match. Now normalized to origin plus path, which also means one proof covers any query on a path
    • XRPC array query parameters are now sent as repeated keys instead of one comma-joined value — 21 call sites built ?dids=a,b where XRPC specifies ?dids=a&dids=b, so any endpoint taking an array silently saw one malformed element. Affected getPosts, getFeedGenerators, getProfiles, getServices, getRelationships, getStarterPacks, getAccountInfos, queryLabels, getConvoForMembers, getConvoAvailability, searchAccounts, and the eight array filters on queryEvents / querySubjects. Callers passing a single element are unaffected
    • RecordCollection list operations no longer hand back records whose Value is nullListAsync/ListFromAsync suppressed a failed deserialization with value!, so an entry not matching T violated its own required contract and threw a NullReferenceException downstream. They now throw InvalidOperationException naming the record URI and target type, as GetAsync already did
    • A failed per-request client build no longer leaks an ECDSA key handleAtProtoClientFactory.CreateClientForUserAsync built the client and DPoP session before ApplyOAuthSessionAsync took ownership; if that threw, the native key handle survived until finalization. Both are now disposed on the failure path
    • FileAtProtoTokenStore writes are atomic and deletes are serialized against themStoreAsync wrote in place, so a crash mid-write could truncate the file and lose the refresh token; it now writes a temp file and moves it. RemoveAsync now takes the same lock as writes, so a logout racing a rotation cannot leave the just-written file behind. Token files are created owner-only on Unix
    • XRPC error responses that are not an XRPC error envelope now keep their body — the fallback AtProtoHttpException discarded text it had already read, leaving ResponseBody null exactly when it was most useful. The parse failure is also no longer caught with a bare catch (Exception)
    • Non-integral query parameters are formatted with the invariant culture — a client under a culture such as de-DE no longer sends 1,5 where the server expects 1.5
    • Cid.Parse/TryParse documentation matches what they do — both were described as validating, and the type carried a GeneratedRegex nothing called. The dead regex is gone and the summaries say plainly that only null/blank input is rejected
    • AtProtoClientFactory no longer claims a refresh that does not happen — a comment described on-demand refresh in XrpcClient that does not exist, so per-request clients silently never refreshed. The comment now states the actual contract
    • README.md and docs/ audited against the actual public API (Issue #74) — every type, method, parameter, and constant named in the documentation was cross-checked against src/ and tools/. The larger corrections: docs/crypto.md documented an AtProtoKey.Generate / EncodeMultikey / GenerateDidKey / Base58Encode surface that does not exist, a static ServiceAuthGenerator.CreateToken, and an MST taking string CIDs; docs/standard-site.md omitted the repository argument every StandardSiteClient method takes and used record fields that were never in the Lexicon models; docs/ozone.md named moderation events ModerationEvent* instead of ModEvent*. Smaller fixes across api-reference.md, did-resolution.md, firehose.md, identity-types.md, blob-upload.md, video.md, labeler.md, batch-operations.md, server.md, aspnet-core.md, managed-pds.md, oauth.md, and lexicon-codegen.md
    • Documentation added for the 0.5.0 repository-authoring APIs (Issue #74) — CarWriter, RepoCommit/SignedRepoCommit, PlcOperationBuilder, MerkleSearchTree.SerializeProof, Tid.FromInt64/ToInt64, CidComputation.TryDecodeCidString, DidDocument.Context, and XrpcClient.SetAdminCredentials shipped without prose documentation and are now covered. docs/architecture.md also claimed five runtime packages (there are four) and that only the core project generates documentation (all four do)
    • The solution now builds with zero warnings (Issue #72) — beyond CS1591: a missing <param> on XrpcClient.SendWithDPoPRetryAsync (CS1573), an ambiguous <see cref> in PlcOperationBuilder (CS0419), and a missing @using in ServerIntegrationSample (RZ10012)
    Downloads