feat(sync): Sync 1.1 inductive firehose verification and a Tap client #171
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!171
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-134-sync"
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 #134.
The CHANGELOG has claimed "Sync v1.1 support" since 0.4.0, but only the message fields existed. This adds the verification itself, resynchronization, and a client for Tap, Bluesky's reference Sync 1.1 consumer.
Sync 1.1
RepoSyncVerifier(ATProtoNet.Streaming) runs the checklist of the rewritten sync spec on#commitand#syncevents:commit; commit version 3; the event'srepo/didandrevmatch the signed commit;Staleotherwise);#atprotokey throughAtProtoCrypto.VerifySignature(the low-S path, unchanged), refetching the DID document once on failure, asFirehoseVerifierandServiceAuthVerifierdo;cid, noprev; update: both; delete:prev, nocid; no path twice; validcollection/rkey), every created or updated record present as DAG-CBOR;prevData;prevDataequals the tree of the last verified commit; a commit withoutprevData(pre-Sync 1.1 host) chains onsinceinstead; a#syncthat moves the tree resets the repository.Outcomes are
Valid,Stale,Invalid(rejected, state untouched) andDesynchronized(state marked,OnDesynchronizedinvoked once with the reason). A valid event's state is recorded only byApplyAsync, after processing, so a crash replays rather than skips it. Like the reference relay and Tap,sinceis not required to match whenprevDatadoes (an empty commit moves the revision, not the tree).MST inversion —
Repo/PartialMerkleSearchTree.csis a port of indigo'satproto/repo/mstnode tree (the code the reference relay and Tap invert with), holding CID-only subtrees, including its stub-root trimming. Loading checks key validity, ordering within the parent's range, and layers.State —
IRepoSyncStateStore(per DID:Rev,Data,Status=Synchronized/Desynchronized/Resynchronizing), withInMemoryRepoSyncStateStoreand, inATProtoNet.Server,EfCoreRepoSyncStateStore<TContext>+RepoSyncStateDbContext+ConfigureRepoSyncStateModel+AddAtProtoEfCoreRepoSyncStateStore<TContext>(), in the style of the JTI replay store (Sync/EntityFrameworkCore/, namespaceATProtoNet.Server.EntityFrameworkCore).Typed consumer (opt-in) —
TypedFirehoseConsumerOptions.SyncVerifier(exclusive withVerifier, which keeps working unchanged). Only events that verify and chain are delivered; the repository's state is recorded when the caller asks for the next message (a new asyncDeliveredAsynchook in the internalEventStreamLoop). With a sync verifier the collection filter can no longer skip commits unparsed: every commit is verified to keep its chain, and the filter only decides delivery. NewStreamDropReason.StaleandDesynchronized.Resync (
TypedFirehoseConsumerOptions.Resync) — a desynchronized repository is fetched in the background withgetRepo, from the relay first (the spec's thundering-herd guidance; redirects to the PDS are followed, up to 3), then from the account's PDS; a copy older than the event that broke the chain is skipped. The export is verified as aRepoSnapshot(all CIDs, commit, signature with one refresh, complete and canonical tree) and delivered as aRepoResyncEvent; the repository's events that arrived during the fetch were verified and held, and follow it in order. All state changes happen on the consumer's loop (a newNextPendingAsynchook), so there is no locking around the store and a repository's events stay in order. Bounded:MaxConcurrency(4),MaxPendingRepos(1,000),MaxRepoBytes(256 MiB),MaxHeldEventsPerRepo(1,000),MaxHeldBytes(64 MiB),FetchTimeout,RetryDelayafter a failure; overflow triggers a refetch. Repositories the store lists as unsynchronized (left from a crash, or marked for backfill) are picked up everyScanInterval.IRepoFetcher(HostRepoFetcher,PdsRepoFetcher) is the pluggable seam; the default fetchers use the SDK's SSRF-hardened handler (AllowPrivateNetworksfor development).RepoSnapshot.Verify(car, did, key)is also public for standalone use.S2 (untrusted MST
PrefixLen) was already fixed by #111:MstNodeDataboundspby the previous key's length before any allocation, with regression tests inMerkleSearchTreeTests(Deserialize_FirstEntryPrefixOutOfRange_ThrowsFormatException,…PrefixLongerThanThePreviousKey…). The partial tree decodes through the sameMstNodeData.Tap
TapClient(ATProtoNet.Tap):OpenChannel()→TapChannel.ReadAllAsync()is anIAsyncEnumerable<TapEvent>over/channelwith explicitAckAsync({"type":"ack","id":N}, exactly as@atproto/tap); acks made while disconnected are queued and flushed on reconnect, as the TS channel does. Reconnects perStreamReconnectPolicyover the internalStreamSocket(which gained a send path); a refused upgrade (e.g. webhook mode, 400) throws. Admin:AddReposAsync/RemoveReposAsync({"dids":[…]}),ResolveDidAsync(404 → null),GetRepoInfoAsync, all withBasic base64("admin:"+password)(FormatAdminAuthHeader("secret")=Basic YWRtaW46c2VjcmV0, the README vector).TapRecordEvent(implementsIRecordEvent, so Tap, firehose and Jetstream indexing code is shared) andTapIdentityEvent, parsed from the shapes indigo'scmd/tapmarshals (pinned in tests); unknown types are skipped unacknowledged and reported toOnError, as the TS client does.ATProtoNet.Server.Tap):MapTapWebhook(pattern, handler)/MapTapWebhook<THandler>()withITapEventHandler. Basic admin auth checked in constant time (SHA-256 digests +FixedTimeEquals, split at the first colon like Go'sSetBasicAuth), 401 withWWW-Authenticate; body bounded (MaxBodyBytes, 4 MiB; 413 declared or streamed); 400 for a non-event; 500 when the handler throws so Tap retries; mapping without a password throws unlessAllowUnauthenticated.Docs
docs/firehose.md: new "Verifying the firehose (Sync 1.1)" section (checklist table, consumer usage, resync, state stores, cost, direct use), option table, drop reasons, message types.docs/tap.md, linked fromdocs/index.md,docs/firehose.mdand README.Addedbullets;Fixedbullets for the claim (and the 0.4.0 entry reworded to "Sync v1.1 message fields") and for the stricter CBOR below.Breaking changes
None to public signatures. Behaviour notes:
FormatException) in CAR headers, commits and MST nodes; DAG-CBOR forbids them. (Side effect of reading them without an intermediate copy.)StreamDropReasonhas two new members; an exhaustiveswitchover it gains two cases.Verification
a081657). Fulldotnet build: 0 warnings, 0 errors.MstReferenceVectorTests) — the SDK's own covering proof is loaded as a partial tree and inverted back torootBeforeCommit; plus 240 random commits (1–8 creates/updates/deletes) over trees of 0 to 1,500 keys.tests/ATProtoNet.Tests/Streaming/TestData/firehose-sync-frames.json, six frames fromwss://bsky.network(2026-09-26, 28 KB): two consecutive commits of one repository, an update, a delete, a 3-op commit and a#sync, with the signing keys. All verify from an empty store, the pair chains, and tampering with ops orprevDatafails inversion.#syncsame/different tree.TestServer(auth variants, 413 both ways, 400, 500, DI handler).FirehoseVerifier(sig + CIDs)RepoSyncVerifier(full checklist)The ECDSA check dominates. Allocation started at 50–150 KB/commit; it came down by comparing MST node field names as bytes, reading CID links with one copy, pre-sizing the node encoder, validating keys on bytes, and encoding inverted nodes straight into one reused
CborWriter(only the CID is kept). The first three also speed upMerkleSearchTree.DeserializeandRecordProof.Review fixes
Six findings from a review of
6aa8eaf, fixed on top of it; each has a regression test that fails without the fix.prevDatacould hide operations (9e9b9b5). Chaining it onsincelet a relay stripprevDataand drop an operation unnoticed. Once a repository has a chain, such a commit now desynchronizes it. indigo's relay rejects it ("missing prevData"); desynchronizing rejects it too, and also gets the repository fetched again, so the commit's changes are not lost if no later commit comes to reveal the gap. It is still accepted as a repository's first commit.7dbf41a).CursorTrackergained a persist limit, and the resync keeps it just below the oldest held event not yet delivered or dropped, so a restart replays those events against the recorded snapshot (at-least-once kept). A snapshot whose held events overflowed is recorded as desynchronized.MaxConcurrency(18423d2). A fetch now holds its slot until the consumer's loop takes the completion, so exports in memory stay near(MaxConcurrency + 1) × MaxRepoBytes. The docs say so.FetchTimeoutdid not cover the body (bc2a3b9). The download runs under a linked token cancelled after the client'sTimeout, and the resync gives each fetcher attemptFetchTimeout, custom fetchers included. Running out is a failed source; the caller's own cancellation stays cancellation.72b501e), which Tap retries forever while holding back the repository. They are now acknowledged with a 200 and reported toTapWebhookOptions.OnUnreadableEvent(a warning log by default). 401 and 413 are unchanged.Content-Lengthsized the download buffer (bc2a3b9), so a claim past 2 GiB threw and any host could force aMaxRepoBytesallocation. The buffer starts at no more than 1 MiB and grows up to the limit.After the fixes: 4759 passed, 0 failed, 5 skipped; full build 0 warnings.
Deferred / not done
#accounthosting status is not tracked by the verifier, and there is no host-authority check (direct-PDS subscriptions); a relay applies both. The issue's checklist does not include them.getRepoexports are buffered in memory (bounded byMaxRepoBytes); a streaming CAR reader is research item S3.TapChannelstill leaves an unparseable message unacknowledged (reported toOnError), exactly as@atproto/tap's channel does; review fix 5 changed only the webhook. The same stall argument applies, but acknowledging needs anida malformed message may not have, so it is left to follow the reference client.🤖 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.
⏳ Held on the OpenCode Go window — the 5-hour window is at 0% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-26T21:28:17.643Z) — nothing to re-trigger.
@claude /review — large and verification-critical. The diff will be truncated, so please read these in full from the branch:
src/ATProtoNet/Repo/PartialMerkleSearchTree.cs(the port of indigo's partial-MST operation inversion; compare with indigoatproto/repo),src/ATProtoNet/Streaming/RepoSyncVerifier.cs(the Sync 1.1 checklist: signature with refetch-once, event fields vs the signed commit, the prevData comparison, rev ordering and the future check, since/prevData chain, commit v3, size limits, #sync reset),RepoResync.cs+RepoFetchers.cs(relay-first getRepo, bounds, the ordering of held events),RepoSyncState.cs, theTypedFirehoseConsumer.cschange (opt-in SyncVerifier; state recorded only after the caller takes the next message),src/ATProtoNet/Tap/TapClient.cs+TapModels.cs(ack protocol and admin API vs@atproto/tap),src/ATProtoNet.Server/Tap/TapWebhookExtensions.cs(auth compare, body bound) and the EF store. Focus on verification bypasses (a commit that passes with ops not matching the signed tree, rev regressions, a #sync that resets state without a valid signature), memory or DoS bounds on untrusted CAR/MST input, and webhook auth.🤖 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.
✅ Verdict: approve — no blocking issues found.
This is a large, well-scoped PR that finally implements the Sync 1.1 verification the CHANGELOG had been claiming since 0.4.0, plus a Tap client/webhook receiver. I focused deep review on the highest-risk, newest algorithmic and security-sensitive code rather than skimming everything:
RepoSyncVerifier(src/ATProtoNet/Streaming/RepoSyncVerifier.cs): tracedVerifyCommitAsync/VerifySyncAsyncend to end — staleness check, signature verify-then-refresh-once, operation shape validation (create/update/delete pre/post-conditions), and the chain check againstprevData/since. TheRepoOpActionshape-check switch correctly guards against null-ref onop.Prev!later (inductive commits are required to carryPrevfor update/delete before the code that dereferences it runs). The two-stage validation — self-consistency (inverted tree equals the commit's own declaredprevData) followed by chain continuity (declaredprevDataequals last stored state) — matches the PR's description and the spec.PartialMerkleSearchTree: read through load-time structural validation (key ordering, layer consistency, bounds) and the insert/remove/split/merge logic. This is intricate, MST-are-content-addressed-so-order-shouldn't-matter code; I didn't attempt an independent formal proof of the insert/remove algebra, but didn't spot an obvious defect either, and the PR states it was checked against six interop reference vectors plus 240 randomized fuzz commits, which is credible coverage for this kind of code.CommitBlock/Cid.AsSpan: confirmedCid's binary layout (_bytes[1]is the multicodec byte) matches the assumption inRepoSyncVerifier.CheckRecord's DAG-CBOR codec check.TapClient/TapChannel: the send/ack concurrency design (_sendLockserializingAckAsync/AttachAsync,_socketswapped only under that lock or viaCompareExchangein the single reading loop) looks race-free; queued acks flush correctly on reconnect.TapWebhookExtensions: the admin-auth check hashes both sides with SHA-256 beforeFixedTimeEquals(avoiding a length-based timing signal), the body is bounded both viaIHttpMaxRequestBodySizeFeatureand a manual streaming read, and errors map to the documented status codes.TypedFirehoseConsumerintegration: state is only persisted inDeliveredAsync(called afteryield returnresumes), so a resync snapshot's state is durably applied beforeNextPendingAsyncre-verifies held events against it — the sequencing that makes an at-least-once resync correct.Given the size of the diff (~7,100 lines) and the truncation of the diff itself partway through
RepoSyncVerifier.cs, I pulled the full files from the PR branch directly via git rather than relying solely on the (truncated) diff text. I did not exhaustively re-derive every MST split/merge case or review every line ofRepoResync.cs's bookkeeping (backoff maps, overflow handling), but skimmed them for obvious bounds/lifecycle issues and found none. No blocking issues found.⏱ 3m 57s · 20 turns · tokens: 126.5k in / 16.9k out (+2283.3k cache-read)
📜 Full session transcript
@claude /review — re-review please. Five new commits (9e9b9b5..72b501e) on top of the approved
6aa8eaffix six findings from an independent adversarial review (see "Review fixes" in the description):prevDatadesynchronizes the repo.OnUnreadableEvent.Please review these commits in full (
git diff 6aa8eaf..72b501e), especially theCursorTrackerpersist-limit interaction withRepoResyncand theTypedFirehoseConsumerdelivery path.🤖 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.
✅ Verdict: approve — no blocking issues found.
This is a re-review of the five fix commits that landed on top of the original
6aa8eaf(which my prior pass had already approved as no-blocking-issues):9e9b9b5,7dbf41a,18423d2,bc2a3b9,72b501e. Since the diff supplied in the PR body was truncated partway through, I read each fix commit directly from the local origin/issue-134-sync ref to verify the actual changes rather than the truncated text.All six previously-reported findings are fixed correctly:
9e9b9b5) — the old accept-on-matching-since path is removed; a commit without prevData is only trusted as a repository's very first event. The test suite correctly flips the old "chains on since" assertion to "desynchronizes", and adds a first-commit-accepted case plus a stripped-prevData-with-dropped-op regression that reproduces the exact attack described.7dbf41a) —CursorTracker.SetPersistLimit/Persistable()correctly caps the saved cursor;RepoResyncCoordinatortracks per-sequence hold counts (_undelivered, aSortedDictionaryso the minimum is cheap to find) and publishesmin - 1on every hold/release. I traced all three release call sites — normal delivery (DeliveredAsync), a held event failing re-verification, and abandonment after a failed fetch — plus the overflow-clear path, which previously would have left dropped events counted forever and blocked cursor persistence permanently; it's now correctly released too. The newConsumeAsync_StopBetweenSnapshotAndHeldEvents_ReplaysTheHeldEventstest exercises the exact crash-recovery scenario.MaxConcurrency(18423d2) —_concurrency.Release()moved from the background worker (right after the fetch) intoCompleteAsync, which only runs on the consumer's own loop when it dequeues the completion. This matches the corrected memory-bound doc comment(MaxConcurrency + 1) × MaxRepoBytes.FetchTimeoutnow covers the whole download body, including for customIRepoFetchers (bc2a3b9) — a linked,CancelAfter-based token wraps every fetcher attempt inFetchSnapshotAsync, andRepoDownload.GetRepoAsyncseparately budgets the streaming read against the client's ownTimeout(sinceResponseHeadersReaddoesn't cover the body). A declaredContent-Lengthno longer pre-sizes the buffer past 1 MiB, closing the forced-allocation angle.72b501e), reported to the newOnUnreadableEventhook (defaulting to a warning log); 401/413 paths are unchanged.I did not have the ability to fetch/build a worktree in this session (git worktree/fetch operations require approval I couldn't obtain), so I relied on reading the full commits from the already-present
origin/issue-134-syncremote-tracking ref rather than running the test suite myself; the PR's own reported numbers (4759 passed, 0 failed, 0 warnings) are consistent with what I'd expect given the fixes and their accompanying regression tests. No new issues found in this pass.⏱ 2m 9s · 26 turns · tokens: 105.7k in / 8.6k out (+2790.3k cache-read)
📜 Full session transcript