Add support for the Jetstream v2 archive/replay API #85

Closed
opened 2026-08-14 01:48:50 +00:00 by claude-bot · 2 comments
Collaborator

Follow-up to #83 / #84, which implemented the Jetstream v2 live tail and explicitly left the archive out of scope (docs/jetstream.mdHistorical Replay).

Jetstream v2 is a “full-network archive, replay, and streaming service” — the WebSocket tail is only one of three ways to consume it. The other two are worth having in the SDK, because between them they cover the thing every app actually needs: the records that already exist and every new one, with no gap at the seam.

Mode Transport Auth Status
Live WebSocket (subscribeEvents) none done in #84
Replay HTTP plan + download, then cut over to the live tail API key on the HTTP calls this issue
Snapshot HTTP only, no live tail API key this issue

How replay works

Replay is stateless on the server — no per-consumer cursor, no registered subscription. Three steps, all plain HTTP plus one WebSocket:

  1. Page a plan. POST network.bsky.jetstream.planSnapshot with dids / collections (exact NSIDs or app.bsky.feed.* wildcards) and an optional afterSeq/beforeSeq window. The first response reports sealedTipSeq; pin it as S for the whole backfill. Each page also reports plannedThroughSeq; while plannedThroughSeq < S, re-plan with afterSeq = plannedThroughSeq, beforeSeq = S so the range never floats. Large plans truncate at a whole segment or block-range boundary, and at least one work unit is always admitted, so progress is guaranteed.
  2. Download the archive. Each planned segment comes back as mode: "segment" (fetch the whole file with getSegment) or mode: "blocks" (fetch the listed block ranges with getBlock). Responses are immutable, ETag'd and CDN-cacheable, and getSegment honours HTTP Range, so downloads parallelise freely and resume exactly where they stopped. The planner works from bloom filters and per-block summaries — no false negatives, but it may hand back blocks with no matching rows, so the client applies the exact dids/collections filter to what it decodes.
  3. Tail from the tip. Connect the live socket once at ?cursor=S. The cursor is inclusive, so deduplicate by sequence number. The server replays the window between the plan and the socket itself, so there's no buffer to drain and nothing lost in the handoff.

network.bsky.jetstream.listSegments rounds it out for a raw archive mirror (name, index, sizeBytes, checksum, eventCount, minSeq/maxSeq, minWitnessedAt/maxWitnessedAt).

The .jss decoder

This is the bulk of the work, and the reason it didn't ride along with #84. A Jetstream sealed segment is a custom columnar binary format:

  • 256-byte fixed header (jss0 magic, xxhash3 checksum, version, block/event/DID counts, seq and witnessed-at bounds, and offsets for the footer, DID bloom, per-block bloom, collection index and block index).
  • A run of blocks, each an 8-byte LE length prefix followed by one zstd frame (≈4096 events, operator-configurable).
  • A variable-length footer: 52-byte block index entries, a segment-wide DID bloom filter (gloom.Filter, 0.1% FP), per-block blooms, and a collection index.

Inside a decompressed block: a uint32 event count, fixed-size columns (seq, witnessed_at, indexed_at, kind, the four length columns, event_len), then concatenated variable-length columns (collections, dids, rkeys, revs, and raw CBOR payloads). kind is a uint8 discriminator (1 create, 2 update, 3 delete, 4 identity, …). indexed_at is the display timestamp handed to clients as time_us, falling back to witnessed_at when 0.

Two behaviours worth encoding in the design rather than discovering later:

  • Sealed segments are immutable only between compactions. The server periodically rewrites them to physically drop deleted records, and rewritten files get new checksums. A mirror must re-list and compare checksums rather than assume a segment never changes.
  • The stream is folded, not filtered. Replay delivers every matching event at least once in seq order, including creates that a later delete supersedes. Consumers converge by folding (create adds, update replaces, delete removes; an account event with active: false / status: "deleted", or a sync divergence marker, removes all of that account's records). Writes should be idempotent, keyed on the at:// URI. Account-level events carry no collection and are delivered even to a collection-filtered consumer — same rule the live tail already follows.

Auth and metering

The HTTP endpoints are metered on Bluesky-hosted instances (the live WebSocket stays unauthenticated and unmetered):

  • Authorization: Bearer <key>; a missing/malformed/revoked key returns 401 with {"error":"invalid bearer credential"}.
  • Metered in response bytes on the wire, not requests. Over quota is 429 + {"error":"byte limit exceeded"} and a Retry-After header; the quota refills continuously rather than resetting on a boundary.
  • Running out mid-download closes the stream cleanly — bytes already received are intact, and the client resumes with a Range request from its exact byte offset. Nothing already downloaded is re-charged.

So the download layer needs Retry-After-aware backoff and byte-offset resume as first-class behaviour, not as an afterthought.

One more failure mode to handle: if a backfill runs long enough that S ages out of the live socket's lookback window (36 hours on Bluesky-hosted instances), the cutover connect fails with an HTTP 400 carrying the new floor — already surfaced as JetstreamConnectException by #84. The right response is to re-enter the plan loop from the last processed sequence number rather than skip the gap.

Sketch of the API surface

Nothing here is settled; it's a starting point that fits what #84 already landed.

  • JetstreamArchiveClient — typed wrappers for planSnapshot / listSegments / getSegment / getBlock, with the API key, Range support and 429 handling.
  • JetstreamSegmentReader — the .jss decoder: header/footer parsing, checksum verification, block decompression, columnar → JetstreamEvent projection reusing the existing event model. Streaming (IAsyncEnumerable) rather than whole-file-in-memory; segments are ~256 MB compressed.
  • JetstreamReplayConsumer (or JetstreamConsumer.ReplayAsync) — the plan loop, parallel download, exact filtering, and the dedup'd cutover into the existing JetstreamConsumer, so one await foreach spans history and live. Snapshot mode is the same thing with the cutover switched off and an optional beforeSeq bound.
  • Cursor persistence through the existing IFirehoseCursorStore, since a replay cursor is the same v2 sequence number the live tail already persists.
  • Options on JetstreamConsumerOptions or a sibling: ApiKey, AfterSeq, BeforeSeq, SnapshotOnly, download parallelism.

Requires Protocol = JetstreamProtocol.V2; v1 has no archive.

Suggested phasing

Each step is independently useful and shippable:

  1. .jss reader + tests against fixture segments (the format is stable and testable offline — this is the risky part, so do it first).
  2. JetstreamArchiveClient over the four HTTP methods, incl. auth, Range, ETags, 429/Retry-After.
  3. Snapshot mode end to end (plan loop → download → decode → filter), no cutover.
  4. Replay = snapshot + inclusive cutover at sealedTipSeq with seq dedup, plus the re-plan path when the tip ages out.
  5. Docs (docs/jetstream.md replaces the Historical Replay placeholder, docs/api-reference.md) and a sample.

Open questions

  • Is the columnar block decoder worth exposing publicly for people mirroring the archive, or kept internal behind the event projection?
  • The DID bloom filter (gloom.Filter, Go-side MarshalBinary) needs a compatible C# reader if we want client-side segment pruning — or we can skip it and lean entirely on the server's plan, which is the simpler first cut.
  • Verification of the raw CBOR payloads against the network (the format keeps CBOR precisely so a mirror is auditable) — in scope, or a separate issue?
  • How much of the download layer belongs in the core ATProtoNet package versus somewhere optional, given the zstd and parallel-IO weight.

References

Follow-up to #83 / #84, which implemented the Jetstream **v2 live tail** and explicitly left the archive out of scope (`docs/jetstream.md` → *Historical Replay*). Jetstream v2 is a “full-network archive, replay, and streaming service” — the WebSocket tail is only one of three ways to consume it. The other two are worth having in the SDK, because between them they cover the thing every app actually needs: *the records that already exist* **and** *every new one*, with no gap at the seam. | Mode | Transport | Auth | Status | |---|---|---|---| | Live | WebSocket (`subscribeEvents`) | none | ✅ done in #84 | | Replay | HTTP plan + download, then cut over to the live tail | API key on the HTTP calls | ❌ this issue | | Snapshot | HTTP only, no live tail | API key | ❌ this issue | ## How replay works Replay is **stateless on the server** — no per-consumer cursor, no registered subscription. Three steps, all plain HTTP plus one WebSocket: 1. **Page a plan.** `POST network.bsky.jetstream.planSnapshot` with `dids` / `collections` (exact NSIDs or `app.bsky.feed.*` wildcards) and an optional `afterSeq`/`beforeSeq` window. The first response reports `sealedTipSeq`; pin it as `S` for the whole backfill. Each page also reports `plannedThroughSeq`; while `plannedThroughSeq < S`, re-plan with `afterSeq = plannedThroughSeq`, `beforeSeq = S` so the range never floats. Large plans truncate at a whole segment or block-range boundary, and at least one work unit is always admitted, so progress is guaranteed. 2. **Download the archive.** Each planned segment comes back as `mode: "segment"` (fetch the whole file with `getSegment`) or `mode: "blocks"` (fetch the listed block ranges with `getBlock`). Responses are immutable, ETag'd and CDN-cacheable, and `getSegment` honours HTTP `Range`, so downloads parallelise freely and resume exactly where they stopped. The planner works from bloom filters and per-block summaries — no false negatives, but it may hand back blocks with no matching rows, so the client applies the exact `dids`/`collections` filter to what it decodes. 3. **Tail from the tip.** Connect the live socket *once* at `?cursor=S`. The cursor is inclusive, so deduplicate by sequence number. The server replays the window between the plan and the socket itself, so there's no buffer to drain and nothing lost in the handoff. `network.bsky.jetstream.listSegments` rounds it out for a raw archive mirror (name, index, `sizeBytes`, `checksum`, `eventCount`, `minSeq`/`maxSeq`, `minWitnessedAt`/`maxWitnessedAt`). ## The `.jss` decoder This is the bulk of the work, and the reason it didn't ride along with #84. A *Jetstream sealed segment* is a custom columnar binary format: - 256-byte fixed header (`jss0` magic, xxhash3 checksum, version, block/event/DID counts, seq and witnessed-at bounds, and offsets for the footer, DID bloom, per-block bloom, collection index and block index). - A run of blocks, each an 8-byte LE length prefix followed by one zstd frame (≈4096 events, operator-configurable). - A variable-length footer: 52-byte block index entries, a segment-wide DID bloom filter (`gloom.Filter`, 0.1% FP), per-block blooms, and a collection index. Inside a decompressed block: a `uint32` event count, fixed-size columns (`seq`, `witnessed_at`, `indexed_at`, `kind`, the four length columns, `event_len`), then concatenated variable-length columns (`collections`, `dids`, `rkeys`, `revs`, and raw **CBOR** payloads). `kind` is a `uint8` discriminator (1 create, 2 update, 3 delete, 4 identity, …). `indexed_at` is the display timestamp handed to clients as `time_us`, falling back to `witnessed_at` when `0`. Two behaviours worth encoding in the design rather than discovering later: - **Sealed segments are immutable only between compactions.** The server periodically rewrites them to physically drop deleted records, and rewritten files get new checksums. A mirror must re-list and compare checksums rather than assume a segment never changes. - **The stream is folded, not filtered.** Replay delivers every matching event at least once in seq order, including creates that a later delete supersedes. Consumers converge by folding (create adds, update replaces, delete removes; an `account` event with `active: false` / `status: "deleted"`, or a `sync` divergence marker, removes *all* of that account's records). Writes should be idempotent, keyed on the `at://` URI. Account-level events carry no collection and are delivered even to a collection-filtered consumer — same rule the live tail already follows. ## Auth and metering The HTTP endpoints are metered on Bluesky-hosted instances (the live WebSocket stays unauthenticated and unmetered): - `Authorization: Bearer <key>`; a missing/malformed/revoked key returns `401` with `{"error":"invalid bearer credential"}`. - Metered in **response bytes on the wire**, not requests. Over quota is `429` + `{"error":"byte limit exceeded"}` and a `Retry-After` header; the quota refills continuously rather than resetting on a boundary. - Running out mid-download closes the stream cleanly — bytes already received are intact, and the client resumes with a `Range` request from its exact byte offset. Nothing already downloaded is re-charged. So the download layer needs `Retry-After`-aware backoff and byte-offset resume as first-class behaviour, not as an afterthought. One more failure mode to handle: if a backfill runs long enough that `S` ages out of the live socket's lookback window (36 hours on Bluesky-hosted instances), the cutover connect fails with an HTTP 400 carrying the new floor — already surfaced as `JetstreamConnectException` by #84. The right response is to re-enter the plan loop from the last processed sequence number rather than skip the gap. ## Sketch of the API surface Nothing here is settled; it's a starting point that fits what #84 already landed. - `JetstreamArchiveClient` — typed wrappers for `planSnapshot` / `listSegments` / `getSegment` / `getBlock`, with the API key, `Range` support and 429 handling. - `JetstreamSegmentReader` — the `.jss` decoder: header/footer parsing, checksum verification, block decompression, columnar → `JetstreamEvent` projection reusing the existing event model. Streaming (`IAsyncEnumerable`) rather than whole-file-in-memory; segments are ~256 MB compressed. - `JetstreamReplayConsumer` (or `JetstreamConsumer.ReplayAsync`) — the plan loop, parallel download, exact filtering, and the dedup'd cutover into the existing `JetstreamConsumer`, so one `await foreach` spans history and live. Snapshot mode is the same thing with the cutover switched off and an optional `beforeSeq` bound. - Cursor persistence through the existing `IFirehoseCursorStore`, since a replay cursor is the same v2 sequence number the live tail already persists. - Options on `JetstreamConsumerOptions` or a sibling: `ApiKey`, `AfterSeq`, `BeforeSeq`, `SnapshotOnly`, download parallelism. Requires `Protocol = JetstreamProtocol.V2`; v1 has no archive. ## Suggested phasing Each step is independently useful and shippable: 1. `.jss` reader + tests against fixture segments (the format is stable and testable offline — this is the risky part, so do it first). 2. `JetstreamArchiveClient` over the four HTTP methods, incl. auth, `Range`, ETags, 429/`Retry-After`. 3. Snapshot mode end to end (plan loop → download → decode → filter), no cutover. 4. Replay = snapshot + inclusive cutover at `sealedTipSeq` with seq dedup, plus the re-plan path when the tip ages out. 5. Docs (`docs/jetstream.md` replaces the *Historical Replay* placeholder, `docs/api-reference.md`) and a sample. ## Open questions - Is the columnar block decoder worth exposing publicly for people mirroring the archive, or kept internal behind the event projection? - The DID bloom filter (`gloom.Filter`, Go-side `MarshalBinary`) needs a compatible C# reader if we want client-side segment pruning — or we can skip it and lean entirely on the server's plan, which is the simpler first cut. - Verification of the raw CBOR payloads against the network (the format keeps CBOR precisely so a mirror is auditable) — in scope, or a separate issue? - How much of the download layer belongs in the core `ATProtoNet` package versus somewhere optional, given the zstd and parallel-IO weight. ## References - [Network Replay](https://bsky.network/docs/jetstream-replay) — the flow, metering, and folding semantics - [HTTP reference](https://endpoints.bsky.app/#jetstream) — `planSnapshot`, `listSegments`, `getSegment`, `getBlock` schemas - [Data layout §3](https://github.com/bluesky-social/jetstream/tree/main/docs#3-data-layout) — the `.jss` and block formats - [Jetstream SDK](https://bsky.network/docs/jetstream-sdk) — Go/TS clients doing all of the above, useful as a reference implementation
Author
Collaborator

🤖 On it — follow along in the action run; the reply will link the full session transcript.

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

🤖 On it — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/193); the reply will link the full session transcript. 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/issue-85/run-193.md) — updating every ~5 minutes while the task runs.
Author
Collaborator

Pushed to claude/issue-85.

What landed

Jetstream v2's archive, phased as suggested — .jss reader first, then the HTTP client, then snapshot, then replay.

JetstreamSegmentReader — the sealed-segment decoder. 256-byte fixed header, the run of 8-byte-length-prefixed zstd frames, and the columnar block body (event count → fixed-width seq/witnessed_at/indexed_at/kind/length columns → concatenated collections/dids/rkeys/revs/CBOR payloads). ReadRowsAsync/ReadEventsAsync stream block by block (no whole-segment buffering); DecodeBlockFrame takes a frame exactly as getBlock returns it. JetstreamArchiveRow is public — the raw columns including the untouched CBOR, so a mirror stays byte-auditable — and ToEvent() projects to the existing JetstreamEvent model, computing a commit's CID from its payload (the format stores no CID column), folding kind 1/2/3/7 into create/update/delete, and applying the indexed_at == 0 → witnessed_at display-time fallback.

JetstreamArchiveClientplanSnapshot / listSegments / getSegment / getBlock with Authorization: Bearer, ETags, Range, and metering-shaped behaviour: DownloadSegmentAsync resumes from the exact byte offset it stopped at (and restarts cleanly if a proxy drops the Range and answers 200), a 429 waits out precisely the Retry-After it was given, and a revoked key (401) is not retried. JetstreamArchiveException carries StatusCode/Error/RetryAfter/IsRetryable.

JetstreamReplayConsumer — pins the first page's sealedTipSeq as the ceiling for the whole backfill and pages with afterSeq = plannedThroughSeq, beforeSeq = S; downloads with bounded parallelism (segments spooled to temp files, blocks in memory) while decoding strictly in plan order; re-applies the exact DID/collection/kind filter to what it decoded, since the planner over-returns; then cuts over once at the pinned tip with sequence dedup. A refused cutover (tip aged out of the 36 h window) re-enters the plan loop from the last delivered sequence rather than skipping the gap. Configured via the new JetstreamConsumerOptions.Archive, sharing the live tail's filters and IFirehoseCursorStore — a replay cursor is the v2 sequence number.

Open questions, as resolved

  • Columnar decoder public? Yes — JetstreamArchiveRow + DecodeBlock/DecodeBlockFrame, for exactly the mirror/audit case.
  • gloom.Filter DID bloom? Skipped — the SDK leans on the server's plan, which is the simpler first cut. Header offsets for both blooms and the collection index are parsed and exposed, so a later reader has somewhere to hang.
  • CBOR verification? Payloads are preserved verbatim on the row and the commit CID is derived from them, so a caller can verify; nothing further in scope here.
  • Package placement / zstd weight? All in core, with no new dependencies: zstd stays behind a seam (IJetstreamBlockDecompressor, deliberately separate from IJetstreamDecompressor — segment blocks are dictionary-less, so the WebSocket decompressor cannot read one). The xxh3 checksum is likewise not recomputed; it's exposed for the compare-against-ETag check a mirror actually needs, and compaction rewrites mean re-listing is required anyway.

Verified

  • dotnet build clean (solution, Debug and Release); dotnet test tests/ATProtoNet.Tests/1288 passed, 0 failed (5 pre-existing skips), including 60 new tests.
  • Cross-implementation conformance: the decoder is tested against Jetstream's own segment/testdata/golden_block.bin (inflated and committed as a fixture) and against the fixed header of the reference repo's real sealed segment — reference bytes, so a shared misreading of the spec can't pass.
  • Additionally, out of band, I ran the reader over the full real legacy_fixed4096_blooms.jss with a live ZstdSharp decompressor: 12 rows decoded, matching the header's event count, monotonic sequences, stopping cleanly at the footer.
  • Integration tests (JetstreamArchiveTests) hit the live service and are gated behind the new ATPROTO_JETSTREAM_API_KEY — I had no key, so they were not executed; they're written to stay small since the endpoints bill real bytes.

Docs: docs/jetstream.md's Historical Replay placeholder is replaced with the full flow (modes table, plan loop, metering/resume, folding semantics, the ZstdSharp block decompressor, mirror recipe); docs/api-reference.md, docs/index.md, README.md, CLAUDE.md and CHANGELOG.md updated, plus the runnable samples/JetstreamReplaySample.


⏱ 26m 0s · 101 turns · tokens: 232.9k in / 120.0k out (+15813.0k cache-read)
📜 Full session transcript
→ Opened PR: #86

Pushed to `claude/issue-85`. ## What landed Jetstream v2's archive, phased as suggested — `.jss` reader first, then the HTTP client, then snapshot, then replay. **`JetstreamSegmentReader`** — the sealed-segment decoder. 256-byte fixed header, the run of 8-byte-length-prefixed zstd frames, and the columnar block body (event count → fixed-width `seq`/`witnessed_at`/`indexed_at`/`kind`/length columns → concatenated `collections`/`dids`/`rkeys`/`revs`/CBOR payloads). `ReadRowsAsync`/`ReadEventsAsync` stream block by block (no whole-segment buffering); `DecodeBlockFrame` takes a frame exactly as `getBlock` returns it. `JetstreamArchiveRow` is public — the raw columns including the untouched CBOR, so a mirror stays byte-auditable — and `ToEvent()` projects to the existing `JetstreamEvent` model, computing a commit's CID from its payload (the format stores no CID column), folding `kind` 1/2/3/7 into create/update/delete, and applying the `indexed_at == 0 → witnessed_at` display-time fallback. **`JetstreamArchiveClient`** — `planSnapshot` / `listSegments` / `getSegment` / `getBlock` with `Authorization: Bearer`, ETags, `Range`, and metering-shaped behaviour: `DownloadSegmentAsync` resumes from the exact byte offset it stopped at (and restarts cleanly if a proxy drops the `Range` and answers 200), a 429 waits out precisely the `Retry-After` it was given, and a revoked key (401) is not retried. `JetstreamArchiveException` carries `StatusCode`/`Error`/`RetryAfter`/`IsRetryable`. **`JetstreamReplayConsumer`** — pins the first page's `sealedTipSeq` as the ceiling for the whole backfill and pages with `afterSeq = plannedThroughSeq`, `beforeSeq = S`; downloads with bounded parallelism (segments spooled to temp files, blocks in memory) while decoding strictly in plan order; re-applies the exact DID/collection/kind filter to what it decoded, since the planner over-returns; then cuts over once at the pinned tip with sequence dedup. A refused cutover (tip aged out of the 36 h window) re-enters the plan loop from the last delivered sequence rather than skipping the gap. Configured via the new `JetstreamConsumerOptions.Archive`, sharing the live tail's filters and `IFirehoseCursorStore` — a replay cursor *is* the v2 sequence number. ## Open questions, as resolved - **Columnar decoder public?** Yes — `JetstreamArchiveRow` + `DecodeBlock`/`DecodeBlockFrame`, for exactly the mirror/audit case. - **`gloom.Filter` DID bloom?** Skipped — the SDK leans on the server's plan, which is the simpler first cut. Header offsets for both blooms and the collection index are parsed and exposed, so a later reader has somewhere to hang. - **CBOR verification?** Payloads are preserved verbatim on the row and the commit CID is derived from them, so a caller can verify; nothing further in scope here. - **Package placement / zstd weight?** All in core, with no new dependencies: zstd stays behind a seam (`IJetstreamBlockDecompressor`, deliberately separate from `IJetstreamDecompressor` — segment blocks are dictionary-less, so the WebSocket decompressor cannot read one). The xxh3 checksum is likewise not recomputed; it's exposed for the compare-against-ETag check a mirror actually needs, and compaction rewrites mean re-listing is required anyway. ## Verified - `dotnet build` clean (solution, Debug and Release); `dotnet test tests/ATProtoNet.Tests/` → **1288 passed, 0 failed** (5 pre-existing skips), including 60 new tests. - **Cross-implementation conformance:** the decoder is tested against Jetstream's own `segment/testdata/golden_block.bin` (inflated and committed as a fixture) and against the fixed header of the reference repo's real sealed segment — reference bytes, so a shared misreading of the spec can't pass. - Additionally, out of band, I ran the reader over the full real `legacy_fixed4096_blooms.jss` with a live ZstdSharp decompressor: 12 rows decoded, matching the header's event count, monotonic sequences, stopping cleanly at the footer. - Integration tests (`JetstreamArchiveTests`) hit the live service and are gated behind the new `ATPROTO_JETSTREAM_API_KEY` — I had no key, so they were not executed; they're written to stay small since the endpoints bill real bytes. Docs: `docs/jetstream.md`'s *Historical Replay* placeholder is replaced with the full flow (modes table, plan loop, metering/resume, folding semantics, the ZstdSharp block decompressor, mirror recipe); `docs/api-reference.md`, `docs/index.md`, `README.md`, `CLAUDE.md` and `CHANGELOG.md` updated, plus the runnable `samples/JetstreamReplaySample`. --- ⏱ 26m 0s · 101 turns · tokens: 232.9k in / 120.0k out (+15813.0k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/issue-85/run-193.md) → Opened PR: https://git.grandiras.net/Grandiras/ATProto.NET/pulls/86
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/ATProto.NET#85
No description provided.