Claude: changes for issue #40 #65

Merged
Grandiras merged 3 commits from claude/issue-40 into main 2026-07-25 15:40:50 +00:00
Collaborator

Closes #40.

Closes #40.
feat: federation support for ATProtoNet.Pds (closes #40)
All checks were successful
CI / build-and-test (pull_request) Successful in 40s
0fffba5028
A PDS built on ATProtoNet.Pds could host accounts and serve repo CRUD, but
nothing on the network could resolve its identities, verify its repositories,
or follow its updates. This closes all four gaps from the issue.

Real identities — PdsIdentityService generates a rotation key and a repo
signing key, builds a signed plc_operation genesis operation naming the PDS as
the account's service endpoint, and derives the DID from the hash of that
signed operation, the same derivation the directory performs. Submission is
opt-in via PdsOptions.RegisterDidsWithPlc so neither a test suite nor a dev
host writes to a public append-only directory as a side effect of creating an
account. PdsDidMethod.Web mints did:web identities needing no directory.

Real repo structure — records are DAG-CBOR encoded and addressed by true
CIDv1, arranged in a Merkle Search Tree keyed by collection/rkey, and rooted in
a commit signed with the account's key. PdsRepoManager rebuilds the tree from
the full record set per write; since the MST is a pure function of its key/value
set the result is byte-identical to an incrementally maintained tree, which
keeps IRepoStore free of block-storage concerns.

Sync surface — getRepo, getLatestCommit, getRepoStatus, listRepos, getRecord,
getBlocks, listBlobs, and a subscribeRepos WebSocket firehose with cursor
replay. PdsCrawlNotifier calls requestCrawl against configured relays.

Handle resolution — /.well-known/atproto-did and /.well-known/did.json for the
request host, plus com.atproto.identity.resolveHandle.

Two bugs surfaced while building this. The CIDs the PDS reported only looked
like CIDs: hex in a base32 multibase payload, hashed over JSON rather than
DAG-CBOR. And Tid.Next() cannot supply a commit rev — it resolves to the
millisecond and randomizes the clock id, so a rapid second write could produce
a revision sorting before the first; PdsRevisionGenerator clamps instead.

Producer-side primitives land in the core SDK alongside their consumers:
CarWriter, RepoCommit/SignedRepoCommit, and PlcOperationBuilder. Frames and
commits are asserted to round-trip through the SDK's own FirehoseEventParser
and FirehoseVerifier.ExtractSignedView.

IRepoStore gains ListAllRecordsAsync and ListBlobCidsAsync as default interface
members that throw, naming the type and member, so stores written before
federation keep compiling and keep serving repo CRUD.

165 new tests; 1198 pass, 0 fail, in Debug and Release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
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/61); 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/pr-65/run-61.md) — updating every ~5 minutes while the task runs.
Author
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/62); 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/pr-65/run-62.md) — updating every ~5 minutes while the task runs.
Author
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/63); 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/pr-65/run-63.md) — updating every ~5 minutes while the task runs.
claude-bot left a comment

Substantial, well-documented PDS federation feature (real DIDs, DAG-CBOR/MST repos, signed commits, sync surface, firehose). Most of the crypto/CBOR/CID plumbing looks careful and the docs/CHANGELOG are thorough. However, there's one change that looks like it silently breaks the backward-compatibility guarantee the PR itself promises, plus a secondary correctness gap in listRepos.

Main issue — federation is wired unconditionally, breaking pre-existing IRepoStore implementations for ordinary CRUD, not just the new sync surface.
AddFederationServices (called from every AddAtProtoPds overload, including the plain non-generic one) unconditionally does services.TryAddSingleton<PdsRepoManager>(). PdsService now has two public constructors — a 4-arg one and a 6-arg one taking PdsRepoManager?/PdsIdentityService?. Since PdsRepoManager and PdsIdentityService are now always resolvable from the container, ASP.NET Core's default DI constructor selection (most-parameters-that-resolve) will always pick the 6-arg constructor, so _repoManager is effectively never null for any host built with AddAtProtoPds(). That means every createRecord/putRecord/deleteRecord call now unconditionally goes through PdsRepoManager.CommitAsyncBuildSnapshotAsyncIRepoStore.ListAllRecordsAsync, which throws NotSupportedException by default. This directly contradicts the stated design goal in IRepoStore.cs and the CHANGELOG ("a store written before federation support still compiles and still serves repo CRUD; only the federation surface reports the gap") — in practice, any consumer with a pre-existing custom IRepoStore that hasn't implemented the two new members will now fail on every write, not just on the new sync endpoints. Worth confirming whether this is intentional (federation always-on) and, if not, gating PdsRepoManager/PdsIdentityService registration or the commit call behind something that preserves the documented opt-out.

Secondary — com.atproto.sync.listRepos hardcodes active = true. RepoCommitState carries no active/status flag, so the endpoint always reports every listed repo as active, even for deactivated accounts (which GetRepoStatus correctly reports via the account/session lookup). A relay relying on listRepos won't see deactivated accounts reflected accurately.

Minor/non-blocking observations: PdsRepoManager.PublishCommit inlines the entire MST block set into every #commit firehose frame (not just the touched path), so any repo of non-trivial size will quickly exceed MaxFirehoseFrameBytes and fall back to tooBig on nearly every write — acknowledged as a scale tradeoff in the docs, but worth flagging since it undermines incremental firehose consumption for real repos. The per-DID SemaphoreSlim dictionary in PdsRepoManager also grows unboundedly over the process lifetime (never removed), though this is a minor memory concern only.

  • src/ATProtoNet.Pds/PdsHostingExtensions.cs:103AddFederationServices is called unconditionally from every AddAtProtoPds overload and always registers PdsRepoManager/PdsIdentityService via TryAddSingleton. Because PdsService (see PdsService.cs) has both a 4-arg and a 6-arg constructor, and both PdsRepoManager and PdsIdentityService are now always resolvable, the framework's default DI constructor selection will always bind the 6-arg ("federating") constructor — the 4-arg constructor becomes effectively dead code. As a result, every repo write now unconditionally calls PdsRepoManager.CommitAsync, which rebuilds the MST via IRepoStore.ListAllRecordsAsync. Any pre-existing IRepoStore implementation that hasn't overridden that default-interface member (which throws NotSupportedException) will now fail on ordinary createRecord/putRecord/deleteRecord calls, contradicting the explicit compatibility promise in IRepoStore.cs and the CHANGELOG that only the new federation surface would report the gap.
  • src/ATProtoNet.Pds/PdsService.cs:43 — This constructor documents repoManager/identity as optionally null ("the PDS keeps serving repo CRUD but nothing on the network can follow it"), but given how AddFederationServices registers these services unconditionally (see PdsHostingExtensions.cs), DI will always select this constructor over the 4-arg one, so in practice repoManager is never null for any host wired through AddAtProtoPds. If federation is meant to be effectively mandatory now, the 4-arg constructor and its null-repoManager code paths (e.g. CommitAsync's _repoManager is null check) are misleading dead code; if it's meant to be optional, the DI wiring needs to make PdsRepoManager conditional or otherwise avoid always satisfying this constructor's parameters.
  • src/ATProtoNet.Pds/PdsHostingExtensions.cs:449com.atproto.sync.listRepos hardcodes active = true for every repo returned, regardless of the account's actual hosting status. GetRepoStatus correctly derives active from the session/account lookup, but RepoCommitState (and thus ListReposAsync) has no such field, so a deactivated account still reports as active in listRepos. Relays enumerating repos via this endpoint would get an inconsistent picture relative to getRepoStatus.

⏱ 3m 37s · 1 turns · tokens: 69.0k in / 21.9k out (+29.1k cache-read)
📜 Full session transcript

Substantial, well-documented PDS federation feature (real DIDs, DAG-CBOR/MST repos, signed commits, sync surface, firehose). Most of the crypto/CBOR/CID plumbing looks careful and the docs/CHANGELOG are thorough. However, there's one change that looks like it silently breaks the backward-compatibility guarantee the PR itself promises, plus a secondary correctness gap in `listRepos`. **Main issue — federation is wired unconditionally, breaking pre-existing `IRepoStore` implementations for ordinary CRUD, not just the new sync surface.** `AddFederationServices` (called from *every* `AddAtProtoPds` overload, including the plain non-generic one) unconditionally does `services.TryAddSingleton<PdsRepoManager>()`. `PdsService` now has two public constructors — a 4-arg one and a 6-arg one taking `PdsRepoManager?`/`PdsIdentityService?`. Since `PdsRepoManager` and `PdsIdentityService` are now always resolvable from the container, ASP.NET Core's default DI constructor selection (most-parameters-that-resolve) will always pick the 6-arg constructor, so `_repoManager` is effectively never `null` for any host built with `AddAtProtoPds()`. That means every `createRecord`/`putRecord`/`deleteRecord` call now unconditionally goes through `PdsRepoManager.CommitAsync` → `BuildSnapshotAsync` → `IRepoStore.ListAllRecordsAsync`, which throws `NotSupportedException` by default. This directly contradicts the stated design goal in `IRepoStore.cs` and the CHANGELOG ("a store written before federation support still compiles and still serves repo CRUD; only the federation surface reports the gap") — in practice, any consumer with a pre-existing custom `IRepoStore` that hasn't implemented the two new members will now fail on *every write*, not just on the new sync endpoints. Worth confirming whether this is intentional (federation always-on) and, if not, gating `PdsRepoManager`/`PdsIdentityService` registration or the commit call behind something that preserves the documented opt-out. **Secondary — `com.atproto.sync.listRepos` hardcodes `active = true`.** `RepoCommitState` carries no active/status flag, so the endpoint always reports every listed repo as active, even for deactivated accounts (which `GetRepoStatus` correctly reports via the account/session lookup). A relay relying on `listRepos` won't see deactivated accounts reflected accurately. Minor/non-blocking observations: `PdsRepoManager.PublishCommit` inlines the *entire* MST block set into every `#commit` firehose frame (not just the touched path), so any repo of non-trivial size will quickly exceed `MaxFirehoseFrameBytes` and fall back to `tooBig` on nearly every write — acknowledged as a scale tradeoff in the docs, but worth flagging since it undermines incremental firehose consumption for real repos. The per-DID `SemaphoreSlim` dictionary in `PdsRepoManager` also grows unboundedly over the process lifetime (never removed), though this is a minor memory concern only. - `src/ATProtoNet.Pds/PdsHostingExtensions.cs:103` — `AddFederationServices` is called unconditionally from every `AddAtProtoPds` overload and always registers `PdsRepoManager`/`PdsIdentityService` via `TryAddSingleton`. Because `PdsService` (see `PdsService.cs`) has both a 4-arg and a 6-arg constructor, and both `PdsRepoManager` and `PdsIdentityService` are now always resolvable, the framework's default DI constructor selection will always bind the 6-arg ("federating") constructor — the 4-arg constructor becomes effectively dead code. As a result, every repo write now unconditionally calls `PdsRepoManager.CommitAsync`, which rebuilds the MST via `IRepoStore.ListAllRecordsAsync`. Any pre-existing `IRepoStore` implementation that hasn't overridden that default-interface member (which throws `NotSupportedException`) will now fail on ordinary `createRecord`/`putRecord`/`deleteRecord` calls, contradicting the explicit compatibility promise in `IRepoStore.cs` and the CHANGELOG that only the *new* federation surface would report the gap. - `src/ATProtoNet.Pds/PdsService.cs:43` — This constructor documents `repoManager`/`identity` as optionally `null` ("the PDS keeps serving repo CRUD but nothing on the network can follow it"), but given how `AddFederationServices` registers these services unconditionally (see PdsHostingExtensions.cs), DI will always select this constructor over the 4-arg one, so in practice `repoManager` is never null for any host wired through `AddAtProtoPds`. If federation is meant to be effectively mandatory now, the 4-arg constructor and its null-repoManager code paths (e.g. `CommitAsync`'s `_repoManager is null` check) are misleading dead code; if it's meant to be optional, the DI wiring needs to make `PdsRepoManager` conditional or otherwise avoid always satisfying this constructor's parameters. - `src/ATProtoNet.Pds/PdsHostingExtensions.cs:449` — `com.atproto.sync.listRepos` hardcodes `active = true` for every repo returned, regardless of the account's actual hosting status. `GetRepoStatus` correctly derives `active` from the session/account lookup, but `RepoCommitState` (and thus `ListReposAsync`) has no such field, so a deactivated account still reports as active in `listRepos`. Relays enumerating repos via this endpoint would get an inconsistent picture relative to `getRepoStatus`. --- ⏱ 3m 37s · 1 turns · tokens: 69.0k in / 21.9k out (+29.1k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-65/run-63.md) <!-- claude-reviewed-head:0fffba502820f992fb2b4d38c35ac1b86756e3ef -->
Owner

@claude-bot Please fix the conflicts and pick up the comments.

@claude-bot Please fix the conflicts and pick up the comments.
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/64); 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-65/run-64.md) — updating every ~5 minutes while the task runs.
Resolves conflicts in CHANGELOG.md, docs/pds.md and PdsHostingExtensions.cs,
where the federation work landed alongside the session signing key work from
issue #37. All three are additive on both sides, so both sides are kept.
fix: address review on PDS federation (#65)
All checks were successful
CI / build-and-test (pull_request) Successful in 39s
Sync Closures to GitHub / sync-closure (pull_request) Successful in 6s
4044191ab8
Federation is registered by every AddAtProtoPds overload, so PdsService always
resolved its federating constructor and every write went through
PdsRepoManager.CommitAsync -> BuildSnapshotAsync -> IRepoStore.ListAllRecordsAsync.
That default-interface member throws, so a custom IRepoStore written before
federation support failed on every createRecord/putRecord/deleteRecord —
contradicting the compatibility promise that only the new sync surface would
report the gap.

CommitAsync now degrades instead of throwing when the store cannot enumerate a
repository: the write succeeds, the gap is logged once and exposed as
PdsRepoManager.IsRepositoryEnumerationUnsupported, and nothing is signed or
published. The sync endpoints, which genuinely need the record set, still
surface the NotSupportedException. PdsService and PdsRepoManager are also
registered through explicit factories, so which constructor runs is stated
rather than left to the container's greediest-constructor heuristic.

com.atproto.sync.listRepos hardcoded active=true, so a relay enumerating repos
saw a different picture than getRepoStatus gave for the same DID. It now reads
the owning account's status from the account store — the same source
getRepoStatus uses — via the new ListRepoListingsAsync/RepoListing.

#commit frames inlined the entire MST block set, so any repo of real size blew
past MaxFirehoseFrameBytes and fell back to tooBig on nearly every write,
defeating incremental consumption. New MerkleSearchTree.SerializeProof(keys)
serializes only the root and the nodes on the root->key paths; the firehose and
com.atproto.sync.getRecord both use it, so frame size now grows with the
operation count and the log of the repo size. The per-DID commit gate is also
dropped on repo deletion, bounding the lock table by live repos.
Author
Collaborator

Pushed to claude/issue-40 — conflicts resolved and all three review points addressed.

Conflicts

main had moved ahead by the issue #37 (session signing key) and #45 (lexgen) work. Three files conflicted — CHANGELOG.md, docs/pds.md, PdsHostingExtensions.cs — all additive on both sides, so both sides were kept. Committed as a separate merge commit (7afb7f7) so the review fixes are reviewable on their own.

Review fixes (4044191)

Federation broke pre-existing IRepoStore implementations — confirmed, and fixed. Your read was right: every write went through CommitAsyncBuildSnapshotAsyncListAllRecordsAsync, so a legacy store failed on every createRecord/putRecord/deleteRecord, not just on the sync surface.

I kept federation always-on (that's the intended design) and instead made it honour the promise: CommitAsync now returns null rather than throwing when the store can't enumerate a repository. The write succeeds, the gap is logged once as a warning naming the member to implement, and PdsRepoManager.IsRepositoryEnumerationUnsupported reports it. The sync endpoints, which genuinely can't work without the record set, still surface the NotSupportedException — so the gap is still reported where it matters.

On the dead-constructor point: PdsService and PdsRepoManager are now registered via explicit factories, so which constructor runs is stated in code rather than falling out of the container's greediest-constructor heuristic. The 4-arg constructor stays for hosts that construct the service themselves, and its doc comment now says so instead of implying DI might pick it.

listRepos hardcoded active = true — fixed. New ListRepoListingsAsync/RepoListing reads each account's status from the account store, the same source getRepoStatus uses, so the two endpoints agree. A head whose account is gone reports deleted; status is omitted for active repos.

Firehose inlining the whole MST — fixed rather than left as a tradeoff. Added MerkleSearchTree.SerializeProof(keys), which serializes only the root plus the nodes on the root→key search paths. Both #commit and com.atproto.sync.getRecord use it, so frame size now grows with the operation count and the log of repo size. A 300-record repo's commit frame no longer trips tooBig.

Unbounded lock dictionary — bounded. The per-DID gate is dropped on repo deletion (removed, not disposed — a racing commit must not hit a disposed semaphore).

Verification

dotnet build clean (0 errors, no new warnings on touched files). Unit tests in Release, matching CI: 1245 passed, 0 failed — 1229 was the post-merge baseline, so 16 new tests. They cover CRUD surviving a non-enumerating store, CommitAsync returning null and publishing nothing, the sync surface still throwing, listRepos agreeing with getRepoStatus for a deactivated account, DI resolving a federating PdsService, and seven MST proof tests including one that walks a proof block-set root→key the way a relay would.

CHANGELOG.md and docs/pds.md updated — the docs previously described the old block-inlining behaviour and the compatibility promise the code wasn't keeping.


⏱ 13m 51s · 113 turns · tokens: 142.2k in / 51.1k out (+9959.7k cache-read)
📜 Full session transcript

Pushed to `claude/issue-40` — conflicts resolved and all three review points addressed. ## Conflicts `main` had moved ahead by the issue #37 (session signing key) and #45 (lexgen) work. Three files conflicted — `CHANGELOG.md`, `docs/pds.md`, `PdsHostingExtensions.cs` — all additive on both sides, so both sides were kept. Committed as a separate merge commit (`7afb7f7`) so the review fixes are reviewable on their own. ## Review fixes (`4044191`) **Federation broke pre-existing `IRepoStore` implementations — confirmed, and fixed.** Your read was right: every write went through `CommitAsync` → `BuildSnapshotAsync` → `ListAllRecordsAsync`, so a legacy store failed on every `createRecord`/`putRecord`/`deleteRecord`, not just on the sync surface. I kept federation always-on (that's the intended design) and instead made it honour the promise: `CommitAsync` now returns `null` rather than throwing when the store can't enumerate a repository. The write succeeds, the gap is logged once as a warning naming the member to implement, and `PdsRepoManager.IsRepositoryEnumerationUnsupported` reports it. The sync endpoints, which genuinely can't work without the record set, still surface the `NotSupportedException` — so the gap is still reported where it matters. On the dead-constructor point: `PdsService` and `PdsRepoManager` are now registered via explicit factories, so which constructor runs is stated in code rather than falling out of the container's greediest-constructor heuristic. The 4-arg constructor stays for hosts that construct the service themselves, and its doc comment now says so instead of implying DI might pick it. **`listRepos` hardcoded `active = true` — fixed.** New `ListRepoListingsAsync`/`RepoListing` reads each account's status from the account store, the same source `getRepoStatus` uses, so the two endpoints agree. A head whose account is gone reports `deleted`; `status` is omitted for active repos. **Firehose inlining the whole MST — fixed rather than left as a tradeoff.** Added `MerkleSearchTree.SerializeProof(keys)`, which serializes only the root plus the nodes on the root→key search paths. Both `#commit` and `com.atproto.sync.getRecord` use it, so frame size now grows with the operation count and the log of repo size. A 300-record repo's commit frame no longer trips `tooBig`. **Unbounded lock dictionary — bounded.** The per-DID gate is dropped on repo deletion (removed, not disposed — a racing commit must not hit a disposed semaphore). ## Verification `dotnet build` clean (0 errors, no new warnings on touched files). Unit tests in Release, matching CI: **1245 passed, 0 failed** — 1229 was the post-merge baseline, so 16 new tests. They cover CRUD surviving a non-enumerating store, `CommitAsync` returning null and publishing nothing, the sync surface still throwing, `listRepos` agreeing with `getRepoStatus` for a deactivated account, DI resolving a federating `PdsService`, and seven MST proof tests including one that walks a proof block-set root→key the way a relay would. `CHANGELOG.md` and `docs/pds.md` updated — the docs previously described the old block-inlining behaviour and the compatibility promise the code wasn't keeping. --- ⏱ 13m 51s · 113 turns · tokens: 142.2k in / 51.1k out (+9959.7k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/issue-65/run-64.md)
Grandiras deleted branch claude/issue-40 2026-07-25 15:40:51 +00:00
Grandiras referenced this pull request from a commit 2026-07-25 15:40:51 +00:00
Sign in to join this conversation.
No description provided.