feat!: typed identifiers, AtDatetime and enumerators for chat.bsky, tools.ozone and site.standard (3/4 of #119) #156

Merged
Grandiras merged 1 commit from issue-119-typed-chat-ozone-site into main 2026-09-24 23:16:13 +00:00
Owner

Part of #119 (3/4: chat.bsky, tools.ozone, site.standard).

Converts Lexicon/Chat/**, Lexicon/Tools/Ozone/** and Lexicon/Site/Standard/** to the conventions part 1 (#154) wrote down in CONTRIBUTING.md: typed identifiers and AtDatetime, one parameter order, IReadOnlyList<T> out and IEnumerable<T> in, ICursorPage<T> on every cursored response, Enumerate* on the shared paginator, and internal single-use request DTOs. StandardSiteClient also gets typed signatures and typed listings. Based on issue-119-typed-foundation (#154).

How to review this

Most of the diff is mechanical. Look at these first:

File What to check
src/ATProtoNet/Lexicon/Site/Standard/StandardSiteClient.cs The one real redesign. Typed signatures. List*Async return RecordPage<T> instead of ListRecordsResponse. New Enumerate*Async methods and Get*Async(AtUri) overloads; an overload rejects a URI of another collection, or one without an rkey, before sending anything.
src/ATProtoNet/RecordCollection.cs Only an extraction: the page mapping in ListFromAsync becomes internal static ToPage, so StandardSiteClient deserializes records the same way (a bad record throws XrpcResponseFormatException). No public change.
tests/ATProtoNet.Tests/Conventions/TypedIdentifierGuardTests.cs The three PR-3 prefixes leave PendingConversions. There are 14 new commented Exceptions; they are the only identifier-named members still typed string (see below).
src/ATProtoNet/Lexicon/Tools/Ozone/Moderation/ModerationClient.cs The biggest reorder: limit and cursor move behind the filters. Also AtDatetime? query filters, and three enumerators.

The rest follows one pattern per file: property types, List<T> → IReadOnlyList<T>, ICursorPage<T> with an explicit Items, public → internal on request DTOs, XML <param> docs, and call sites in tests and docs.

What changed

chat.bsky

  • Typed fields: ChatMemberView.Did/Handle, MessageSender.Did and MessageView/DeletedMessageView.SentAt (AtDatetime).
  • Typed inputs: GetConvoForMembersAsync and GetConvoAvailabilityAsync take IEnumerable<Did>, and SendMessageBatchAsync takes IEnumerable<BatchMessageItem>.
  • Collections: outputs are IReadOnlyList<T>, including MessageInput.Facets.
  • Parameter order: ListConvosAsync(readOnly, status, limit, cursor).
  • Pagination: ICursorPage<T> on ListConvosResponse, GetMessagesResponse and GetLogResponse. New EnumerateConvosAsync, EnumerateMessagesAsync and EnumerateLogAsync.
  • Internal: the 10 request bodies, from SendMessageRequest to RemoveReactionRequest. DeleteChatAccountRequest was never sent (chat.bsky.actor.deleteAccount has no input), so it is removed.
  • rev stays string: the chat Lexicons give it no format. Its doc comment no longer calls it "the repository revision (a TID)".

tools.ozone

  • Moderation models:
    • RepoSubject.Did, and RecordSubject.Uri/Cid.
    • CreatedBy (Did), CreatedAt/UpdatedAt/IndexedAt and the other timestamps (AtDatetime).
    • SubjectBlobCids/BlobCids (IReadOnlyList<Cid>).
    • CreatorHandle/SubjectHandle/SubjectRepoHandle (Handle?), LastReviewedBy (Did?), and RepoView/RepoViewDetail.Did/Handle.
    • EmitEventRequest.SubjectBlobCids/CreatedBy.
  • Moderation client:
    • GetRecordAsync(AtUri, Cid?) and GetRepoAsync(Did).
    • QueryEventsAsync(…, Did? createdBy, AtDatetime? createdAfter/createdBefore, …, IEnumerable<string>? …, limit, cursor).
    • QuerySubjectsAsync(…, Did? lastReviewedBy, …, limit, cursor).
  • Communication, Set, Signature and Team:
    • Typed Did/Handle/AtDatetime fields.
    • DeleteMemberAsync(Did), FindCorrelationAsync(IEnumerable<Did>), and FindRelatedAccountsAsync(Did, limit, cursor).
    • SearchAccountsAsync(IEnumerable<SigDetail>, limit, cursor) (was cursor, limit).
    • AddValuesAsync/DeleteValuesAsync take IEnumerable<string>.
  • Pagination:
    • ICursorPage<T> on QueryEventsResponse, QuerySubjectsResponse, SearchReposResponse, QuerySetsResponse, GetValuesResponse, ListMembersResponse, SearchAccountsResponse and FindRelatedAccountsResponse.
    • New EnumerateEventsAsync, EnumerateSubjectsAsync, EnumerateReposAsync, EnumerateSetsAsync, EnumerateValuesAsync and EnumerateMembersAsync.
    • listTemplates is not cursored.
  • Internal: DeleteTemplateRequest, DeleteSetRequest, AddValuesRequest, DeleteValuesRequest and DeleteMemberRequest.
  • Still public: EmitEventRequest, Create/UpdateTemplateRequest, UpsertSetRequest and Add/UpdateMemberRequest, because public methods accept them.

site.standard

  • Models: DocumentRecord.PublishedAt/UpdatedAt (AtDatetime), Tags (IReadOnlyList<string>?) and SubscriptionRecord.Publication (AtUri). DocumentRecord.Site stays string, because its Lexicon format is uri (at:// or https://).
  • StandardSiteClient:
    • Every method takes AtIdentifier repo and RecordKey/RecordKey? rkey, and Cid? swapRecord.
    • ListPublicationsAsync/ListDocumentsAsync/ListSubscriptionsAsync return RecordPage<T> of typed RecordView<T>.
    • New EnumeratePublicationsAsync/EnumerateDocumentsAsync/EnumerateSubscriptionsAsync.
    • GetPublicationAsync(AtUri)/GetDocumentAsync(AtUri)/GetSubscriptionAsync(AtUri) let you follow a SubscriptionRecord.Publication directly.
    • Still built on RepoClient, with the same method set, so #129 can extend it.

Deliberate strings (guard Exceptions, each commented)

  • Chat rev: ConvoView, MessageView, DeletedMessageView, ConvoLogEntry, LeaveConvoResponse and AcceptConvoResponse. The Lexicon gives it no format.
  • Ozone subject filter: QueryEventsAsync/EnumerateEventsAsync/QuerySubjectsAsync/EnumerateSubjectsAsync(subject). The format is uri: an account is a bare DID and a record an AT URI. This is the same reasoning as part 1's Label.Uri.
  • Template Subject: CommunicationTemplateView/CreateTemplateRequest/UpdateTemplateRequest.Subject hold an email subject line.
  • TeamMember.LastUpdatedBy: the Lexicon gives it no format, and Ozone writes admin_token when an admin token made the change (packages/ozone/src/api/team/addMember.ts).

Breaking changes and migration

  • Literals: Did.Parse("…"), Handle.Parse, AtUri.Parse, Cid.Parse, RecordKey.Parse, and AtDatetime.Now()/Parse/FromDateTimeOffset. Values the API returns pass through unchanged and still convert implicitly to string. A response with an invalid identifier now throws XrpcResponseFormatException.
  • Reordered parameters: positional calls to ListConvosAsync, QueryEventsAsync, QuerySubjectsAsync, SearchAccountsAsync and FindRelatedAccountsAsync no longer compile, or bind differently. Reorder them or name the arguments.
  • StandardSiteClient listings: entry.Value is already the typed record. Drop the Deserialize<T>(…) call.
  • Internal or removed DTOs: call the client method instead. DeleteChatAccountRequest is gone.
  • List<T> → IReadOnlyList<T>: copy a list you need to change ([.. page.Convos]).

CHANGELOG.md covers these in three Breaking changes bullets, and has one Added bullet for the enumerators, the typed site.standard listings and the AT URI overloads.

Verification

  • Full solution build (--no-incremental): 0 warnings, 0 errors.
  • Unit tests: 3187 passed, 0 failed, 5 skipped. The skips are the Aspire manifest tests, as on the base. Part 1 had 3161, so this PR adds 26.
  • New tests:
    • ConvoClientTests:
      • The ListConvosAsync parameter order on the wire.
      • Typed members as repeated did parameters, with typed Did/Handle read back.
      • SendMessageBatchAsync from a lazy sequence.
      • EnumerateConvosAsync walking two pages with filters and pageSize.
      • EnumerateMessagesAsync stopping on a repeated cursor after 2 requests.
      • EnumerateLogAsync stopping when the log stops advancing.
    • TypedOzoneClientTests (new):
      • Typed filters on the wire, including an AtDatetime kept verbatim and one canonicalized.
      • A typed ModEventView read (subject AtUri, blob Cids, Did, Handle, AtDatetime).
      • An invalid DID in a response → XrpcResponseFormatException.
      • Typed GetRecordAsync, and EmitEventAsync body serialization.
      • admin_token in LastUpdatedBy.
      • The limit/cursor order of both signature methods.
      • A theory over all six Ozone enumerators: correct NSID, pageSize → limit, cursors threaded, and a stop on a repeated cursor.
    • StandardSiteClientTests:
      • Typed ListDocumentsAsync (record key, typed value, verbatim AtDatetime).
      • A malformed record → XrpcResponseFormatException.
      • EnumeratePublicationsAsync paging.
      • GetPublicationAsync(AtUri) from a SubscriptionRecord.Publication.
      • A URI of the wrong collection, or without an rkey, rejected before any request.
  • Integration tests, samples, Server and Blazor compile. None of them touch these namespaces.

Wire-fidelity bugs noticed (left for #123, JSON names and HTTP methods untouched)

  • Listed in #123:
    • canConvo → canChat (+ convo), ListConvosAsync(readOnly) → readState, ConvoView.Opened, AcceptConvoResponse.Convo.
    • querySubjects → queryStatuses (subjectStatuses, boolean takendown/appealed).
    • searchAccounts as POST, and AccountResult vs admin.defs#accountView.
    • updateAllRead body and updatedCount, and exportAccountData JSONL.
    • ModerationSubject messageRef/convoRef.
  • Not listed in #123:
    • Unwrapped outputs: MuteConvoAsync, UnmuteConvoAsync and UpdateReadAsync deserialize a ConvoView, but the output is {convo: convoView}. AddReactionAsync and RemoveReactionAsync deserialize a MessageView, but the output is {message: messageView}. Against a real chat service, all five fail on the missing required id.
    • Field mismatches: RecordViewDetail.BlobCids is recordView's field; recordViewDetail has blobs (#blobView[]). ModEventView.subjectBlobCids is required upstream but optional here. modEventLabel.createLabelVals/negateLabelVals and modEventTag.add/remove are required upstream but optional here, so an event built without them is rejected by Ozone.
    • Missing fields and filters:
      • server.getConfig verifierDid.
      • getMessages relatedProfiles.
      • queryEvents filters (collections, subjectType, policies, modTool, batchId, ageAssuranceState, withStrike, includeAllUserRecords).
      • querySets namePrefix/sortBy/sortDirection, and listMembers q/disabled/roles.
    • Minor: GetEventAsync formats id with the current culture (id.ToString()).

Deliberately deferred

  • Nullability: Lexicon-required fields that were already optional here, such as RecordSubject.Cid and the modEvent* arrays, stay nullable. Only types changed. Making them required changes what a response or event must contain, which belongs to #123.
  • No enumerators for SearchAccountsAsync or FindRelatedAccountsAsync: their responses implement ICursorPage<T>, but the brief's list doesn't include them, and searchAccounts is the broken POST above.
  • Messages stay JsonElement: EnumerateMessagesAsync yields raw JsonElement messages, like GetMessagesResponse.Messages. Typing the message union belongs to #123/#128.
  • Edits outside this part's area (minimal):
    • src/ATProtoNet/RecordCollection.cs: the internal ToPage extraction. Part 1's file, not owned by parts 2 or 4.
    • One call site in tests/ATProtoNet.Tests/Http/EmptyResponseBodyTests.cs (DeleteMemberAsync).
    • The StandardSiteClient table in docs/api-reference.md.
    • Shared with parts 2 and 4: the guard's PendingConversions (only the PR-3 lines removed) and Exceptions (entries appended), and CHANGELOG.md (bullets appended after part 1's #119 bullets). Expect trivial merge conflicts there.

🤖 Generated with Claude Code

Part of #119 (3/4: chat.bsky, tools.ozone, site.standard). Converts `Lexicon/Chat/**`, `Lexicon/Tools/Ozone/**` and `Lexicon/Site/Standard/**` to the conventions part 1 (#154) wrote down in `CONTRIBUTING.md`: typed identifiers and `AtDatetime`, one parameter order, `IReadOnlyList<T>` out and `IEnumerable<T>` in, `ICursorPage<T>` on every cursored response, `Enumerate*` on the shared paginator, and internal single-use request DTOs. `StandardSiteClient` also gets typed signatures and typed listings. Based on `issue-119-typed-foundation` (#154). ## How to review this Most of the diff is mechanical. Look at these first: | File | What to check | |---|---| | `src/ATProtoNet/Lexicon/Site/Standard/StandardSiteClient.cs` | The one real redesign. Typed signatures. `List*Async` return `RecordPage<T>` instead of `ListRecordsResponse`. New `Enumerate*Async` methods and `Get*Async(AtUri)` overloads; an overload rejects a URI of another collection, or one without an rkey, before sending anything. | | `src/ATProtoNet/RecordCollection.cs` | Only an extraction: the page mapping in `ListFromAsync` becomes `internal static ToPage`, so `StandardSiteClient` deserializes records the same way (a bad record throws `XrpcResponseFormatException`). No public change. | | `tests/ATProtoNet.Tests/Conventions/TypedIdentifierGuardTests.cs` | The three PR-3 prefixes leave `PendingConversions`. There are 14 new commented `Exceptions`; they are the only identifier-named members still typed `string` (see below). | | `src/ATProtoNet/Lexicon/Tools/Ozone/Moderation/ModerationClient.cs` | The biggest reorder: `limit` and `cursor` move behind the filters. Also `AtDatetime?` query filters, and three enumerators. | The rest follows one pattern per file: property types, `List<T>` → `IReadOnlyList<T>`, `ICursorPage<T>` with an explicit `Items`, `public` → `internal` on request DTOs, XML `<param>` docs, and call sites in tests and docs. ## What changed ### chat.bsky - **Typed fields:** `ChatMemberView.Did`/`Handle`, `MessageSender.Did` and `MessageView`/`DeletedMessageView.SentAt` (`AtDatetime`). - **Typed inputs:** `GetConvoForMembersAsync` and `GetConvoAvailabilityAsync` take `IEnumerable<Did>`, and `SendMessageBatchAsync` takes `IEnumerable<BatchMessageItem>`. - **Collections:** outputs are `IReadOnlyList<T>`, including `MessageInput.Facets`. - **Parameter order:** `ListConvosAsync(readOnly, status, limit, cursor)`. - **Pagination:** `ICursorPage<T>` on `ListConvosResponse`, `GetMessagesResponse` and `GetLogResponse`. New `EnumerateConvosAsync`, `EnumerateMessagesAsync` and `EnumerateLogAsync`. - **Internal:** the 10 request bodies, from `SendMessageRequest` to `RemoveReactionRequest`. `DeleteChatAccountRequest` was never sent (`chat.bsky.actor.deleteAccount` has no input), so it is removed. - **`rev` stays `string`:** the chat Lexicons give it no format. Its doc comment no longer calls it "the repository revision (a TID)". ### tools.ozone - **Moderation models:** - `RepoSubject.Did`, and `RecordSubject.Uri`/`Cid`. - `CreatedBy` (`Did`), `CreatedAt`/`UpdatedAt`/`IndexedAt` and the other timestamps (`AtDatetime`). - `SubjectBlobCids`/`BlobCids` (`IReadOnlyList<Cid>`). - `CreatorHandle`/`SubjectHandle`/`SubjectRepoHandle` (`Handle?`), `LastReviewedBy` (`Did?`), and `RepoView`/`RepoViewDetail.Did`/`Handle`. - `EmitEventRequest.SubjectBlobCids`/`CreatedBy`. - **Moderation client:** - `GetRecordAsync(AtUri, Cid?)` and `GetRepoAsync(Did)`. - `QueryEventsAsync(…, Did? createdBy, AtDatetime? createdAfter/createdBefore, …, IEnumerable<string>? …, limit, cursor)`. - `QuerySubjectsAsync(…, Did? lastReviewedBy, …, limit, cursor)`. - **Communication, Set, Signature and Team:** - Typed `Did`/`Handle`/`AtDatetime` fields. - `DeleteMemberAsync(Did)`, `FindCorrelationAsync(IEnumerable<Did>)`, and `FindRelatedAccountsAsync(Did, limit, cursor)`. - `SearchAccountsAsync(IEnumerable<SigDetail>, limit, cursor)` (was `cursor, limit`). - `AddValuesAsync`/`DeleteValuesAsync` take `IEnumerable<string>`. - **Pagination:** - `ICursorPage<T>` on `QueryEventsResponse`, `QuerySubjectsResponse`, `SearchReposResponse`, `QuerySetsResponse`, `GetValuesResponse`, `ListMembersResponse`, `SearchAccountsResponse` and `FindRelatedAccountsResponse`. - New `EnumerateEventsAsync`, `EnumerateSubjectsAsync`, `EnumerateReposAsync`, `EnumerateSetsAsync`, `EnumerateValuesAsync` and `EnumerateMembersAsync`. - `listTemplates` is not cursored. - **Internal:** `DeleteTemplateRequest`, `DeleteSetRequest`, `AddValuesRequest`, `DeleteValuesRequest` and `DeleteMemberRequest`. - **Still public:** `EmitEventRequest`, `Create`/`UpdateTemplateRequest`, `UpsertSetRequest` and `Add`/`UpdateMemberRequest`, because public methods accept them. ### site.standard - **Models:** `DocumentRecord.PublishedAt`/`UpdatedAt` (`AtDatetime`), `Tags` (`IReadOnlyList<string>?`) and `SubscriptionRecord.Publication` (`AtUri`). `DocumentRecord.Site` stays `string`, because its Lexicon format is `uri` (`at://` or `https://`). - **`StandardSiteClient`:** - Every method takes `AtIdentifier repo` and `RecordKey`/`RecordKey? rkey`, and `Cid? swapRecord`. - `ListPublicationsAsync`/`ListDocumentsAsync`/`ListSubscriptionsAsync` return `RecordPage<T>` of typed `RecordView<T>`. - New `EnumeratePublicationsAsync`/`EnumerateDocumentsAsync`/`EnumerateSubscriptionsAsync`. - `GetPublicationAsync(AtUri)`/`GetDocumentAsync(AtUri)`/`GetSubscriptionAsync(AtUri)` let you follow a `SubscriptionRecord.Publication` directly. - Still built on `RepoClient`, with the same method set, so #129 can extend it. ### Deliberate `string`s (guard `Exceptions`, each commented) - **Chat `rev`:** `ConvoView`, `MessageView`, `DeletedMessageView`, `ConvoLogEntry`, `LeaveConvoResponse` and `AcceptConvoResponse`. The Lexicon gives it no format. - **Ozone `subject` filter:** `QueryEventsAsync`/`EnumerateEventsAsync`/`QuerySubjectsAsync`/`EnumerateSubjectsAsync(subject)`. The format is `uri`: an account is a bare DID and a record an AT URI. This is the same reasoning as part 1's `Label.Uri`. - **Template `Subject`:** `CommunicationTemplateView`/`CreateTemplateRequest`/`UpdateTemplateRequest.Subject` hold an email subject line. - **`TeamMember.LastUpdatedBy`:** the Lexicon gives it no format, and Ozone writes `admin_token` when an admin token made the change (`packages/ozone/src/api/team/addMember.ts`). ## Breaking changes and migration - **Literals:** `Did.Parse("…")`, `Handle.Parse`, `AtUri.Parse`, `Cid.Parse`, `RecordKey.Parse`, and `AtDatetime.Now()`/`Parse`/`FromDateTimeOffset`. Values the API returns pass through unchanged and still convert implicitly to `string`. A response with an invalid identifier now throws `XrpcResponseFormatException`. - **Reordered parameters:** positional calls to `ListConvosAsync`, `QueryEventsAsync`, `QuerySubjectsAsync`, `SearchAccountsAsync` and `FindRelatedAccountsAsync` no longer compile, or bind differently. Reorder them or name the arguments. - **`StandardSiteClient` listings:** `entry.Value` is already the typed record. Drop the `Deserialize<T>(…)` call. - **Internal or removed DTOs:** call the client method instead. `DeleteChatAccountRequest` is gone. - **`List<T>` → `IReadOnlyList<T>`:** copy a list you need to change (`[.. page.Convos]`). `CHANGELOG.md` covers these in three `Breaking changes` bullets, and has one `Added` bullet for the enumerators, the typed `site.standard` listings and the AT URI overloads. ## Verification - Full solution build (`--no-incremental`): **0 warnings, 0 errors**. - Unit tests: **3187 passed, 0 failed, 5 skipped**. The skips are the Aspire manifest tests, as on the base. Part 1 had 3161, so this PR adds 26. - **New tests:** - **`ConvoClientTests`:** - The `ListConvosAsync` parameter order on the wire. - Typed `members` as repeated `did` parameters, with typed `Did`/`Handle` read back. - `SendMessageBatchAsync` from a lazy sequence. - `EnumerateConvosAsync` walking two pages with filters and `pageSize`. - `EnumerateMessagesAsync` stopping on a repeated cursor after 2 requests. - `EnumerateLogAsync` stopping when the log stops advancing. - **`TypedOzoneClientTests`** (new): - Typed filters on the wire, including an `AtDatetime` kept verbatim and one canonicalized. - A typed `ModEventView` read (subject `AtUri`, blob `Cid`s, `Did`, `Handle`, `AtDatetime`). - An invalid DID in a response → `XrpcResponseFormatException`. - Typed `GetRecordAsync`, and `EmitEventAsync` body serialization. - `admin_token` in `LastUpdatedBy`. - The `limit`/`cursor` order of both signature methods. - A theory over all six Ozone enumerators: correct NSID, `pageSize` → `limit`, cursors threaded, and a stop on a repeated cursor. - **`StandardSiteClientTests`:** - Typed `ListDocumentsAsync` (record key, typed value, verbatim `AtDatetime`). - A malformed record → `XrpcResponseFormatException`. - `EnumeratePublicationsAsync` paging. - `GetPublicationAsync(AtUri)` from a `SubscriptionRecord.Publication`. - A URI of the wrong collection, or without an rkey, rejected before any request. - Integration tests, samples, Server and Blazor compile. None of them touch these namespaces. ## Wire-fidelity bugs noticed (left for #123, JSON names and HTTP methods untouched) - **Listed in #123:** - `canConvo` → `canChat` (+ `convo`), `ListConvosAsync(readOnly)` → `readState`, `ConvoView.Opened`, `AcceptConvoResponse.Convo`. - `querySubjects` → `queryStatuses` (`subjectStatuses`, boolean `takendown`/`appealed`). - `searchAccounts` as POST, and `AccountResult` vs `admin.defs#accountView`. - `updateAllRead` body and `updatedCount`, and `exportAccountData` JSONL. - `ModerationSubject` `messageRef`/`convoRef`. - **Not listed in #123:** - **Unwrapped outputs:** `MuteConvoAsync`, `UnmuteConvoAsync` and `UpdateReadAsync` deserialize a `ConvoView`, but the output is `{convo: convoView}`. `AddReactionAsync` and `RemoveReactionAsync` deserialize a `MessageView`, but the output is `{message: messageView}`. Against a real chat service, all five fail on the missing required `id`. - **Field mismatches:** `RecordViewDetail.BlobCids` is `recordView`'s field; `recordViewDetail` has `blobs` (`#blobView[]`). `ModEventView.subjectBlobCids` is required upstream but optional here. `modEventLabel.createLabelVals`/`negateLabelVals` and `modEventTag.add`/`remove` are required upstream but optional here, so an event built without them is rejected by Ozone. - **Missing fields and filters:** - `server.getConfig` `verifierDid`. - `getMessages` `relatedProfiles`. - `queryEvents` filters (`collections`, `subjectType`, `policies`, `modTool`, `batchId`, `ageAssuranceState`, `withStrike`, `includeAllUserRecords`). - `querySets` `namePrefix`/`sortBy`/`sortDirection`, and `listMembers` `q`/`disabled`/`roles`. - **Minor:** `GetEventAsync` formats `id` with the current culture (`id.ToString()`). ## Deliberately deferred - **Nullability:** Lexicon-required fields that were already optional here, such as `RecordSubject.Cid` and the `modEvent*` arrays, stay nullable. Only types changed. Making them `required` changes what a response or event must contain, which belongs to #123. - **No enumerators for `SearchAccountsAsync` or `FindRelatedAccountsAsync`:** their responses implement `ICursorPage<T>`, but the brief's list doesn't include them, and `searchAccounts` is the broken POST above. - **Messages stay `JsonElement`:** `EnumerateMessagesAsync` yields raw `JsonElement` messages, like `GetMessagesResponse.Messages`. Typing the message union belongs to #123/#128. - **Edits outside this part's area** (minimal): - `src/ATProtoNet/RecordCollection.cs`: the internal `ToPage` extraction. Part 1's file, not owned by parts 2 or 4. - One call site in `tests/ATProtoNet.Tests/Http/EmptyResponseBodyTests.cs` (`DeleteMemberAsync`). - The `StandardSiteClient` table in `docs/api-reference.md`. - Shared with parts 2 and 4: the guard's `PendingConversions` (only the PR-3 lines removed) and `Exceptions` (entries appended), and `CHANGELOG.md` (bullets appended after part 1's #119 bullets). Expect trivial merge conflicts there. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat!: typed identifiers and enumerators for chat.bsky, tools.ozone and site.standard (part of #119)
All checks were successful
CI / pds-integration (pull_request) Successful in 27s
CI / build-and-test (pull_request) Successful in 59s
d92aef84e8
Part 3 of 4 of the typed public API. Applies the conventions part 1
recorded in CONTRIBUTING.md to Lexicon/Chat, Lexicon/Tools/Ozone and
Lexicon/Site/Standard:

- Did, Handle, AtIdentifier, AtUri, Cid, RecordKey and AtDatetime
  wherever the Lexicon field carries that format, in models and client
  parameters. IReadOnlyList<T> outputs and IEnumerable<T> inputs.
- One parameter order: ListConvosAsync, QueryEventsAsync,
  QuerySubjectsAsync and the signature searches take their filters
  before limit and cursor.
- ICursorPage<T> on every cursored response, with Enumerate* on the
  shared paginator for convos, messages, the convo log, Ozone events,
  subjects, repos, sets, set values and team members.
- The chat and Ozone single-use request bodies are internal. The unused
  DeleteChatAccountRequest is removed.
- StandardSiteClient takes typed repositories and record keys, and its
  list methods return RecordPage<T> of typed records instead of
  JsonElement values. Enumerators and AT URI Get overloads are added.
  RecordCollection's page mapping is extracted so both deserialize
  records the same way.

The chat rev, Ozone's subject filter (a DID or an AT URI), email subject
lines and TeamMember.LastUpdatedBy (which can be admin_token) stay
string. They are commented exceptions in the guard test, and its
pending list drops these three namespaces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

@claude /review — large and mostly mechanical; the description opens with How to review this.

@claude /review — large and mostly mechanical; the description opens with *How to review this*.
Collaborator

🤖 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.

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/594); the reply will link the full session transcript. <!-- claude-run:pr_review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-156/run-594.md) — updating every ~5 minutes while the task runs.
claude-reviewer approved these changes 2026-09-24 23:11:53 +00:00
Dismissed
claude-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

This is a large, mechanical conversion PR, and it holds up well on inspection. The pieces flagged by the author as worth the closest look all check out:

  • StandardSiteClient: RecordKeyOf(AtUri, Nsid) correctly rejects a URI whose Collection doesn't match or whose RecordKey is null before any request is sent (AtUri is a sealed record, so Collection/RecordKey are reference-typed nullable properties and the pattern match is { } rkey behaves as intended). The new Get*Async(AtUri) overloads delegate to the existing (AtIdentifier, RecordKey) overloads without recursion or ambiguity risk.
  • RecordCollection.ToPage: a clean, behavior-preserving extraction; StandardSiteClient's new ListAsync<T> private helper reuses it correctly (same assembly, so internal access works), preserving the XrpcResponseFormatException-on-bad-record behavior.
  • ModerationClient: the limit/cursor reorder is applied consistently across QueryEventsAsync/EnumerateEventsAsync/QuerySubjectsAsync/EnumerateSubjectsAsync, with the Enumerate* wrappers forwarding every filter parameter in the same order as the corresponding Query*Async/List*Async/Search*Async method, so pagination composes correctly.
  • Typed identifiers: Did/Handle/AtUri/Cid/AtDatetime are applied consistently to fields the Lexicon marks with that format, while the documented exceptions (chat rev, Ozone subject filter, template Subject, TeamMember.LastUpdatedBy) are left as plain strings with a clear rationale, matching the corresponding guard-test entries.
  • Breaking parameter reorders (ListConvosAsync, SearchAccountsAsync, FindRelatedAccountsAsync, etc.) are reflected in the CHANGELOG.md bullets added in this same commit, satisfying the repo's changelog policy.

I didn't find correctness bugs, security issues, or notable omissions in the reviewed portion of the diff (the diff is truncated at the end, but the visible material is consistent and well-tested per the PR's own verification section). The many wire-fidelity issues the author found (unwrapped chat.bsky outputs, missing filters, etc.) are explicitly called out and deferred to #123 rather than silently left as bugs, which is the right call for a typing-only PR.


⏱ 1m 20s · 8 turns · tokens: 86.7k in / 7.7k out (+767.2k cache-read)
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. This is a large, mechanical conversion PR, and it holds up well on inspection. The pieces flagged by the author as worth the closest look all check out: - **`StandardSiteClient`**: `RecordKeyOf(AtUri, Nsid)` correctly rejects a URI whose `Collection` doesn't match or whose `RecordKey` is null before any request is sent (`AtUri` is a `sealed record`, so `Collection`/`RecordKey` are reference-typed nullable properties and the pattern match `is { } rkey` behaves as intended). The new `Get*Async(AtUri)` overloads delegate to the existing `(AtIdentifier, RecordKey)` overloads without recursion or ambiguity risk. - **`RecordCollection.ToPage`**: a clean, behavior-preserving extraction; `StandardSiteClient`'s new `ListAsync<T>` private helper reuses it correctly (same assembly, so `internal` access works), preserving the `XrpcResponseFormatException`-on-bad-record behavior. - **`ModerationClient`**: the `limit`/`cursor` reorder is applied consistently across `QueryEventsAsync`/`EnumerateEventsAsync`/`QuerySubjectsAsync`/`EnumerateSubjectsAsync`, with the `Enumerate*` wrappers forwarding every filter parameter in the same order as the corresponding `Query*Async`/`List*Async`/`Search*Async` method, so pagination composes correctly. - **Typed identifiers**: `Did`/`Handle`/`AtUri`/`Cid`/`AtDatetime` are applied consistently to fields the Lexicon marks with that format, while the documented exceptions (chat `rev`, Ozone `subject` filter, template `Subject`, `TeamMember.LastUpdatedBy`) are left as plain strings with a clear rationale, matching the corresponding guard-test entries. - Breaking parameter reorders (`ListConvosAsync`, `SearchAccountsAsync`, `FindRelatedAccountsAsync`, etc.) are reflected in the `CHANGELOG.md` bullets added in this same commit, satisfying the repo's changelog policy. I didn't find correctness bugs, security issues, or notable omissions in the reviewed portion of the diff (the diff is truncated at the end, but the visible material is consistent and well-tested per the PR's own verification section). The many wire-fidelity issues the author found (unwrapped `chat.bsky` outputs, missing filters, etc.) are explicitly called out and deferred to #123 rather than silently left as bugs, which is the right call for a typing-only PR. --- ⏱ 1m 20s · 8 turns · tokens: 86.7k in / 7.7k out (+767.2k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-156/run-594.md) <!-- claude-verdict:approve --> <!-- claude-reviewed-head:d92aef84e87c63fab7817421f8d01f87f9585997 -->
Author
Owner

Rebased onto main after #155 merged. The conflicts were CHANGELOG plus the guard test's pending-conversions list, where both PRs removed their own entries. Outside CHANGELOG, the patch now differs from the approved head d92aef84 only in that list's context lines. 3203 unit tests pass locally. Requesting a fresh review, per the policy that only CHANGELOG-only rebases carry an approval.

@claude /review

Rebased onto main after #155 merged. The conflicts were CHANGELOG plus the guard test's pending-conversions list, where both PRs removed their own entries. Outside CHANGELOG, the patch now differs from the approved head `d92aef84` only in that list's context lines. 3203 unit tests pass locally. Requesting a fresh review, per the policy that only CHANGELOG-only rebases carry an approval. @claude /review
Grandiras force-pushed issue-119-typed-chat-ozone-site from d92aef84e8
All checks were successful
CI / pds-integration (pull_request) Successful in 27s
CI / build-and-test (pull_request) Successful in 59s
to fb029940f5
All checks were successful
CI / pds-integration (pull_request) Successful in 26s
CI / build-and-test (pull_request) Successful in 1m0s
Sync Closures to GitHub / sync-closure (pull_request) Successful in 7s
2026-09-24 23:13:26 +00:00
Compare
Collaborator

🤖 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.

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/595); the reply will link the full session transcript. <!-- claude-run:pr_review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-156/run-595.md) — updating every ~5 minutes while the task runs.
claude-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

Re-reviewed against my prior approve pass; nothing that changes that assessment. Re-checked the areas the PR calls out as worth attention:

  • Typed identifiers via implicit conversions: ModerationClient, SignatureClient, TeamClient, etc. pass Did?/AtUri/Cid? values directly into XrpcParams.Add(string, string?) — this compiles and works correctly because Did, AtUri, Cid, Handle, etc. all define implicit operator string?, so no manual .Value/.ToString() calls are needed and none were missed.
  • Pagination.EnumerateAsync (unchanged, from part 1) correctly backs every new Enumerate* method here: it stops on a null/empty cursor or a repeated one, which matches the new EnumerateLogAsync/EnumerateMessagesAsync tests (repeated-cursor and log-stops-advancing cases both terminate correctly after tracing through the loop).
  • ModerationClient.EnumerateEventsAsync/EnumerateSubjectsAsync forward every filter parameter to the corresponding Query*Async method in the exact same order the method declares them, so the limit/cursor reorder composes correctly with pagination.
  • StandardSiteClient: the AtUri overloads and RecordKeyOf guard, and the RecordCollection.ToPage extraction reused via the new private ListAsync<T> helper, are unchanged from my last pass and still correct.
  • Typed model fields (Did, Handle, AtUri, Cid, AtDatetime) are applied consistently, and the documented exceptions (chat rev, Ozone subject filter, template Subject, TeamMember.LastUpdatedBy) match their guard-test entries.

One purely cosmetic nit, not worth blocking on: the CHANGELOG.md hunk leaves three blank lines before ### Added and before ### Changed (two added on top of the pre-existing one) instead of the single blank line used elsewhere in the file.

No correctness bugs, security issues, or omissions found in the reviewed diff.


⏱ 1m 20s · 11 turns · tokens: 94.6k in / 5.6k out (+1108.4k cache-read)
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. Re-reviewed against my prior approve pass; nothing that changes that assessment. Re-checked the areas the PR calls out as worth attention: - **Typed identifiers via implicit conversions**: `ModerationClient`, `SignatureClient`, `TeamClient`, etc. pass `Did?`/`AtUri`/`Cid?` values directly into `XrpcParams.Add(string, string?)` — this compiles and works correctly because `Did`, `AtUri`, `Cid`, `Handle`, etc. all define `implicit operator string?`, so no manual `.Value`/`.ToString()` calls are needed and none were missed. - **`Pagination.EnumerateAsync`** (unchanged, from part 1) correctly backs every new `Enumerate*` method here: it stops on a null/empty cursor or a repeated one, which matches the new `EnumerateLogAsync`/`EnumerateMessagesAsync` tests (repeated-cursor and log-stops-advancing cases both terminate correctly after tracing through the loop). - **`ModerationClient.EnumerateEventsAsync`/`EnumerateSubjectsAsync`** forward every filter parameter to the corresponding `Query*Async` method in the exact same order the method declares them, so the `limit`/`cursor` reorder composes correctly with pagination. - **`StandardSiteClient`**: the `AtUri` overloads and `RecordKeyOf` guard, and the `RecordCollection.ToPage` extraction reused via the new private `ListAsync<T>` helper, are unchanged from my last pass and still correct. - Typed model fields (`Did`, `Handle`, `AtUri`, `Cid`, `AtDatetime`) are applied consistently, and the documented exceptions (chat `rev`, Ozone `subject` filter, template `Subject`, `TeamMember.LastUpdatedBy`) match their guard-test entries. One purely cosmetic nit, not worth blocking on: the `CHANGELOG.md` hunk leaves three blank lines before `### Added` and before `### Changed` (two added on top of the pre-existing one) instead of the single blank line used elsewhere in the file. No correctness bugs, security issues, or omissions found in the reviewed diff. --- ⏱ 1m 20s · 11 turns · tokens: 94.6k in / 5.6k out (+1108.4k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-156/run-595.md) <!-- claude-verdict:approve --> <!-- claude-reviewed-head:fb029940f541cc91796ecb726d2af5484db026ca -->
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the review runs.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/ATProto.NET/pr-156/run-2026-09-24T23-15-39-562Z.md) — updating every ~5 minutes while the review runs.
opencode-reviewer left a comment

💬 Verdict: comment — the reviewer did not submit a structured review; the raw output follows.

(empty)


⏱ 0m 28s · 2 steps · $0.0080 · tokens: 45.5k in / 27 out (+0 cache-read) · Go: 5h 2% · wk 99% · mo 82%
📜 Full session transcript

💬 **Verdict: comment** — the reviewer did not submit a structured review; the raw output follows. (empty) --- ⏱ 0m 28s · 2 steps · $0.0080 · tokens: 45.5k in / 27 out (+0 cache-read) · Go: 5h 2% · wk 99% · mo 82% 📜 [Full session transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/ATProto.NET/pr-156/run-2026-09-24T23-15-39-562Z.md)
Collaborator

🔧 Let opencode-bot fix this — tick the box and opencode-bot picks up the review above, on this PR branch. Nothing runs until you do.

  • @opencode-bot Address the findings of the latest review on this pull request.
🔧 **Let opencode-bot fix this** — tick the box and opencode-bot picks up the review above, on this PR branch. Nothing runs until you do. - [ ] @opencode-bot Address the findings of the latest review on this pull request. <!-- opencode-fix-button:156 -->
Grandiras deleted branch issue-119-typed-chat-ozone-site 2026-09-24 23:16:13 +00:00
Sign in to join this conversation.
No description provided.