Add support for spaces #89

Closed
opened 2026-08-20 20:23:20 +00:00 by Grandiras · 2 comments
Owner

https://atproto.com/blog/atproto-spaces-alpha
(this is a major effort, feel free to split into multiple issues)

https://atproto.com/blog/atproto-spaces-alpha (this is a major effort, feel free to split into multiple issues)
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/314); the reply will link the full session transcript. <!-- claude-run:comment --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/issue-89/run-314.md) — updating every ~5 minutes while the task runs.
Collaborator

Implemented the client side of AT Protocol's permissioned data protocol on branch claude/issue-89.

What spaces are

AT Protocol is not one protocol but several. Public broadcast publishes signed, redistributable records anyone may crawl. Permissioned data (proposal 0016) is the other half: the same shape — DID-based authority, per-user repos, Lexicon-typed records, applications crawling hosts — with an access perimeter around it, called a space. It's what bookmarks, drafts, subscriber-only posts, private forums, and group chats need.

It provides access control, not confidentiality — not end-to-end encrypted, and every service handling it can read it. That's deliberate: it's what makes server-side search, indexing, and moderation possible. It's also an alpha proposal with no security review, which docs/spaces.md says plainly.

What landed

I worked from the actual lexicons and reference implementation on the alpha branch, not just the blog post.

Protocol machinery (src/ATProtoNet/Spaces/) — SpaceUri/SpaceRecordUri; LtHash (the homomorphic set hash that replaces the MST root); SpaceRepoCommit/SpaceCommitVerifier; SpaceRepoCar for the two-root CAR; SpaceCredentialProvider/SpaceReader for the DPoP-bound credential exchange; SpaceSyncer for incremental sync with automatic full-state recovery.

Endpoints — all 20 com.atproto.space.* methods on client.Space, all 8 com.atproto.simplespace.* on client.SimpleSpace, plus AtProtoScopes.Space(...) with SpaceAction/SpaceManage.

Three things I'd point at specifically:

  • A commit is deliberately not a rebroadcastable proof. The signature covers only the context — space, author, rev, and 32 fresh random bytes — never the digest, which is bound by a symmetric MAC. A reader gets full authenticity; a leaked commit proves nothing, because anyone holding it can forge a valid MAC for any digest.
  • Sync is self-healing on the digest comparison, not on receiving every operation. A dropped write, a compacted oplog, and local corruption all surface identically as a mismatch, and all repair identically. I narrowed the recovery fallback so a 429 or 5xx propagates instead of triggering a full re-download.
  • BLAKE3 XOF from scratch — .NET ships none, and the SDK carries no third-party crypto (same reason it bundles no zstd).

Two pre-existing bugs I had to fix

Both were prerequisites, and both are worth knowing about independently:

  • DAG-CBOR map keys were sorted bytewise, not length-first. The two agree only when no keys differ in length, so every CID the SDK computed for a record spanning more than one key length was wrong — an app.bsky.feed.post with both text and createdAt among them. DagCborDecoder's matching check had the same rule and would have rejected valid blocks. Verified by fetching a real post from a live PDS: length-first reproduces the network's CID, bytewise does not.
  • DPoP proofs put the query string in htu, where RFC 9449 §4.2 requires it stripped — so every proof for an XRPC query named an htu no conforming server would match.

Verification

dotnet build clean (zero warnings, CS1591-as-error), 1510 tests pass in Release, 212 of them new. The crypto isn't self-consistent-and-hoping — LtHash digests, commit context encoding, and the MAC are pinned against values generated by the reference implementation's own libraries; BLAKE3 against 27 official vectors (0–102400 bytes, 131 bytes of extended output each); the ordering fix against a real network CID.

Also: docs/spaces.md, updates to api-reference/index/architecture/README/CLAUDE.md, CHANGELOG.md, and a samples/SpacesSample.

What I left out

Server-side handling (acting as a space authority or repo host), lexgen support for "type": "space", and integration tests — no PDS release serves these endpoints yet. Filed as three follow-up issues.


⏱ 44m 58s · 160 turns · tokens: 405.0k in / 208.8k out (+38555.0k cache-read)
📜 Full session transcript
→ Opened PR: #90
→ Filed follow-up issue #91: Spaces: server-side support in ATProtoNet.Server (space authority and repo host)
→ Filed follow-up issue #92: atproto-lexgen does not understand "type": "space" Lexicon definitions
→ Filed follow-up issue #93: Spaces: integration tests against a permissioned-data PDS

Implemented the client side of AT Protocol's permissioned data protocol on branch **`claude/issue-89`**. ## What spaces are AT Protocol is not one protocol but several. Public broadcast publishes signed, redistributable records anyone may crawl. [Permissioned data](https://atproto.com/blog/atproto-spaces-alpha) ([proposal 0016](https://github.com/bluesky-social/proposals/tree/main/0016-permissioned-data)) is the other half: the same shape — DID-based authority, per-user repos, Lexicon-typed records, applications crawling hosts — with an access perimeter around it, called a **space**. It's what bookmarks, drafts, subscriber-only posts, private forums, and group chats need. It provides **access control, not confidentiality** — not end-to-end encrypted, and every service handling it can read it. That's deliberate: it's what makes server-side search, indexing, and moderation possible. It's also an alpha proposal with no security review, which `docs/spaces.md` says plainly. ## What landed I worked from the actual lexicons and reference implementation on the alpha branch, not just the blog post. **Protocol machinery** (`src/ATProtoNet/Spaces/`) — `SpaceUri`/`SpaceRecordUri`; `LtHash` (the homomorphic set hash that replaces the MST root); `SpaceRepoCommit`/`SpaceCommitVerifier`; `SpaceRepoCar` for the two-root CAR; `SpaceCredentialProvider`/`SpaceReader` for the DPoP-bound credential exchange; `SpaceSyncer` for incremental sync with automatic full-state recovery. **Endpoints** — all 20 `com.atproto.space.*` methods on `client.Space`, all 8 `com.atproto.simplespace.*` on `client.SimpleSpace`, plus `AtProtoScopes.Space(...)` with `SpaceAction`/`SpaceManage`. Three things I'd point at specifically: - A commit is deliberately **not** a rebroadcastable proof. The signature covers only the context — space, author, rev, and 32 fresh random bytes — never the digest, which is bound by a *symmetric* MAC. A reader gets full authenticity; a leaked commit proves nothing, because anyone holding it can forge a valid MAC for any digest. - Sync is **self-healing** on the digest comparison, not on receiving every operation. A dropped write, a compacted oplog, and local corruption all surface identically as a mismatch, and all repair identically. I narrowed the recovery fallback so a 429 or 5xx propagates instead of triggering a full re-download. - **BLAKE3 XOF from scratch** — .NET ships none, and the SDK carries no third-party crypto (same reason it bundles no zstd). ## Two pre-existing bugs I had to fix Both were prerequisites, and both are worth knowing about independently: - **DAG-CBOR map keys were sorted bytewise, not length-first.** The two agree only when no keys differ in length, so every CID the SDK computed for a record spanning more than one key length was wrong — an `app.bsky.feed.post` with both `text` and `createdAt` among them. `DagCborDecoder`'s matching check had the same rule and would have rejected valid blocks. Verified by fetching a real post from a live PDS: length-first reproduces the network's CID, bytewise does not. - **DPoP proofs put the query string in `htu`,** where RFC 9449 §4.2 requires it stripped — so every proof for an XRPC *query* named an `htu` no conforming server would match. ## Verification `dotnet build` clean (zero warnings, CS1591-as-error), **1510 tests pass in Release**, 212 of them new. The crypto isn't self-consistent-and-hoping — LtHash digests, commit context encoding, and the MAC are pinned against values generated by the reference implementation's own libraries; BLAKE3 against 27 official vectors (0–102400 bytes, 131 bytes of extended output each); the ordering fix against a real network CID. Also: `docs/spaces.md`, updates to `api-reference`/`index`/`architecture`/`README`/`CLAUDE.md`, `CHANGELOG.md`, and a `samples/SpacesSample`. ## What I left out Server-side handling (acting *as* a space authority or repo host), lexgen support for `"type": "space"`, and integration tests — no PDS release serves these endpoints yet. Filed as three follow-up issues. --- ⏱ 44m 58s · 160 turns · tokens: 405.0k in / 208.8k out (+38555.0k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/issue-89/run-314.md) → Opened PR: https://git.grandiras.net/Grandiras/ATProto.NET/pulls/90 → Filed follow-up issue [#91](https://git.grandiras.net/Grandiras/ATProto.NET/issues/91): Spaces: server-side support in ATProtoNet.Server (space authority and repo host) → Filed follow-up issue [#92](https://git.grandiras.net/Grandiras/ATProto.NET/issues/92): atproto-lexgen does not understand `"type": "space"` Lexicon definitions → Filed follow-up issue [#93](https://git.grandiras.net/Grandiras/ATProto.NET/issues/93): Spaces: integration tests against a permissioned-data PDS
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
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#89
No description provided.