feat!: typed identifiers for Spaces, the space server and streaming (4/4 of #119) #157
No reviewers
Labels
No labels
breaking-change
bug
documentation
duplicate
enhancement
good first issue
help wanted
performance
question
wontfix
No milestone
No project
No assignees
4 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!157
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-119-typed-spaces-streaming"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #119 (4/4 — the last part: Spaces, server and streaming).
The temporary
PendingConversionsallow-list inTypedIdentifierGuardTestsis now empty and has been removed along with its test, so the guard enforces typed identifiers across the whole public surface with only the documented per-member exceptions.Stacked on #154 (part 1), which sets the conventions this PR follows (
CONTRIBUTING.md→ Lexicon models and clients).How to review this
Most of the 96 files are mechanical: a
stringbecomesDid/Nsid/RecordKey/Tid/Cid/SpaceUri, and the call sites and tests follow. The parts worth reading closely:src/ATProtoNet/Spaces/SpaceUri.cs,SpaceCommit.cs,SpaceRepoCar.cs— the typed space components,SpaceCommitContext(SpaceUri, Did, Tid), and the two places where typing adds validation (SpaceRepoCar.Verifyrefuses an index path that is notNSID/rkeyor a link that is not a CID). The commit-context encoding still hashes the exact text, so the reference vector test is unchanged.src/ATProtoNet/Lexicon/Com/AtProto/Sync/SyncModels.cs— the firehose event models and the newRepoOpActionenum.src/ATProtoNet/Streaming/JetstreamEventParser.cs/JetstreamSegmentReader.cs— what the forward-tolerant parsers do with a value that does not parse (skip the commit forcollection/rkey, null outrev/handle).src/ATProtoNet.Server/Spaces/SpaceRequestValidation.csand the endpoint files — query parameters and request bodies now bind through the identifier types, so the per-format string validators collapse to a presence check.tests/ATProtoNet.Tests/Conventions/TypedIdentifierGuardTests.cs— my allow-list lines are gone; the newExceptionsentries are the members that genuinely carry something other than one format.CHANGELOG.md.Everything else — tests, samples, docs, the EF stores converting at the boundary — follows from those.
What changed
Spaces (
com.atproto.space.*,com.atproto.simplespace.*,ATProtoNet.Spaces)SpaceUri(Lexiconspace-ref),SpaceRecordUri(theat-uriof a space record),Did,Nsid,RecordKey,Tid,Cid;RegisterNotifyResponse.ExpiresAtis anAtDatetime; collections areIReadOnlyList<T>.SpaceUri.Authority/SpaceType/SkeyandSpaceRecordUri.Author/Collection/Rkeyare typed;SpaceUri.Create/Recordtake typed components (so they can no longer fail).SpaceCommitContext,SignedSpaceCommit.Rev,SpaceRepoOp,SpaceRepoRecord,SpaceRepoCommit,VerifiedSpaceRepo.Index,SpaceRepoCar.Verify,SpaceSyncer(resolver takes aDid),SpaceSyncResult,SpaceRepoCursor,ISpaceRepoStore,SpaceCredentialProvider(+HostResolver),SpaceTypeDeclaration.Collections,SpaceAuthority.HostAudience/ParseServiceIdentifier,SpaceTokens.Verify(expectedSubject).ICursorPage<T>;ListRecordsAsyncandListRepoOpsAsynctake filters beforelimit/cursor; thestring/SpaceUrioverload pairs and theToSpaceUri()/ToRecordUri()helpers are gone; client-only request bodies (CreateSpaceRecordRequest,PutSpaceRecordRequest,DeleteSpaceRecordRequest,ApplySpaceWritesRequest,NotifySpaceDeletedRequest) are internal. The other request bodies stay public because the public server endpoints accept them (#133 makes those internal).EnumerateReposAsync,EnumerateRecordsAsync(+reverse),EnumerateMembersAsync, and newEnumerateSpacesAsync/EnumerateBlobsAsync. NewGetRecordAsync(SpaceRecordUri)/DeleteRecordAsync(SpaceRecordUri).Space server (
ATProtoNet.Server.Spaces)ISpaceRepoHost,ISpaceAuthorityStore,ISimpleSpaceStore,SimpleSpaceRecord.Owner,SpaceAccessRequest,Verified*records,ISpaceCallerResolver,ISpaceDidDocumentResolver(+CachingSpaceDidDocumentResolver.Invalidate),ISpaceServiceAuthVerifier(Didissuer,Nsidmethod),ISimpleSpaceManagingAppClient,SpaceWriteNotifier, the query-parameter classes andSpaceServerOptions.ServiceDid.InvalidRequestnaming the parameter), sosinceonlistRepoOps/listBlobsandcidongetBlob, which were passed through unchecked, are now validated too.Streaming
subscribeReposmodels:CommitEvent.RepoDid,CommitCid,Rev/SinceTid/Tid?,PrevDataCid?,BlobsIReadOnlyList<Cid>?,OpsIReadOnlyList<RepoOp>?;RepoOp.Cid/PrevCid?andActiona newRepoOpActionenum with the same members asJetstreamOperation; the account/identity/sync/handle/tombstone events carryDid/Handle/Tid;FirehoseMessage.TimeAtDatetime?.JetstreamCommitEvent.CollectionNsid,RKey→Rkey(RecordKey),RevTid?;JetstreamIdentityEvent.HandleHandle?; the events'TimeAtDatetime?;WantedDids/JetstreamSnapshotRequest.DidsIReadOnlyList<Did>;JetstreamArchiveRow.RKey→Rkey;ListAllSegmentsAsync→EnumerateSegmentsAsyncon the shared paginator;JetstreamSegmentPage : ICursorPage<T>.Auth/ServiceAuthGeneratorDidservice DID,Nsid?lxm; the audience stays a string (a DID with an optional#servicefragment) and is now validated as one.Guard test
ServiceAuthGeneratorlines fromPendingConversions. The mechanism stays: parts 2 and 3 still own their lines.Exceptions:SpaceToken.Issuer/SubjectandSpaceTokens.Create(issuer/subject)(a DID or an OAuth client ID),ISpaceReplayStore.TryConsumeAsync(issuer)(also a DPoP thumbprint), the DPoPhtuURL members,WantedCollections/JetstreamSnapshotRequest.Collections(acceptapp.bsky.graph.*wildcards), andJetstreamArchiveRow.Did/Collection/Rkey/Rev(raw archive columns;ToEvent()types them).Breaking changes and migration
created.Uri,writer.Did); parse literals once (Nsid.Parse("com.example.bookmark"),Did.Parse(…),options.ServiceDid = Did.Parse(…)); name the reorderedListRecordsAsync/ListRepoOpsAsyncarguments; read.Uriinstead ofToSpaceUri()/ToRecordUri(); implementISpaceRepoStore/ISpaceRepoHost/stores with the typed signatures.op.Action == RepoOpAction.Createinstead of"create";RKey→Rkey;ListAllSegmentsAsync→EnumerateSegmentsAsync;WantedDids = [Did.Parse(…)].new ServiceAuthGenerator(Did.Parse(…), key),CreateToken(aud, Nsid.Parse(…)).null, as for any malformed frame); a Jetstream commit with an unparseablecollection/rkeyis skipped;SpaceTypeDeclaration.FromLexiconthrowsJsonExceptionfor a non-NSID collection (e.g.*).Fixed
SpaceClientTests.Enumerators_HostRepeatingItsCursor_StopAfterTheRepeat(all five space enumerators) andJetstreamArchiveClientTests.EnumerateSegmentsAsync_ServerRepeatingItsCursor_StopsAfterTheRepeat; each fails against the old loop (it keeps requesting) and passes with exactly two requests.Verification
dotnet build --no-incrementalof the whole solution: 0 warnings, 0 errors (samples and integration tests included).TypedIdentifierGuardTests: no violations in my namespaces outside the documented exceptions;Exceptions_AllStillMatchAMemberandPendingConversions_AllStillHaveViolationspass.repo/commit/rev; Jetstream commits with a malformedcollection/rkey/revand an identity with a malformed handle;SpaceRepoCar.Verifywith an index path that is notNSID/rkey;SpaceTypeDeclarationwith a wildcard collection; delegation and service-auth tokens whoseissis not a DID;ServiceAuthGeneratoraudience validation; the enumerator loops above. TheSpaceCommitContextreference vector (from@atproto/space) still passes unchanged, and the unambiguous-encoding test now uses two contexts that concatenate to the same text through a colon-bearing space key and DID.bafyreiabc,3kaa,rev123, …) now uses well-formed ones, since the models validate them.Deliberately not done
SpaceRepoCar.Verifyand the NSID-onlySpaceTypeDeclaration.Collectionsoverlap two #133 items (each index key exactly one validatedNSID/rkey;collectionsmust not contain*); #133 can tick them after review.TypedFirehoseConsumerOptions.CollectionFilterstaysIReadOnlySet<string>(#126 reworks the filter to peekops[].pathbefore deserializing); no commonIRecordEventabstraction;JetstreamOperationandRepoOpActionstay separate enums with identical members rather than one shared type, to keep this a type conversion.EnumerateRepoOpsAsync: an oplog page carries the head commit, which an item enumerator would drop;SpaceSynceris the consumer of that endpoint.docs/firehose.md"With Cursor" example still readsmessage.Seqoff a rawFirehoseFrame, which has no such member; pre-existing and outside the APIs this PR changes.🤖 Generated with Claude Code
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
@claude /review — large and mostly mechanical. The description has How to review this. The non-mechanical parts are the server-side parameter binding (identifiers are now validated at bind time),
RepoOpAction, andServiceAuthGenerator's audience validation.🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
⏳ Held on the OpenCode Go window — the 5-hour window is at 2% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-25T03:54:33.092Z) — nothing to re-trigger.
✅ Verdict: approve — no blocking issues found.
Spot-checked the six areas the PR description calls out for close reading, plus a few of the mechanical conversions, against the actual branch content (not just the diff text):
SpaceRepoCar.Verify/SpaceRepoRecord/DecodeIndex— correctly refuses an index path that doesn't split into a valid NSID/rkey pair and an index link that isn't a CID, exactly as described.Cid.Valueis used where DAG-CBOR encoding still needs the raw string.SpaceCommit.cs—SpaceCommitContext.Encodestill hashes.Value/.Valuetext for space/author/rev, so the reference vector is preserved;SpaceCommitVerifiercomparescommit.Rev != context.Revusing the newTidequality instead ofstring.Equals, which is equivalent.JetstreamEventParser—ParseCommitFieldsnow skips the commit (returns null) whencollection/rkeydon't parse, andParseTid/ParseDatetimenull outrev/optional timestamps rather than failing the whole event, matching the documented forward-tolerant behavior.SyncModels.cs— the firehose event models are typed consistently (Did,Cid,Tid,Handle, newRepoOpActionenum with the same members asJetstreamOperation); a malformed field on arequiredproperty will throw during JSON binding and get treated as a malformed frame upstream, consistent with the write-up.SpaceRequestValidation— the collapse to a single genericRequire<T>presence check is correct because the identifier types now do their own format validation during model binding; verified this is consistent withDid,Nsid,SpaceUri, etc. all being reference types (sealed record ... : IIdentifier<T>), so thewhere T : classconstraint is safe.TypedIdentifierGuardTests— thePendingConversionsallow-list and its supporting test are fully removed, and the newExceptionsentries genuinely describe fields carrying more than one identifier format (space tokens' dual DID/OAuth-client-IDiss/sub, DPoPhtuURLs, Jetstream wildcard collection filters, raw archive row columns pre-ToEvent()).RecordKey.NewTid()andSpaceUri.Create(Did, Nsid, RecordKey)referenced fromCreateSimpleSpaceEndpointactually exist in the branch and match how they're called.No correctness issues found in the sampled surface. The typed-identifier conversions in the EF Core stores, in-memory stores, and endpoints follow the same patterns established in the prior three PRs in this series, and the equality/comparison rewrites (
space.Owner == caller,rev.CompareTo(existing.Rev), etc.) are sound given these aresealed recordtypes with value equality. I did not execute the build/test suite myself, but the diff is internally consistent with the PR's own verification claims.⏱ 2m 24s · 18 turns · tokens: 107.0k in / 9.3k out (+2001.2k cache-read)
📜 Full session transcript