Author of design brief: Nick Gerakines (CTO, Graze Social; founder, Smoke Signal & Lexicon Garden; author, AIP OAuth server; AT Protocol Community Fund member).
Target home: tangled.org/ngerakines.me/atproto-crates, as a new crate atproto-pds.
Date: May 1, 2026 (revision 2).
Architectural North Star: A low-latency, highly-performant Rust PDS that is fully conformant to the existing reference implementations and is architected from day zero to support permissioned data spaces as a first-class concern, grounded in the concrete design laid out in bluesky-social/atproto/docs/superpowers/specs/2026-04-22-permissioned-data-pds-design.md (Daniel Holmgren's PDS implementation design, hereafter "the Spaces Design Spec"). The Spaces Design Spec supersedes the earlier Permissioned Data Diary blog posts as the authoritative source for protocol mechanics; the diary is retained only as conceptual background.
Revision 2 changelog: All permissioned-data sections (§1.4, §2.8, §3.6, §4.5, §5.5, §6.5, §7.3, §8.7, §9.8, §10.6, §15, §17, §19) rewritten to match the Spaces Design Spec. Specifically: spaces use SetHash (XOR-SHA256 placeholder for ECMH/ltHash) not bare ECMH; commits use HKDF-derived HMAC with per-commit random IKM for deniability; XRPC namespace is
com.atproto.space.*(notcommunity.lexicon.space.*); inter-PDS notification isnotifyWrite/notifyMembership(not firehose redaction); credentials are exchanged via a two-step MemberGrant → SpaceCredential JWT flow.
A PDS in atproto is, in network terms, simultaneously:
- A repository host: it stores one or more atproto repositories (one per account), including the Merkle Search Tree (MST), the signed commit chain, all DAG-CBOR records, and referenced blobs. With the Spaces Design Spec, a PDS additionally hosts permissioned repos — one per
(owner, space-type, space-key)triple the user participates in. - An identity custodian: it manages did:plc rotation keys for hosted accounts (when the user delegates), holds the active atproto signing key, and submits PLC operations on behalf of the user.
- An XRPC service host: it exposes the
com.atproto.*HTTP API (and well-known endpoints) used by clients to read/write the repo, manage the account, perform sync, and operate identity. Permissioned data adds thecom.atproto.space.*namespace. - An OAuth Authorization Server and Resource Server (per
atproto.com/specs/oauth): it issues DPoP-bound access/refresh tokens via PAR + PKCE, hosts the authorization UI, publishes/.well-known/oauth-authorization-serverand/.well-known/oauth-protected-resource(RFC 9728), and validates inbound resource requests. - A firehose emitter: it publishes
com.atproto.sync.subscribeReposevents (#commit,#identity,#account,#sync,#info) over a WebSocket, durably sequenced. Permissioned writes are never emitted on this stream; they propagate via point-to-pointnotifyWrite/notifyMembershippush instead (§6.5). - A request proxy: via the
Atproto-ProxyHTTP header and inter-service auth (service auth JWTs minted with the account's signing key), the PDS forwards authenticated client requests to AppViews, labelers, chat services, and feed generators. - A moderation node: it enforces account-level and record-level takedowns, hosts admin endpoints, forwards moderation reports, and may subscribe to labelers.
- A space credential issuer & verifier: per the Spaces Design Spec, the PDS mints MemberGrant JWTs for its own accounts (when an OAuth-bound app requests them) and verifies inbound SpaceCredential JWTs presented by remote apps requesting permissioned reads.
- Relays (e.g.,
bsky.network, Blacksky's relay, cerulea, the Sync 1.1 reference relay) crawl PDSes viarequestCrawl/listHostsand aggregate firehoses. As of Sync 1.1, relays no longer need archival storage; they validate via inductive proofs. The PDS is authoritative for repo state. Relays are unaffected by permissioned data — they only see the public realm. - AppViews (
api.bsky.app, Tangled, Smoke Signal, Frontpage, Leaflet, Bookhive, etc.) consume the firehose and build app-specific indices. The PDS does not implement AppView logic but does proxy requests to them via service auth. "Syncing apps" in the Spaces Design Spec are AppView-equivalent services that hold space credentials and pull permissioned data viagetRepoOplogfrom member PDSes. - Labelers (Ozone and other labeler implementations) emit labels out-of-band; the PDS may subscribe to forward labels to clients and/or to enforce auto-takedowns.
- Feed generators are externally hosted; the PDS proxies and authenticates client requests to them.
- PLC Directory (
plc.directoryand replicas) holdsdid:plcdocuments. The PDS reads it for DID resolution and writes to it for genesis ops, rotation, deactivation, and migration.
- Public ingress (writes): Client → DPoP-bound HTTPS → XRPC handler → authn/authz middleware → lexicon validation → repo write transaction (block store + MST mutate + commit signing) → outbox sequencer → firehose subscribers and
requestCrawl'd relays. - Permissioned ingress (writes): Client → DPoP-bound HTTPS →
com.atproto.space.createRecord/putRecord/deleteRecord/applyWrites→ authn (OAuth, scoped to space) → SpaceRepoformatCommit→ SpaceTransactorapplyRepoCommit(writes tospace_record, updatesspace_reporev/setHash, appendsspace_record_oplog) → fire-and-forgetnotifyWriteto space owner's PDS → space owner's PDS relays to each registered syncing app vianotifyWrite. - Public egress (reads): Client → XRPC handler → cache → block store / MST traversal → JSON or DAG-CBOR response. Sync endpoints stream CAR files. The firehose continually flushes
#commitand#identityevents from the durable outbox. - Permissioned egress (reads): Apps holding a SpaceCredential →
com.atproto.space.getRecord/listRecords/getRepoState/getRepoOplogagainst the member's PDS, orgetMemberState/getMemberOplogagainst the owner's PDS → PDS verifies the SpaceCredential by resolving the space owner's DID doc → response.
The Spaces Design Spec defines a space as an authorization and sync boundary for permissioned records representing a shared social context. A space includes records from many users, each storing their own records on their own PDS. Concretely:
- Space identity:
ats://<ownerDid>/<spaceType>/<spaceKey>. The URI schemeats://is provisional in the spec. - Owner DID is the root of trust — owns the member list, signs SpaceCredentials.
- Member DIDs participate by writing records to their own PDS, scoped to a space URI. Membership is enforced at the read/sync boundary, not at write time — a user can write records scoped to any space URI on their own PDS; consumers check the member list when ingesting.
- Storage is split: each user's per-actor SQLite database gets new tables (
space,space_member_state,space_repo,space_record,space_member,space_record_oplog,space_member_oplog,space_credential_recipient). The public repo is unaffected. - Sync is oplog-based, not MST-diff-based. Each space has a per-user
space_record_oplogordered by(rev, idx)(mirroringapplyWritesatomic-batch semantics). Apps pullgetRepoOplog?since=<rev>and replay; on setHash mismatch, fall back to full resync vialistRecords. - Commitment is via SetHash — currently XOR-of-SHA256 placeholder, to be replaced by ECMH or ltHash before production. The owner has a member-list SetHash; each participant has a per-space record SetHash.
- Commit authentication is HKDF-derived HMAC with random IKM per commit, signed by the user's atproto signing key, with
SpaceContext { spaceDid, spaceType, spaceKey, userDid, scope: 'records' | 'members', rev }as HKDF info. Random IKM gives deniability (a commit cannot be re-attributed to its signer outside the original verification context);scopeensures domain separation between record commits and member-list commits. - Credentials: a two-step MemberGrant (member's PDS → app, signed by member, scoped to client ID + lxm) + SpaceCredential (owner's PDS → app, signed by owner, valid 2–4 hours) JWT exchange.
This means atproto-pds must, from the start, treat the storage, sync, authorization, and notification layers as multi-realm: the public repo is one realm; permissioned spaces are additional realms each with their own commit primitive (SetHash + signed HMAC), sync protocol (oplog + setHash verification), addressing (ats://), notification mechanism (notifyWrite push), and OAuth scope. The TS, Go (indigo, cocoon), and Rust (rsky, tranquil-pds) reference implementations all model the public repo as the single source of truth — the Spaces Design Spec defines exactly how to extend, and atproto-pds should generalize from the bottom up. Every subsystem section below identifies the specific extension point.
The "sidecar pattern" (Nick's terminology, from the Smoke Signal architecture and his blog on authoritative/unauthoritative references) maps to the spec's records-by-strongRef pattern: an authoritative community.lexicon.location.address record can live in a permissioned space, and a public community.lexicon.calendar.event record references it via strongRef. Readers without space credentials see the strongRef but cannot dereference. The PDS implements this by routing record fetches through a realm-aware resolver that returns 404/403 for unauthorized permissioned-record requests.
com.atproto.server.createAccount parameters: email, handle, did?, inviteCode?, password?, recoveryKey?, plcOp?, verificationCode?, verificationPhone?. Behaviors required for conformance:
- Invite codes: Optional, controlled by
PDS_INVITE_REQUIRED. Reference TS PDS, cocoon, rsky-pds, and tranquil-pds all support invite issuance, single-use enforcement, and per-account interval-based reissuance (PDS_INVITE_INTERVAL, default 7 days). - Email verification: Most implementations issue a
verificationCodetoken by email; cocoon also supports SMTP optional. Tranquil-pds extends this to multi-channel (email/discord/telegram/signal). Spec is permissive —atproto-pdsshould treat verification as pluggable. - Handle assignment: Either a subdomain of
PDS_SERVICE_HANDLE_DOMAINS(e.g.,alice.pds.example.com) or a user-provided handle resolvable to the new DID. PDS must verify handle ownership (DNS TXT_atproto.<handle>orhttps://<handle>/.well-known/atproto-did) before assignment. - DID creation paths: (a) PDS creates a
did:plcgenesis op signed by the PDS's rotation key and (optionally) the user-suppliedrecoveryKey; (b) PDS accepts a pre-signedplcOp(used for migration); (c)did:webwhere the PDS hosts/.well-known/did.jsonfor managed subdomains, or the user provides their owndid:web. - Migration mode: When
didandplcOpare supplied with a service-auth JWT issued by the prior PDS (com.atproto.server.getServiceAuthwithlxm=com.atproto.server.createAccount), the new account is created in deactivated state pending repo import.
Per the Account Hosting specification: active, deactivated, takendown, suspended, plus the implicit deleted state. State changes emit #account firehose events with active boolean and optional status reason. getRepoStatus and checkAccountStatus expose state.
deactivated: voluntary; identity may still be served, repo not accessible to public sync.takendown: moderator action; blocks reads and writes; emits firehose#account active=false status=takendown.suspended: temporary admin action.deleted: hard delete after the optionaldeleteAftergrace window ofdeactivateAccount.
com.atproto.server.requestAccountDelete→ emails a token;deleteAccountconsumes it and tombstones records, deletes blobs, and purges credentials. Reference TS PDS retains a stub; cocoon performs full purge.- Public data export is via
com.atproto.sync.getRepo(CAR),listBlobs+getBlobfor media, andapp.bsky.actor.getPreferencesfor private prefs. - Permissioned data export is a new requirement:
com.atproto.space.listSpaces+ per-spacelistRecords+getMemberState/getMemberOplogfor owned spaces. There is currently no spec-defined CAR-equivalent for permissioned repos; per-space SQLite-table dump is the pragmatic export.
com.atproto.server.requestEmailUpdate,confirmEmail,updateEmail,requestPasswordReset,resetPassword. Phone verification is in some lexicons (legacy), but cocoon and tranquil-pds support TOTP/WebAuthn as superset features.- Bcrypt or Argon2 for password hashing (TS PDS uses scrypt; rsky-pds bcrypt; tranquil argon2).
atproto-pdsshould default to argon2id.
createAppPassword,listAppPasswords,revokeAppPassword. App passwords mint long-lived JWT sessions with restricted scope (privileged: falseexcludes chat, etc.). They are deprecated for new clients; OAuth is the future.- OAuth sessions issue DPoP-bound access tokens (15–30 min) and refresh tokens (single-use rotation, longer-lived per the OAuth spec).
- Space credentials are NOT app passwords — they are short-lived (2–4 hour) JWTs bound to a specific space + client ID, separate from the account's auth tokens. See §15.
com.atproto.server.getServiceAuth mints a short-lived JWT signed with the account's atproto signing key, with claims iss=did, aud=did:web:appview.example, lxm=<NSID-of-method>, exp. This is used:
- by the PDS itself when proxying client calls,
- by clients calling AppViews directly,
- for inter-PDS account migration,
- for inter-service trust where DPoP is not appropriate,
- for
notifyWriteandnotifyMembershippush notifications between member's PDS, owner's PDS, and syncing apps (per Spaces Design Spec).
The TS PDS and cocoon both restrict lxm to be required; older indigo PDS does not. atproto-pds should follow the strict spec.
com.atproto.identity.requestPlcOperationSignature(email-token gate),signPlcOperation(PDS signs with held rotation key),submitPlcOperation(forward to PLC directory),getRecommendedDidCredentials(returns the credentials the PDS would set if it controlled the DID).- Account migration sequence (per
atproto.com/guides/account-migration): old PDS issues service auth → new PDScreateAccount(withdid+plcOpdeactivated) →com.atproto.repo.importRepo(CAR upload) →listMissingBlobs+uploadBlobloop →getPreferences/putPreferences→signPlcOperationto rotate keys/services →submitPlcOperation→activateAccounton new +deactivateAccount(with optionaldeleteAfter) on old. - Permissioned data migration is not yet specified. The Spaces Design Spec is silent on migration; this is a known open question.
atproto-pdsshould design acom.atproto.space.exportSpaces/com.atproto.space.importSpaces(provisional NSIDs) capability and feed feedback upstream when migration semantics are addressed.
- The
spacetable on the per-actor store records every space the user owns or is a member of. Account creation initializes this table as empty. Account deletion must cascade throughspace,space_repo,space_record,space_member,space_record_oplog,space_member_oplog,space_credential_recipient. - Space ownership is tied to the account. Account deletion of a space owner orphans the spaces — there is no cross-account ownership transfer in the spec. The PDS should warn the user at deletion time.
- App passwords MUST NOT be used to write to permissioned spaces; only OAuth sessions with appropriate scopes can write. This is enforced in the auth verifier.
- A removed member retains their
space_recordrows (per the spec: "the user's data remains intact — they may want to rejoin"). Garbage collection is left as an admin policy.
Per atproto.com/specs/repository: the repo is a key/value MST keyed by <collection>/<rkey> (UTF-8 bytes), values are CIDs of DAG-CBOR-encoded records. MST nodes have a deterministic structure (left subtree CID, entries with prefix-compressed keys, right subtree CIDs). The current binary format is v3. Fanout/leading-zero parameter and SHA-256 hashing are mandatory.
Commit object fields: did, version (currently 3), data (root MST CID), rev (TID, monotonic), prev (deprecated/null), prevData (previous root MST CID, required for inductive sync), and sig (raw bytes signature over the unsigned commit serialized as DRISL/DAG-CBOR). DRISL CBOR (deterministic) is used for the canonical encoding for both signing and CID generation.
CIDs use SHA-256, DAG-CBOR codec for structured data, raw codec for blobs. The "blessed" CID form is base32 with b prefix in string contexts, raw bytes in CBOR. atproto-dasl already implements DRISL encoding, CID computation, CARv1, and block storage backends in the existing workspace and is the foundation for atproto-pds's repo layer.
There is no spec mandate on storage backend. The reference implementations diverge significantly:
| Implementation | Block store | Account DB | Blob store |
|---|---|---|---|
TS reference (@atproto/pds) |
SQLite per-account (one DB file per repo) | SQLite shared accounts.db |
Disk filesystem |
| indigo PDS (Go, deprecated for PDS) | carstore (CAR shards on disk + gorm metadata) | Postgres or SQLite | Filesystem |
| rsky-pds (Rust) | Postgres (shared) | Postgres | S3-compatible |
| cocoon (Go) | SQLite block store (default), Postgres optional | SQLite or Postgres | SQLite blob store, optional S3 |
| tranquil-pds (Rust) | Postgres (required) | Postgres | Filesystem (default), optional S3, optional Valkey cache |
The Spaces Design Spec assumes per-actor SQLite (it specifies actor-store/space/sql-repo-storage.ts). This is the strongest hint yet that the upstream direction continues to favor per-account isolation, because the space tables piggy-back on the per-actor DB. atproto-pds should support both per-actor SQLite and a unified Fjall keyspace, with the per-actor model recommended for spec-fidelity.
Recommendations for atproto-pds:
- Define a
BlockStoretrait and ship multiple backends. Per-account SQLite gives strong isolation and trivially supports per-account compaction/takedown but limits cross-account batch reads. Shared Postgres unifies operations and supports horizontal scaling but trades latency. Fjall (LSM, pure Rust, ~3.5s compile, ~2.2 MB binary) is a strong default for embedded single-host PDS deployments — low-latency reads, compactable, no JNI/C++. - The MST mutation path is the hot path. Cache the working MST as an in-memory
Arc<Mst>per active repo, persist nodes lazily on commit, and use a write-ahead-log (Fjall's manifest, or a separate WAL crate) for crash safety.
Use the K-256 (secp256k1) or P-256 (secp256r1) atproto signing key from the DID document. Sign SHA256(DRISL(unsigned_commit)). Note signatures are normalized to low-S form. K-256 is the default for did:plc; P-256 is also valid. The rotation key is a separate key used only for PLC operations and never signs commits.
CARv1, mimetype application/vnd.ipld.car. For full repo export, root[0] = current commit CID. For diffs (firehose #commit payload), root[0] = current commit CID, and the body contains exactly the new + modified blocks since since. Implementation must be streaming both ways (importRepo can be multi-GB).
CAR is the public-repo format only. Permissioned repos do not use CAR — they use SQL tables and oplog streams (§7.3).
The Spaces Design Spec specifies these per-actor-store tables. atproto-pds should mirror them exactly, modulo Rust naming conventions:
space — every space the user owns or is a member of.
| Column | Type | Notes |
|---|---|---|
uri |
TEXT PK | ats://<ownerDid>/<spaceType>/<spaceKey> |
is_owner |
INTEGER (bool) | |
is_member |
INTEGER (bool) | Set via notifyMembership |
created_at |
TEXT (ISO8601) |
space_member_state — owner-only: member list commitment.
| Column | Type | Notes |
|---|---|---|
space |
TEXT PK, FK | |
set_hash |
BLOB nullable | SetHash over member DIDs |
rev |
TEXT nullable | Member list TID |
The spec emphasizes: this table only exists for owned spaces. Presence of the row is the truth-value of "I own this space" — no nullable columns on space.
space_repo — per-space record commitment for this user.
| Column | Type | Notes |
|---|---|---|
space |
TEXT PK, FK | |
set_hash |
BLOB nullable | |
rev |
TEXT nullable |
space_record — actual records.
| Column | Type | Notes |
|---|---|---|
space |
TEXT | |
collection |
TEXT | NSID |
rkey |
TEXT | |
cid |
TEXT | |
value |
BLOB | DAG-CBOR record |
repo_rev |
TEXT | rev at write |
indexed_at |
TEXT |
PK (space, collection, rkey). Index (space, repo_rev) for since-queries.
space_member — owner-only: actual member list.
PK (space, did). Columns: space, did, member_rev, added_at.
space_record_oplog — record-mutation log per space.
PK (space, rev, idx). Columns: space, rev, idx, action (create/update/delete), collection, rkey, cid, prev.
space_member_oplog — owner-only: member-mutation log.
PK (space, rev, idx). Columns: space, rev, idx, action (add/remove), did.
space_credential_recipient — owner-only: tracks services issued credentials, for notifyWrite fan-out.
PK (space, service_did). Columns: space, service_did, service_endpoint, last_issued_at.
Atomic batch semantics: A single applyWrites-style batch produces multiple oplog entries sharing a rev (TID) but with monotonically increasing idx. The owning space_repo.rev (or space_member_state.rev) is set to that commit's rev. This mirrors public-repo applyWrites.
Block storage and DAG-CBOR: Records in space_record.value are DAG-CBOR blobs identical in encoding to public records. The CID is computed identically. The MST is not used. SetHash takes its place as the cryptographic commitment.
SetHash element format (per spec):
- Records:
"<collection>/<rkey>:<cid>"byte string → SHA-256 → XOR-fold into accumulator. - Members: the DID string itself → SHA-256 → XOR-fold.
The XOR-of-SHA256 placeholder is explicitly to be replaced by ECMH or ltHash before production. atproto-pds should implement the SetHash interface as a trait so swapping the underlying primitive is a one-crate change.
Sidecar attachments: Not in the Spaces Design Spec. If atproto-pds wants to support sidecar/off-protocol attachments to public records (Nick's community.lexicon.preference.ai use case), the cleanest mapping is: store the attached private record as a normal permissioned record in a personal space owned by the user, and use strongRef from the public record. This requires no new storage primitive — it's an application-level pattern over the spec.
The Spaces Design Spec flags several TODOs that affect storage:
- SetHash algorithm: XOR placeholder, will move to ECMH or ltHash. Trait-based abstraction.
- Oplog retention policy: How long must a PDS keep oplog entries? Currently unspecified.
atproto-pdsshould default to retain-forever (with optional admin compaction), and signal this via a configurable. - URI scheme:
ats://<ownerDid>/<spaceType>/<spaceKey>is provisional.
| Endpoint | Behavior |
|---|---|
com.atproto.repo.createRecord |
Validates lexicon, generates rkey (TID by default), inserts into MST, commits, sequences firehose event. Supports swapCommit and optional rkey. |
com.atproto.repo.putRecord |
Idempotent upsert with swapRecord and swapCommit for optimistic concurrency. |
com.atproto.repo.deleteRecord |
Tombstone in MST, blob ref-count decrement, optional swapRecord/swapCommit. |
com.atproto.repo.applyWrites |
Atomic batch (create / update / delete) producing a single commit. Mandatory for migration replays and high-throughput clients. |
com.atproto.repo.getRecord |
Public, no auth; returns record + CID + URI. |
com.atproto.repo.listRecords |
Public; pagination via cursor (rkey-bounded). |
com.atproto.repo.describeRepo |
Public; returns DID, handle, collections, validity. |
com.atproto.repo.listMissingBlobs |
Auth; lists blobs referenced by records but not present locally. |
com.atproto.repo.uploadBlob |
Auth; see §4.4. |
com.atproto.repo.importRepo |
Auth; CAR import, indexes records, regenerates commit signed by the new PDS's signing key. |
Two spec-defined modes: strict (records must validate against resolved lexicons) and lenient (records that fail to resolve a lexicon are still accepted; only structural validity is checked). Indigo's lexicon.ValidateFlags defines AllowLegacyBlob, AllowLenientDatetime, RequireDataInUnknownUnions. atproto-pds should default to lenient on writes (to allow community lexicons that haven't propagated) while exposing strict mode as a config knob, mirroring TS PDS behavior.
The existing atproto-lexicon crate handles NSID validation, recursive resolution (DNS TXT _lexicon.<nsid> then HTTPS), and schema parsing.
Permissioned records use the same lexicon system. Records written via com.atproto.space.createRecord carry $type and validate identically to public records. The XRPC layer enforces the realm separation; the lexicon layer doesn't care which realm the record lives in.
TIDs are 13-char base32-sortable timestamps + clock id; per spec they are monotonically increasing within a repo. The existing atproto-record crate has a TID generator. Swap mechanics: swapCommit is the parent commit CID the client believes the repo is at; if the actual current commit differs the request fails with InvalidSwap. swapRecord is the prior record CID for putRecord/deleteRecord.
For permissioned repos, the equivalent of swapCommit is "the rev of the prior commit" (since there is no commit CID — the SetHash is the commitment, not a content-addressed object). The Spaces Design Spec does not explicitly call out swap semantics for permissioned writes; atproto-pds should implement swapRev as a direct analog and propose this upstream.
Per atproto.com/specs/blob:
uploadBlobreturns{ $type: blob, ref: { $link: CID }, mimeType, size }. CID isbafkrei…(raw codec, SHA-256). Empty blob is technically valid but typically rejected by app lexicons.- Server may sniff Content-Type, must reject if Content-Length mismatches.
- Temporary state: blobs not yet referenced by any record; servers should garbage-collect after a grace window (TS PDS: configurable, default ~hours; cocoon: hourly cron; rsky-pds: hourly).
- Lexicon validation enforces MIME type and size at reference time, not upload time.
app.bsky.embed.imagesrequiresimage/*and ≤1,000,000 bytes per image. - When the last referencing record is deleted, the blob is GC'd. Deleted account → all blobs purged.
Blobs in permissioned records: The Spaces Design Spec does not separately address blobs for permissioned records — implicitly, blobs uploaded via the existing com.atproto.repo.uploadBlob path are referenced by permissioned records via the same blob type. Access control is at the record layer, not the blob layer. A blob CID referenced from a permissioned record is still served by com.atproto.sync.getBlob to anyone who knows the CID. For applications that need blob-level access control, the recommended pattern is to encrypt the blob payload application-side; the PDS stores ciphertext.
Per the Spaces Design Spec, the new procedure/query namespace mirrors com.atproto.repo.* but with a required space parameter:
| Endpoint | Type | Auth | Description |
|---|---|---|---|
com.atproto.space.createRecord |
procedure | OAuth (member) | Create record in permissioned repo; appends oplog entry; triggers notifyWrite. |
com.atproto.space.putRecord |
procedure | OAuth | Idempotent upsert. |
com.atproto.space.deleteRecord |
procedure | OAuth | |
com.atproto.space.applyWrites |
procedure | OAuth | Atomic batch. |
com.atproto.space.getRecord |
query | Dual auth: user OAuth or SpaceCredential | |
com.atproto.space.listRecords |
query | Dual auth |
Every request includes a space parameter (the URI). After every write, the PDS performs a fire-and-forget notifyWrite to the space owner's PDS. The owner's PDS then relays via notifyWrite to each entry in space_credential_recipient (the syncing apps that have been issued credentials).
Write-time membership check: Per spec, the PDS does NOT enforce membership at write time. A user can write to any space URI on their own PDS; consumers will discover and ignore non-members at read time by checking the owner's member list. This is intentional — it keeps writes local and avoids a mandatory round-trip to the owner's PDS on every write.
Authoritative-public references to permissioned records: Public records may strongRef permissioned records. The public reader sees the strongRef but cannot dereference (the reference returns 404/403 without space credentials). This is Nick's "authoritative reference to permissioned data" pattern, supported naturally by the spec.
The PDS interacts with the PLC directory (plc.directory or a configurable endpoint via PDS_DID_PLC_URL) to:
- Genesis op: Compose
{type: plc_operation, rotationKeys: [pds_rotation_key, optional_user_recovery_key], verificationMethods: {atproto: did:key:...}, alsoKnownAs: ["at://handle"], services: {atproto_pds: {type: AtprotoPersonalDataServer, endpoint: pds_url}}}, sign with rotation key, POST. - Rotation: When updating handle, signing key, services, or rotation keys themselves. The
prevfield links to the previous op's CID. - Tombstone: When deleting an account, optionally publish a
tombstoneop. - Recovery: Within a 72-hour window after a rotation, a higher-priority rotation key can override.
The existing atproto-identity crate handles DID resolution, document parsing, P-256/K-256 keys; atproto-plc (in the workspace) handles operation construction. atproto-pds must extend these with the PDS-specific roles: signing PLC ops on behalf of users (gated by requestPlcOperationSignature token), submitting them, and caching DID documents.
Conformance: PDS must support hosting did:web:<pds-domain>:<account> style documents at /.well-known/did.json for managed subdomains, and must accept users whose DIDs are did:web they self-host. Not all reference PDSes are equal here:
- TS reference PDS: limited did:web support, primarily for the service DID.
- cocoon: full did:web + did:plc support.
- tranquil-pds + vicwalker fork: full did:web with PDS-hosted subdomains and BYO domains.
- rsky-pds: did:plc only.
atproto-pds should match cocoon/tranquil's superset.
Both methods required:
- DNS TXT at
_atproto.<handle>containingdid=did:plc:.... - HTTPS GET
https://<handle>/.well-known/atproto-didreturning the DID as plain text.
Conflict policy: per spec, both should agree; if they disagree, treat as unresolved. Indigo's resolver returns both; cocoon prefers DNS; TS PDS prefers DNS with HTTPS fallback. atproto-pds should query both in parallel and require agreement (configurable to allow either).
- Rotation keys: held by PDS for managed accounts (and optionally by user as recovery key). K-256 or P-256. Used only for PLC ops.
- Atproto signing key: per-account, held by PDS. K-256 or P-256. Signs commits, MemberGrants, and SpaceCredentials.
- DPoP keys: per-OAuth-session, held by client. P-256 only (ES256). Bound to access tokens.
- OAuth client authentication keys (confidential clients only): P-256, JWK published in client metadata.
atproto-pds should use atproto-identity::keys for all key handling. Hardware-backed key storage (HSM, secure enclave, KMS) should be a pluggable interface.
The Spaces Design Spec uses fewer key types than the diary posts implied. Specifically:
- There is no per-reader HMAC key. The earlier diary discussion of "per-reader keying" is not what the implementation does. Instead, each commit uses a fresh random IKM with HKDF deriving an HMAC key over
SpaceContext. The HMAC tag travels with the commit. This gives deniability (a commit cannot be re-attributed without seeing the IKM) without per-reader keying. - Member commits and record commits use different
scopevalues ('records'vs'members') inSpaceContext, providing domain separation so a record commit cannot be replayed as a member commit. - MemberGrants and SpaceCredentials are signed with the user's atproto signing key, the same key used for public commits. No new signing key.
- Owner-of-space DID resolution must be cached aggressively — every SpaceCredential verification requires resolving the owner's DID doc. The existing
atproto-identitycache handles this, but TTLs should be tuned with stale-while-revalidate to avoid latency stalls.
WebSocket endpoint with framed CBOR messages (Header { op, t } + body). Required event types:
| Type | Purpose | Sync 1.1 status |
|---|---|---|
#commit |
Repo update with CAR diff, ops list, prevData, since, rev, seq, time, blocks |
required |
#sync |
Force-set repo state without diff (recovery from drift) | required (Sync 1.1) |
#identity |
DID doc / handle change | required |
#account |
Account state change (active bool + status) |
required |
#info |
Out-of-band info (e.g., OutdatedCursor) |
required |
#handle |
Handle-only update | deprecated in favor of #identity |
#migrate |
Account moved | deprecated |
#tombstone |
Account deleted | deprecated in favor of #account |
seq is a strictly increasing 64-bit integer per PDS. Subscribers pass cursor=<seq> to resume; if the server has GC'd events past the cursor, it sends #info OutdatedCursor and disconnects. Outbox retention is implementation-defined; TS PDS keeps all events forever (limited by disk); cocoon supports configurable retention; tranquil retains 24h by default.
Per bluesky-social/proposals/0006-sync-iteration and the docs.bsky.app/blog/relay-sync-updates post:
#commitincludesprevData(prior MST root CID).- Each
repoOpincludesprev(previous record CID) for updates and deletes. - The CAR slice contains exactly the blocks needed to invert the operations and verify against
prevData. - Subscribers can validate signatures + structure without retaining repo state, using only the prior
prevData.
atproto-pds should emit fully-conformant Sync 1.1 events from day one.
The TS PDS sequencer is a single-writer SQLite outbox table. Cocoon uses a similar pattern with PostgreSQL LISTEN/NOTIFY. rsky-pds uses Postgres polling. For low latency in Rust:
- Use a tokio broadcast channel for live subscribers fed by the writer transaction commit hook.
- Use Fjall or a rolling SQLite file for durable outbox (cursor-resumable).
- Implement per-subscriber bounded queues; drop slow consumers with
ConsumerTooSlow(an#infofollowed by close). - A relay subscribing for backfill should be served paginated CAR slices via getRepo with
since=<rev>first, then attached to the live stream.
Permissioned writes are not on the public firehose. This is one of the most important architectural facts in the Spaces Design Spec. The earlier diary speculation about omission/redaction/cipher modes does not appear in the implementation design. Instead:
- A permissioned write triggers no
#commitevent. - Instead, the member's PDS does fire-and-forget
notifyWriteto the space owner's PDS. - The space owner's PDS looks up
space_credential_recipientand relaysnotifyWriteto each registered syncing app. - Each syncing app then pulls the actual ops via
getRepoOplogagainst the member's PDS, presenting its SpaceCredential.
This is a push-then-pull model. The push (notifyWrite) is a low-cost notification; the pull (getRepoOplog) carries the actual data and is gated by SpaceCredential verification. The firehose is reserved for the public realm.
Spec open questions on this path:
- notifyWrite fan-out failure: What happens when the space owner can't reach a syncing app? Currently fire-and-forget per spec; may need retry/backoff.
atproto-pdsshould implement bounded retry with exponential backoff and a dead-letter log so the operator can see persistent push failures. - Service endpoint discovery: How does the space owner learn a syncing app's notification endpoint? The spec flags this TBD — either resolved from the app's DID doc or provided in the SpaceCredential request.
atproto-pdsshould support both: prefer the app's DID doc service entry, fall back to a value provided at credential-issuance time.
atproto-pds should NOT introduce a community.lexicon.space.subscribeSpace WebSocket unless and until the spec adds one. The push-then-pull model is the official direction.
| Endpoint | Auth | Behavior |
|---|---|---|
getRepo |
none | Stream full repo or since-cursor diff as CARv1. |
getRepoStatus |
none | {did, active, status, rev} |
getLatestCommit |
none | {cid, rev} |
getRecord |
none | CAR containing the record + MST proof path |
getBlocks |
none | CAR for arbitrary CID list (within repo) |
listRepos |
none | Paginated DIDs hosted with rev |
listReposByCollection |
none (Sync 1.1, optional) | Paginated DIDs that contain records of an NSID |
listBlobs |
auth (PDS) / none (other hosts vary) | CIDs of blobs for a DID |
getBlob |
none | Raw blob bytes |
requestCrawl |
none | Tells a relay to start crawling this PDS |
notifyOfUpdate |
none (deprecated) | Hint for legacy relays |
subscribeRepos |
none (WS) | See §6 |
All CAR-producing endpoints must stream chunked, not buffer-all. For a full repo of 1M records this is the difference between feasible and OOM.
Per the Spaces Design Spec, sync is oplog + setHash, not CAR. Endpoints:
| Endpoint | Called on | Auth | Description |
|---|---|---|---|
getRepoState |
Member's PDS | SpaceCredential | Current {setHash, rev} for this member's permissioned repo. |
getRepoOplog |
Member's PDS | SpaceCredential | Oplog ops since since rev, plus current {setHash, rev}. |
getMemberState |
Owner's PDS | SpaceCredential | Current {setHash, rev} for member list. |
getMemberOplog |
Owner's PDS | SpaceCredential | Member-list oplog ops since since, plus current {setHash, rev}. |
Sync algorithm (apps):
- App holds SpaceCredential.
- For each member DID in the latest member list, app calls
getRepoOplog?since=<last_seen_rev>on that member's PDS. - PDS returns ops
[{rev, idx, action, collection, rkey, cid, prev}, ...]plus current{setHash, rev}. - App replays ops locally. For
create/updateops, app callsgetRecordon the member's PDS to fetch record content. - App computes its local SetHash and compares to the returned
setHash. - If mismatch: full resync via
listRecords, recompute from scratch.
Why the mismatch fallback matters: SetHash is order-independent (XOR/ECMH/ltHash all are), so the protocol survives op reordering, but if the PDS has compacted oplog entries beyond the app's since cursor, the app's replay will be incomplete. The setHash mismatch is the trigger for full resync.
Authoritative-public-record reads from permissioned context: When a permissioned record strongRefs a public record, the app already has access to the public record via normal com.atproto.repo.getRecord. No special handling.
Authoritative-permissioned-record reads from public context: When a public record strongRefs a permissioned record, public readers (without SpaceCredential) cannot dereference. The PDS returns 404 or a structured "permissioned" error; atproto-pds should standardize on RecordPermissioned as the error name and document it for upstream feedback.
The Spaces Design Spec flags:
- Oplog retention: vague "backfill window."
atproto-pdsshould default to retain-forever and expose admin compaction. - Credential expiration: 2–4 hours, exact default TBD.
atproto-pdsshould default to 3 hours, configurable.
All XRPC endpoints under /xrpc/{nsid}. Procedures use POST with JSON or CBOR body; queries use GET with query string params. Subscriptions use WebSocket GET with CBOR-framed messages. The existing atproto-xrpcs crate provides the framework: axum routing, JWT extractors, DID resolution. It needs PDS-specific extensions:
- DPoP nonce issuance and validation (
DPoP-Nonceheader rotation per RFC 9449). - Service auth verification with
lxmmatching. - App-password JWT validation.
- SpaceCredential JWT validation (new — see §15).
- MemberGrant JWT validation (new — see §15).
- Rate limit middleware.
- Lexicon-driven request/response validation.
CBOR (application/cbor, application/vnd.ipld.car) and JSON (application/json). Subscriptions use CBOR-only frames.
The middleware must:
- Extract the
Authorizationheader. - Detect token type by JWT
typheader:at+jwt(OAuth),dpop+jwt(DPoP proof),space_member_grant,space_credential, or unmarked (App Password / service auth). - Route to the appropriate verifier:
- OAuth: verify
DPoPheader, JWT thumbprint match, nonce, JWT signature against the PDS auth key. - App Password: signed with PDS JWT secret.
- Service auth: signed with sender DID's signing key, verify via DID doc, check
lxmandaud. - MemberGrant: signed with member's atproto signing key,
audis space owner DID,lxm=com.atproto.space.getSpaceCredential,clientIdmatches the requesting app. - SpaceCredential: signed with space owner's atproto signing key,
spacematches the requested resource.
- OAuth: verify
- Construct an
AuthContext { did, scope, app_password_id?, oauth_session_id?, service_audience?, space?, client_id? }.
The Spaces Design Spec explicitly notes the PDS auth verifier gets new paths beyond the existing OAuth / service auth verifiers. atproto-pds should implement these as separate axum extractors that compose.
TS PDS uses an in-memory token bucket per IP + DID. Tranquil-pds supports distributed rate limiting via Valkey. atproto-pds should ship in-memory by default and an optional Redis/Valkey backend, with configurable limits per endpoint family (write, read, sync, OAuth, space writes, space reads).
Permissioned-realm rate limits should be tunable separately because their access pattern is different — frequent getRepoOplog polling from many syncing apps can saturate a member's PDS if not bounded.
Per atproto convention: HTTP 400/401/403/429/500 with JSON {error: "ErrorName", message: "Human description"}. Lexicons enumerate possible error names. atproto-xrpcs already defines this shape.
New error names introduced by the Spaces Design Spec (anticipated): SpaceNotFound, NotSpaceMember, NotSpaceOwner, InvalidSpaceCredential, InvalidMemberGrant, OplogGap (returned by getRepoOplog if since is older than retained oplog).
server.*: createSession, refreshSession, deleteSession, getSession, createAccount, deleteAccount, requestAccountDelete, activateAccount, deactivateAccount, checkAccountStatus, describeServer, getServiceAuth, requestEmailUpdate, confirmEmail, updateEmail, requestPasswordReset, resetPassword, requestEmailConfirmation, createInviteCode, createInviteCodes, getAccountInviteCodes, createAppPassword, listAppPasswords, revokeAppPassword, reserveSigningKey.
identity.*: resolveHandle, updateHandle, getRecommendedDidCredentials, requestPlcOperationSignature, signPlcOperation, submitPlcOperation, resolveDid, resolveIdentity, refreshIdentity.
repo.*: createRecord, putRecord, deleteRecord, applyWrites, getRecord, listRecords, describeRepo, uploadBlob, importRepo, listMissingBlobs.
sync.*: getRepo, getRepoStatus, getLatestCommit, getRecord, getBlocks, getBlob, listBlobs, listRepos, listReposByCollection, requestCrawl, notifyOfUpdate (legacy), subscribeRepos (WS).
moderation.*: createReport (typically forwarded to moderation service via service auth).
admin.* (auth=admin): getAccountInfo, getAccountInfos, searchAccounts, disableAccountInvites, enableAccountInvites, disableInviteCodes, getInviteCodes, getSubjectStatus, updateSubjectStatus, sendEmail, updateAccountEmail, updateAccountHandle, updateAccountPassword, deleteAccount.
space.* (per Spaces Design Spec):
- Record CRUD (member's PDS, OAuth):
createRecord,putRecord,deleteRecord,applyWrites,getRecord(dual-auth),listRecords(dual-auth). - Space management (owner's PDS, OAuth):
createSpace,getSpace,listSpaces,addMember,removeMember,getMembers. - Credential flow:
getMemberGrant(member's PDS),getSpaceCredential(owner's PDS). - Sync (SpaceCredential):
getRepoState,getRepoOplog(member's PDS),getMemberState,getMemberOplog(owner's PDS). - Notifications (service auth):
notifyMembership(owner → member),notifyWrite(member → owner → syncing apps; same endpoint on both relay hops).
- The
spaceparameter is required on everycom.atproto.space.*write/read endpoint; the auth middleware must verify the OAuth scope grants access to that specific space (or that a SpaceCredential covers it). - The
getRecord/listRecordsendpoints undercom.atproto.space.*are explicitly dual auth per spec: either user OAuth (own PDS, own data) or SpaceCredential (remote app, syncing). The middleware must detect and route accordingly. notifyWriteis the same NSID on both directions of relay (member→owner and owner→syncing-app). The PDS must implement both the server side (receivenotifyWrite, dispatch to internal subscribers) and the client side (sendnotifyWriteoutbound after writes). Same fornotifyMembership.
(Nick has authored AIP and is comfortable here; this section calls out PDS-specific points.)
- PAR (RFC 9126) at
/oauth/par. Required. - PKCE (RFC 7636) with
S256. Required. - DPoP (RFC 9449) for both Authorization Server and Resource Server requests, including server-issued nonces (
DPoP-Nonceheader) and per-request rotation. ES256 (P-256) only. - Confidential client authentication via
private_key_jwt(client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer). - Public client support for browser SPAs and native mobile.
/.well-known/oauth-authorization-server(RFC 8414): issuer, authorization_endpoint, token_endpoint, par endpoint, jwks_uri, supported scopes, supported challenge methods (S256 only), supported DPoP signing alg values (ES256)./.well-known/oauth-protected-resource(RFC 9728): resource (the PDS URL), authorization_servers (often the same PDS, or a separate entryway).
Clients are identified by client_id = a fully-qualified https:// URL pointing to a JSON metadata document. The PDS fetches this at PAR time, validates redirect_uris, application_type, grant_types, response_types=["code"], dpop_bound_access_tokens=true, scope (must include atproto), and (for confidential clients) jwks or jwks_uri.
/oauth/authorize — accepts request_uri from PAR. Renders consent UI. The UI must:
- Show client name, logo, requested scopes (with human-readable permission set descriptions).
- Show the authenticated identity (login if needed).
- Allow approve/deny.
- Issue authorization code on approve, redirect to client's
redirect_uriwithcode,state,iss.
/oauth/token. Grant types: authorization_code and refresh_token. Refresh tokens are single-use rotation. Access tokens are JWTs signed by the PDS, claims iss, aud=did:web:pds.example.com, sub=did:plc:..., client_id, scope, cnf.jkt=<dpop-thumbprint>, iat, exp (15-30 min recommended).
/oauth/revoke per RFC 7009. Revoke refresh and bound access tokens.
Per atproto.com/specs/permission:
atproto— required base scope (proves identity, no other privileges).transition:generic— broad legacy-equivalent scope (post, like, follow, etc.).transition:chat.bsky— DM access.- Granular:
repo:<nsid>?action=...,rpc:<lxm>?aud=...,blob:<mime>/<size>,account?attr=email&action=read, etc. - Permission sets:
include:<nsid-of-set>resolves a published lexiconpermission-setinto multiple granular permissions.
The PDS must:
- Resolve permission sets via the lexicon resolution system (24h stale, 90d expiration recommended).
- Cache resolved sets with stale-while-revalidate.
- Re-compute scopes on token refresh (so lexicon updates propagate without re-consent).
The Spaces Design Spec does not yet finalize OAuth scope strings for spaces, but the implementation pattern implies them clearly. Candidate scope syntax (to propose upstream if not already settled):
space:<spaceType>?action=read— read access to spaces of a given type (the user's own permissioned repos for that NSID).space:<spaceType>?action=write— write access.space:<spaceType>?action=manage— for space owners only: create/delete spaces, manage member list.rpc:com.atproto.space.getMemberGrant?aud=<owner-did>— permission to mint member grants for a specific space owner.
The consent UI must surface space-typed scope grants as "Read your group records" / "Write to your group records" rather than raw NSIDs. This is a UX requirement implicit in the spec's emphasis on user understanding.
PDS-hosted; should be a server-rendered template (Askama or similar in Rust) for security (no JS injection surface for credentials). cocoon and tranquil-pds both ship Svelte/HTML UIs; tranquil's is the most polished. atproto-pds should keep this minimal but extensible.
- TS PDS: complete OAuth implementation, reference for spec.
- cocoon: complete, includes JWKs in metadata fix branch (
hailey/support-jwks-in-metadata). - rsky-pds: partial, in flux.
- tranquil-pds: complete + adds 2FA/WebAuthn/passkeys (superset).
atproto-pdsshould match cocoon + tranquil baseline.
com.atproto.admin.updateSubjectStatus with subject={did}, takedown={applied: true, ref: "..."}. The PDS must:
- Block all writes from that account.
- Block public reads of repo/blobs (return
AccountTakedownerror). - Emit
#account active=false status=takendownon firehose. - Optionally notify upstream relays.
Same endpoint with subject={uri, cid}. PDS hides the record from getRecord, omits from listRecords (or returns with takedown flag), but the record technically remains in the repo. getRepo includes it (so relays can still verify the chain) but a separate label/status indicates suppression. This is implementation-divergent — TS PDS removes from the firehose path entirely on takedown; cocoon hides on read; rsky-pds is selective.
Admin-configurable blocklist for new account creation. All five reference implementations support this.
com.atproto.moderation.createReport is per-spec a forwarded call: the PDS proxies to a configured moderation service (Ozone) via service auth. Cocoon explicitly comments that this should be proxied, not implemented locally. atproto-pds follows this — a MODERATION_SERVICE_DID config envvar is required and reports forward via Atproto-Proxy.
A PDS may subscribe to one or more labelers (e.g., Bluesky's Ozone) and propagate labels to clients via app.bsky.actor.getProfile-style hydration. This is optional; most PDSes do not do it directly (it's done by AppView). atproto-pds should support a LabelService pluggable interface for future use.
The Spaces Design Spec is silent on moderation of permissioned content — explicitly out of scope ("Out of scope: application coordination"). Realistic stance for atproto-pds:
- Account-level takedown of a space owner: blocks all
com.atproto.space.*operations against that owner's PDS, including SpaceCredential issuance. Existing members lose access. - Account-level takedown of a member: their permissioned writes still happen on their own PDS but
notifyWriteto the owner's PDS will be rejected (member is takendown), so the data effectively becomes orphaned. Apps will not pull from them. - Record-level takedown of permissioned records: requires admin override. Since the space is access-controlled, the PDS admin needs to either be in the member list (default-deny-friendly admin account) or use a new admin-bypass auth path.
atproto-pdsshould exposecom.atproto.admin.takedownSpaceRecord(provisional) as an admin-authenticated endpoint that suppresses a record fromspace_recordreads regardless of credentials, and audits the action. - Spam detection in permissioned spaces requires PDS-local heuristics (write rate, fan-out patterns) since AppViews can't see content. Recommended: opt-in admin telemetry on space write rates, no content inspection by default.
- Reports:
com.atproto.moderation.createReportcontinues to work, but the moderation service may be unable to view the reported permissioned content without space credentials. The PDS should attach a SpaceCredential to the forwarded report iff the reporter is willing to grant it (this is tricky and probably needs upstream design — flag for spec feedback).
Required for verification, password reset, PLC operation signature requests, account migration confirmations, account takedown notices. TS PDS uses nodemailer; cocoon uses a Go SMTP client; tranquil-pds supports SMTP plus discord/telegram/signal.
atproto-pds should use lettre and ship templates for: verify_email, confirm_email_update, reset_password, confirm_account_delete, plc_op_signature, account_migration_initiated, takedown_notice. A pluggable Mailer trait allows non-SMTP backends.
Optional but recommended: SES/SendGrid webhook ingestion to mark addresses undeliverable and prevent further send attempts.
notifyWrite and notifyMembership (Spaces Design Spec) are inter-service notifications, not user-facing emails. They are HTTPS POSTs with service-auth JWTs. atproto-pds should treat them as a separate subsystem from email (the Notifier trait), with their own retry/backoff and dead-letter logging.
A user-facing "you were added to / removed from a space" notification (email or app push) is not in the spec but is good UX. atproto-pds should optionally trigger an email on notifyMembership ingest, behind a config flag.
Precedence (matching tranquil): env vars > --config <path> > /etc/atproto-pds/config.toml > built-in defaults. Required envvars:
PDS_HOSTNAME,PDS_SERVICE_DID(commonlydid:web:<hostname>).PDS_DATA_DIRECTORYand storage URLs (PDS_DB_URL,PDS_BLOCK_STORE_URL,PDS_BLOB_STORE_URL).PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX(or P-256 variant),PDS_REPO_SIGNING_KEY_K256_PRIVATE_KEY_HEX(or per-account).PDS_JWT_SECRET,PDS_OAUTH_KEY_JWK.PDS_DID_PLC_URL,PDS_BSKY_APP_VIEW_URL,PDS_BSKY_APP_VIEW_DID,PDS_REPORT_SERVICE_URL,PDS_REPORT_SERVICE_DID.PDS_CRAWLERS— comma-separated relay URLs torequestCrawlafter each commit.PDS_INVITE_REQUIRED,PDS_INVITE_INTERVAL.PDS_EMAIL_SMTP_URL,PDS_EMAIL_FROM_ADDRESS.PDS_ADMIN_PASSWORD.PDS_SERVICE_HANDLE_DOMAINS(e.g.,.pds.example.com).PDS_SPACE_CREDENTIAL_TTL_SECONDS(default 10800 = 3h, per spec's 2–4h window).PDS_SPACE_OPLOG_RETENTION_DAYS(default unlimited; admin compaction available).PDS_SPACE_NOTIFY_RETRY_MAX_ATTEMPTS(default 5),PDS_SPACE_NOTIFY_RETRY_INITIAL_BACKOFF_MS(default 1000).- TLS-related are optional (proxy expected).
PDS expects to be fronted by a reverse proxy (Caddy, nginx, Traefik) handling TLS, large request bodies (>1GB for importRepo), and WebSocket upgrade (subscribeRepos). Required proxied paths if running multiple services on one domain: /xrpc/*, /.well-known/atproto-did, /.well-known/oauth-protected-resource, /.well-known/oauth-authorization-server, /.well-known/did.json, /oauth/*.
- SQLite/Fjall: snapshot to S3 hourly; tranquil-pds does this in-process.
- Postgres: external
pg_dumpor provider-managed backups. - Blobs: S3 versioning + replication.
- The PDS should expose an admin
/admin/backupendpoint to trigger snapshots. - Per-actor SQLite is critical for permissioned data backups: a single account's full state (public + all spaces) lives in one DB file, simplifying export and migration scenarios.
/xrpc/_healthreturns{version, status: "ok"}.- Prometheus metrics on a separate port (e.g., 2471), at
/metrics. - Structured tracing via
tracing+tracing-subscriberwith OpenTelemetry export. - Required metrics: request rate by NSID, p50/p99 latency, write transaction time, MST node cache hit rate, firehose subscriber count, lag.
- Permissioned-realm metrics: spaces owned, spaces joined, oplog write rate per space,
notifyWriteretries / DLQ depth, SpaceCredential issuance rate, oplog gap fallback rate.
The TS reference PDS hosts thousands of accounts on one process; per-account SQLite scales acceptably until OS file handle limits. atproto-pds with Fjall could go further by sharing one keyspace partitioned by DID. For very large deployments, sharded multi-process via consistent hashing on DID is the path; this is what Bluesky's mushroom PDSes do.
| Algorithm | Use |
|---|---|
| K-256 (secp256k1, ES256K) | atproto signing keys (default), PLC rotation keys, MemberGrant + SpaceCredential signing |
| P-256 (secp256r1, ES256) | atproto signing keys (alt), PLC rotation keys, OAuth client keys, DPoP keys (mandatory) |
| Ed25519 | NOT supported in atproto |
| SHA-256 | Commit hashing, blob CID, PKCE, DPoP thumbprints, SetHash element hashing |
| Argon2id | Password storage (recommended) |
| HKDF-SHA-256 | Permissioned-repo commit HMAC key derivation (per-commit random IKM, SpaceContext as info) |
| HMAC-SHA-256 | Permissioned-repo commit authentication tag |
| ECMH or ltHash (future) | SetHash production primitive (replaces XOR-SHA256 placeholder) |
Low-S signature normalization required. Use k256 and p256 Rust crates (already used in atproto-identity). atproto-record handles repo commit signing.
The Spaces Design Spec's commit construction:
ikm := random(32 bytes) // per-commit, fresh
hmac_key := HKDF-Extract-then-Expand(ikm, info=SpaceContext-cbor)
tag := HMAC-SHA-256(hmac_key, setHash || rev)
sig := Sign-ECDSA(user_signing_key, tag || rev)
commit := { setHash, rev, ikm, tag, sig }
The IKM is included in the commit so a verifier with the relevant SpaceContext can recompute and check; outside that context, the IKM is meaningless. This is the deniability mechanism.
Lexicons are resolved by:
- Built-in: shipped with the PDS for
com.atproto.*,app.bsky.*, etc., including the newcom.atproto.space.*lexicons once published. - Network: NSID
com.example.foo→ DNS TXT_lexicon.foo.example.com→ resolves to a DID → PDS of that DID →getRecordforcom.atproto.lexicon.schema/foo. - Cache: 24h stale, 90d expiry recommended.
The existing atproto-lexicon crate handles this.
On record write: parse JSON → infer NSID from $type → resolve schema → validate → encode as DAG-CBOR → hash for CID → MST insert (public realm) or space_record insert (permissioned realm).
community.lexicon.* namespace is the de-facto location for community-defined schemas including:
community.lexicon.location.*(Smoke Signal addresses, geo).community.lexicon.calendar.*(events).community.lexicon.preference.ai(Nick's WIP, AI-related preferences).
Per the Spaces Design Spec, space type is itself an NSID, and applications declare what NSIDs are used within a given space type. There is no requirement that space-typed records live under community.lexicon — app.bsky.group is the example space type in the spec, suggesting space types live in app namespaces, with records inside a space drawing from broader vocabularies.
The PDS validates records the same way regardless of realm. The permissioned/public distinction is at the transport layer, not the schema layer.
This section consolidates the Spaces Design Spec into a atproto-pds-implementer's reference. Where the spec is silent, we note the open question and propose a default.
A space is an authorization and sync boundary. It is identified by:
ats://<ownerDid>/<spaceType>/<spaceKey>
(ats:// URI scheme is provisional in the spec.)
Components:
- Owner DID: root of trust for the space. Holds the canonical member list and signs SpaceCredentials.
- Space type: NSID describing modality (e.g.,
app.bsky.group). - Space key: arbitrary string differentiating multiple spaces of the same type under the same owner (e.g.,
default, or a TID-like identifier).
A member is a DID listed in the owner's space_member table. Membership grants read access to all member's permissioned repos for that space, conditional on presenting a valid SpaceCredential to each member's PDS.
Each member who participates in a space hosts a per-space permissioned repo on their own PDS, containing records they have written scoped to that space. The repo is committed via a SetHash + signed HMAC tag, not an MST.
The Spaces Design Spec explicitly excludes:
- Application coordination (allow/deny lists for which apps can be syncing apps, app routing).
- Delegated / sub-accounts (apps signing on behalf of users without OAuth).
atproto-pds should therefore avoid premature design for these. They are likely future protocol layers built on top of spaces.
See §3.6 for full table schemas. Summary: per-actor SQLite gets eight new tables. Key invariants:
space_member_staterow exists ⟺ user owns the space.space_reporow tracks user's record-set commitment per space.- Atomic batches share a
revand use monotonicidx. - Removed members retain their
space_recordrows (data preserved across re-joins).
The Spaces Design Spec defines a @atproto/space TypeScript package containing:
SetHash(set-hash.ts) — XOR-SHA256 placeholder for ECMH/ltHash. Order-independent set digest.Commit(commit.ts) —createCommit(setHash, context, keypair)/verifyCommit(context, commit). HKDF-derived HMAC + ECDSA signature, withSpaceContextas HKDF info for domain separation.SpaceRepoclass — manages a single user's permissioned repo within a space.formatCommit/applyCommit/getRecord/listRecords/listCollections.SpaceMembersclass (owner-only) — manages member set. Same commit structure, different scope ('members'vs'records').SpaceCredentialandMemberGrant— JWT issuance and verification.- Storage interfaces
SpaceRepoStorageandSpaceMembersStorage, with in-memory and SQLite implementations.
Rust equivalent: a new crate atproto-space (within atproto-crates) mirroring this structure:
crates/atproto-space/
src/
lib.rs
types.rs
error.rs
set_hash.rs // SetHash trait + XorSha256SetHash impl
commit.rs // create_commit / verify_commit + SpaceContext
space_repo.rs // SpaceRepo<S: SpaceRepoStorage>
space_members.rs // SpaceMembers<S: SpaceMembersStorage>
credential.rs // create/verify MemberGrant + SpaceCredential
storage/
mod.rs // SpaceRepoStorage + SpaceMembersStorage traits
memory.rs // in-memory impls for testing
The atproto-pds crate then provides the SQLite-backed SpaceRepoStorage and SpaceMembersStorage impls (analogous to the spec's actor-store/space/sql-repo-storage.ts and sql-members-storage.ts), wired into the per-actor store.
pub trait SetHash: Sized + Clone {
fn empty() -> Self;
fn add(&mut self, element: &[u8]);
fn remove(&mut self, element: &[u8]);
fn digest(&self) -> Vec<u8>;
}
pub struct XorSha256SetHash([u8; 32]);
// add: self.0 ^= sha256(element); remove: same (XOR is self-inverse)When ECMH or ltHash is settled upstream, swap the impl. Element formats per spec:
- Records:
format!("{}/{}:{}", collection, rkey, cid).as_bytes() - Members:
did.as_bytes()
pub struct SpaceContext {
pub space_did: String,
pub space_type: String,
pub space_key: String,
pub user_did: String,
pub scope: CommitScope, // Records | Members
pub rev: String,
}
pub struct Commit {
pub set_hash: Vec<u8>,
pub rev: String,
pub ikm: [u8; 32], // per-commit random
pub tag: [u8; 32], // HMAC-SHA-256
pub sig: Vec<u8>, // ECDSA over canonical bytes
}
pub fn create_commit(
set_hash: &[u8],
context: &SpaceContext,
keypair: &SigningKey,
) -> Commit;
pub fn verify_commit(context: &SpaceContext, commit: &Commit, pubkey: &VerifyingKey) -> bool;Domain separation via scope is critical: a record commit verified with scope: Members must fail. Test this explicitly (the spec calls it out as a Layer 1 unit test).
JWT shapes per spec:
MemberGrant (member → app, member's PDS issues):
{
"header": { "alg": "ES256K", "typ": "space_member_grant" },
"payload": {
"iss": "<member-did>",
"aud": "<owner-did>",
"space": "ats://<owner-did>/<space-type>/<space-key>",
"clientId": "<oauth-client-id>",
"lxm": "com.atproto.space.getSpaceCredential",
"iat": 1740000000,
"exp": 1740000300
}
}~5 minute TTL. Signed with member's atproto signing key. Verified by resolving member's DID doc.
SpaceCredential (owner → app, owner's PDS issues):
{
"header": { "alg": "ES256K", "typ": "space_credential" },
"payload": {
"iss": "<owner-did>",
"space": "ats://<owner-did>/<space-type>/<space-key>",
"clientId": "<oauth-client-id>",
"iat": 1740000000,
"exp": 1740010800
}
}2–4h TTL (atproto-pds defaults to 3h, configurable). Signed with owner's atproto signing key.
Full inventory consolidated from §8.6 and the Spaces Design Spec:
Record CRUD (member's PDS, OAuth):
createRecord(procedure) — body{space, collection, rkey?, record, swapRev?}putRecord(procedure) — body{space, collection, rkey, record, swapRev?, swapRecord?}deleteRecord(procedure) — body{space, collection, rkey, swapRev?, swapRecord?}applyWrites(procedure) — body{space, writes: [...]}, atomic batchgetRecord(query, dual auth — OAuth or SpaceCredential) — params{space, collection, rkey, cid?}listRecords(query, dual auth) — params{space, collection, limit, cursor}
Space management (owner's PDS, OAuth):
createSpace(procedure) — body{spaceType, spaceKey}→ returns{uri}getSpace(query) — params{uri}listSpaces(query) — params{filter: 'owned' | 'member' | 'all', limit, cursor}addMember(procedure) — body{space, did}removeMember(procedure) — body{space, did}getMembers(query) — params{space, limit, cursor}
Credential flow:
getMemberGrant(procedure, member's PDS, OAuth+clientId) — body{space, clientId}→ returns{grant: <jwt>}getSpaceCredential(procedure, owner's PDS, MemberGrant) — body{grant}→ returns{credential: <jwt>}and registers app inspace_credential_recipient
Sync (SpaceCredential):
getRepoState(query, member's PDS) — params{space}→{setHash, rev}getRepoOplog(query, member's PDS) — params{space, since?, limit}→{ops, setHash, rev}getMemberState(query, owner's PDS) — params{space}→{setHash, rev}getMemberOplog(query, owner's PDS) — params{space, since?, limit}→{ops, setHash, rev}
Notifications (service auth):
notifyMembership(procedure, on the member's PDS, called by the owner) — body{space, isMember}notifyWrite(procedure, on the owner's PDS and on each syncing app) — body{space, member, rev}
notifyWrite is the same NSID on both relay hops. The owner's PDS receives, looks up space_credential_recipient, and invokes the same NSID on each registered service endpoint.
Flow A — Member writes a record:
- App → member's PDS:
com.atproto.space.createRecord {space, collection, record}(OAuth). - PDS opens actor-store transaction.
- Loads
SpaceRepoviaSqlRepoStorage. repo.format_commit([{action: Create, collection, rkey, record}])— computes new SetHash, signs commit.transactor.apply_repo_commit(space, commit_data)— writesspace_recordrow, updatesspace_repo.set_hashandspace_repo.rev, appendsspace_record_oplogentry.- Fire-and-forget
notifyWriteto space owner's PDS. - Returns
{uri, cid, commit: {rev}}.
Flow B — App obtains a SpaceCredential:
- App has OAuth session with a member user (bound to clientId).
- App → member's PDS:
getMemberGrant {space, clientId}(OAuth). - Member's PDS verifies user has membership knowledge for
space(or trusts the user's claim — spec is permissive here), creates and signs grant JWT. - App → owner's PDS:
getSpaceCredential {grant}. - Owner's PDS verifies grant signature (resolves member's DID doc), confirms member is in
space_member, checkslxmmatches. - Owner's PDS creates and signs SpaceCredential JWT.
- Owner's PDS records app's service DID + endpoint in
space_credential_recipient. - Returns
{credential}.
Flow C — App syncs a member's permissioned repo:
- App holds SpaceCredential.
- App → member's PDS:
getRepoOplog {space, since: <last_seen_rev>}with SpaceCredential. - PDS verifies SpaceCredential by resolving owner's DID doc, checking signature and expiration, confirming requested space matches credential's
spaceclaim. - PDS confirms
is_member=trueinspacetable for this user. - PDS returns
{ops: [...], setHash, rev}. - App replays ops, fetches new/updated record content via
getRecord {space, collection, rkey}. - App computes its local SetHash from cumulative state, compares to returned
setHash. - On mismatch: full resync via
listRecords, recompute from scratch.
Flow D — Space owner adds a member:
- App → owner's PDS:
addMember {space, did}(OAuth). - PDS loads
SpaceMembersviaSqlMembersStorage. members.format_commit([{action: Add, did}]).transactor.apply_member_commit(space, commit_data)— writesspace_memberrow, updatesspace_member_state.set_hash/.rev, appendsspace_member_oplog.- Fire-and-forget
notifyMembership {space, isMember: true}to new member's PDS. - Returns success.
Flow E — Member's PDS receives notifyMembership:
- Owner's PDS → member's PDS:
notifyMembership {space, isMember: true}(service auth). - PDS verifies service-auth JWT against owner's DID.
- PDS upserts
spacerow:is_member=true(creates row if needed). Per spec: this is necessary so the PDS knows to accept space credentials for that space and serve permissioned repo data to authorized requesters. - Optionally email/push the user.
When isMember: false, the PDS sets is_member=false but does NOT delete the space row or any records (per spec).
Per the Spaces Design Spec, the PDS auth verifier gets three paths:
- User auth — existing OAuth flow. Used for record CRUD and space management on the user's own PDS.
- Space credential auth — new. Verifies SpaceCredential JWT for sync endpoints. Resolves space owner's DID doc, checks signing key, validates expiration, confirms requested space matches credential's
spaceclaim. - Service auth — existing pattern. Used for inter-PDS notifications (
notifyWrite,notifyMembership).
The middleware must distinguish these cleanly. Use the JWT typ header (at+jwt, space_credential, space_member_grant, no-typ for service auth) as the primary discriminator.
The spec flags these TODOs that affect atproto-pds:
| Open question | atproto-pds interim default |
|---|---|
URI scheme ats:// |
Use as specified; abstract behind SpaceUri type for future migration. |
| SetHash algorithm (XOR placeholder → ECMH/ltHash) | SetHash trait + XorSha256SetHash default; pluggable. |
| Credential expiration window (2–4h) | Default 3h, configurable. |
| Oplog retention | Retain forever by default; admin compaction available; emit OplogGap error if since precedes retained range. |
notifyWrite fan-out failure |
Bounded retry with exponential backoff (5 attempts, 1s/2s/4s/8s/16s); dead-letter log. |
| Member grant signing key | User's atproto signing key (per spec); document in code. |
| Service endpoint for notifications | Try app DID-doc service entry first; fall back to value provided in getSpaceCredential request. |
Each of these should be a tracked issue in the atproto-crates repo and revisited as the spec firms up.
Layer 1: atproto-space unit tests
SpaceRepo: CRUD, batch writes, format_commit/apply_commit, SetHash correctness, error cases.SpaceMembers: add/remove, SetHash over DIDs, commit signing/verification, duplicate add, remove non-member.SetHash: order-independence, add/remove inverse, consistency.- Domain separation: a commit signed with
scope: Recordsmust fail verification withscope: Members. - Credential/Grant: create, verify, reject expired, reject wrong space, reject tampered, verify
lxmbinding.
Layer 2: atproto-pds integration tests
- Spin up a real PDS instance. Test XRPC endpoints directly.
- Record CRUD via
com.atproto.space.*. - Verify rev advances on each write.
- Verify SetHash correctness after operation sequences.
- Space management auth rejection (non-owner → addMember/removeMember).
- Credential flow happy path and rejections.
Layer 3: Multi-PDS sync tests
- Two PDSes (PDS-A hosts owner + member-1, PDS-B hosts member-2). A test client acting as the syncing app.
- Happy-path sync: owner creates space, adds members; members write; app obtains credential; app syncs.
- Incremental sync with
since. - Sync recovery: simulated oplog gap → setHash mismatch → full resync via
listRecords. - Member lifecycle: removal mid-sync.
- Notification flow:
notifyWritereaches owner → relayed to mock app endpoint.
What we are NOT testing (per spec):
- ECMH (XOR placeholder only until upstream settles).
- Application-level write semantics (out of spec scope).
- Delegated accounts / app coordination.
- Performance at scale.
| Area | TS @atproto/pds |
indigo PDS (Go, deprecated) | rsky-pds (Rust) | tranquil-pds (Rust) | cocoon (Go) |
|---|---|---|---|---|---|
Sync 1.1 (prevData, #sync) |
full | partial | partial | full | full |
did:web for accounts |
service-only | none | none | full (BYO + hosted) | full |
| OAuth provider | full | none | partial | full + 2FA/passkeys | full |
| Permission sets resolution | full | n/a | partial | full | partial |
importRepo |
full but slow | broken/stale | works | works | "use with caution" |
| Per-account SQLite | yes | n/a | no (Postgres) | no (Postgres) | yes (default), Postgres opt-in |
| Blob store: S3 | no (filesystem) | filesystem | yes | optional | optional |
| SMTP / multi-channel notifs | SMTP only | none | mailgun | SMTP + discord/telegram/signal | SMTP |
| Account delegation | no | no | no | yes | no |
| WebAuthn / TOTP | no | no | no | yes | no |
| Built-in admin web UI | minimal | no | no | full Svelte UI | minimal |
listReposByCollection (Sync 1.1) |
partial | no | no | partial | partial |
| Inductive verification on inbound CAR | partial | no | no | yes | yes |
lxm-required service auth |
yes | no | yes | yes | yes |
Permissioned data (com.atproto.space.*) |
in-progress reference | none | none | none | none |
@atproto/space package |
in-progress | none | none | none | none |
| Sidecar / off-protocol attachments | none | none | none | none | none |
atproto-pds opportunity: @atproto/pds is the only implementation currently building toward the Spaces Design Spec, and it's reference-quality, not production-quality (per the spec's own goals: "Not production-ready — focused on correctness and protocol exploration"). atproto-pds can be the second implementation overall, the first in Rust, and architected for production performance from the start. Conformance must track the TS reference closely during the design's settling period.
Notable interop issues observed in the wild:
- Account migration "blob loss" — some PDSes don't refuse
activateAccountiflistMissingBlobsis non-empty; cocoon README warns "use with extreme caution." - DPoP nonce edge cases — cocoon shipped
hailey/fix-dpop-nonce-errafter upstream issues; rsky-pds had a TLS provider init panic. - Identity caching after migration — Blacksky AppView fork notes that staleTTL of 1h causes JWT verification failures during the migration window; the resolver needs a cache-bypass on signature failure.
Atproto-Proxyheader: TS PDS, cocoon honor; some early Go PDSes don't.- Lexicon validation strictness varies — many PDSes silently drop unknown union members on read.
atproto-pds should adopt the strictest reasonable defaults and expose lenient mode as opt-in.
| Crate | Purpose | PDS use |
|---|---|---|
atproto-dasl |
DRISL CBOR, CID, CARv1, block storage backends, RASL retrieval | Public-repo block layer, CAR import/export |
atproto-identity |
DID resolution (plc/web/key), handle resolution, P-256/P-384/K-256 keys | Identity resolution, key handling |
atproto-attestation |
CID-first attestation utilities | Sidecar/permissioned record attestations |
atproto-record |
TID, AT-URI, datetime, CID for records | Record creation hot path |
atproto-lexicon |
NSID validation, schema resolution (DNS+HTTPS) | Write-time lexicon validation |
atproto-repo |
MST encoding/decoding, commit structures, tree diffing, configurable verification | Public repo subsystem |
atproto-oauth |
OAuth 2.0 + DPoP/PKCE/JWT | OAuth provider primitives |
atproto-oauth-aip |
AIP-style authorization-code + PAR + token exchange | OAuth flow orchestration |
atproto-oauth-axum |
Axum web handlers for OAuth endpoints, JWKS, client metadata | OAuth HTTP layer |
atproto-client |
DPoP/Bearer/session HTTP client, XRPC | Outbound calls (PLC, mod service, AppView, notifyWrite/notifyMembership push) |
atproto-xrpcs |
XRPC service framework: JWT extractors, DID-based authn, axum middleware | XRPC server foundation |
atproto-jetstream |
Jetstream client | Useful for testing, not core PDS |
atproto-tap |
TAP consumer (verified events with backfill) | Reference for outbox mechanics |
atproto-extras |
Facets, rich text | Validation helpers for records |
atproto-plc |
DID:plc operation construction, signing | PLC operations |
Added to atproto-crates to mirror the @atproto/space TS package. See §15.4 for module layout. Public API:
// Set commitments
pub trait SetHash { ... }
pub struct XorSha256SetHash; // default impl, swappable
// Commit construction
pub struct SpaceContext { ... }
pub enum CommitScope { Records, Members }
pub struct Commit { set_hash, rev, ikm, tag, sig }
pub fn create_commit(...) -> Commit;
pub fn verify_commit(...) -> bool;
// Per-space repo manager
pub struct SpaceRepo<S: SpaceRepoStorage> { ... }
// Per-space member manager (owner only)
pub struct SpaceMembers<S: SpaceMembersStorage> { ... }
// JWT credentials
pub struct MemberGrant { ... }
pub struct SpaceCredential { ... }
pub fn create_member_grant(...) -> MemberGrant;
pub fn verify_member_grant(...) -> Result<MemberGrant, ...>;
pub fn create_space_credential(...) -> SpaceCredential;
pub fn verify_space_credential(...) -> Result<SpaceCredential, ...>;
// Storage traits
#[async_trait]
pub trait SpaceRepoStorage { ... }
#[async_trait]
pub trait SpaceMembersStorage { ... }
pub mod memory { /* in-memory impls for testing */ }The atproto-pds crate then provides SQLite-backed (or pluggable-backend) implementations of SpaceRepoStorage and SpaceMembersStorage.
atproto-repo: must support Sync 1.1 inductive proofs end-to-end (already largely there), expose a streaming CAR writer, support inductive verification on inbound (importRepo), and add a tree-diff API that emits Sync 1.1 op lists withprevCIDs.atproto-dasl: BlockStore trait should grow per-realm tagging if we want a unified block store across public + permissioned (alternatively, treat permissioned as fully separate viaatproto-space's storage traits).atproto-xrpcs: needs DPoP middleware extractor (currently has Bearer/service auth only), per-endpoint scope enforcement table, rate-limit middleware, plus extractors for SpaceCredential and MemberGrant JWTs distinguishing bytypheader.atproto-oauth/atproto-oauth-axum: needs server-side flows (the AIP variant is largely complete), the consent UI templating, permission-set resolution and caching, JWKS rotation, and space-typed scopes in the consent UI.atproto-identity: needs aKeyStoretrait abstraction so HSM/KMS backends are pluggable; needs a per-account signing key generator.atproto-lexicon: needs an "authority-tagged" resolver mode for permissioned-only schemas.atproto-plc: confirm full coverage of the operation set; may needtombstoneop support.
- Account database: accounts table, app passwords, OAuth sessions, invite codes, email tokens, PLC operation signature tokens. Pluggable backend (SQLite default, Postgres optional).
- Per-actor store: per-account SQLite file (or namespace) holding both public-repo state and the eight Spaces tables. The actor-store transactor wraps both.
- Sequencer / outbox for the public firehose.
- Firehose server (
com.atproto.sync.subscribeRepos) WebSocket framing. - Mailer: SMTP + pluggable trait.
- Notifier (separate from Mailer): outbound
notifyWrite/notifyMembershipHTTP client with bounded retry + DLQ. - Admin endpoints + UI: the
com.atproto.admin.*namespace and a minimal HTML dashboard. Includes admin tooling for permissioned-record takedown audit log. - Authorization UI: server-rendered consent screen using Askama templates, with space-typed scope rendering.
- Rate limiter: in-memory token bucket + optional Redis/Valkey, with per-realm bucket families.
- Service auth issuer: short-lived JWT minting for
getServiceAuth. - MemberGrant issuer (uses account's signing key).
- SpaceCredential issuer (owner's PDS only).
- Auth verifier dispatching across OAuth / App Password / service auth / SpaceCredential / MemberGrant based on JWT
typ. - CLI / installer:
atproto-pdsbinary plus admin tooling for invite issuance, account reset, takedown, space inspection (list spaces, dump oplog, show member list).
- Async runtime:
tokio(multi-threaded). Usetokio-uringfor the blob store path on Linux for io_uring read perf. - HTTP framework:
axum(matches existingatproto-xrpcs). Addtower-httpfor rate limit, CORS, compression. - WebSocket:
tokio-tungsteniteintegrated with axum. - Storage:
sqlxfor SQLite/Postgres (per-actor SQLite is the recommended default to match the spec).fjallavailable for unified-keyspace deployments.s3crate for blob storage. - CBOR:
serde_ipld_dagcbor(already inatproto-dasl). - Crypto:
k256,p256,argon2,hmac,sha2,hkdf,jose-jwt(orjosekit). - Email:
lettre. - Templates:
askama(compile-time, fast). - Tracing:
tracing+tracing-subscriber+ optional OpenTelemetry exporter.
Public read hot path (getRecord, listRecords):
- Resolve DID → repo handle (in-memory LRU, ~1 µs).
- Locate record CID via MST traversal (one MST root cache hit + log-N node fetches; with caching, single-digit-µs to sub-ms).
- Fetch DAG-CBOR block from BlockStore.
- Decode and serialize JSON.
Target: p99 < 5 ms in-process for cached records.
Permissioned read hot path (com.atproto.space.getRecord):
- SpaceCredential verification: parse JWT, resolve owner DID doc (cached), verify ECDSA, check
spaceclaim, check expiration. Cached fast path: ~50 µs. Cold DID-doc fetch: 10–100 ms (caching is essential). - Lookup
space_recordby(space, collection, rkey)PK in the per-actor SQLite. Single-digit ms. - Decode and serialize.
Target: p99 < 10 ms for cached credentials.
Public write hot path (createRecord):
- Authn middleware (DPoP verify ~50 µs ES256, JWT verify ~10 µs).
- Lexicon resolve (cache hit ~µs; cache miss = network, slow path).
- DAG-CBOR encode + CID compute (~10 µs for typical post).
- MST mutate (in-memory copy-on-write, ~10–100 µs).
- Sign commit (K-256 ECDSA ~50 µs).
- Persist blocks + commit + outbox row in one Fjall batch / SQLite transaction (~100 µs–1 ms).
- Notify firehose channel (lock-free broadcast).
Target: p99 < 20 ms.
Permissioned write hot path (com.atproto.space.createRecord):
- Authn (OAuth + space scope check).
- Lexicon validate.
- CBOR encode + CID compute.
- SetHash add: SHA-256 + XOR (or future ECMH op). XOR is ~ns; ECMH is slower (~10–100 µs depending on curve).
- HKDF-SHA-256 key derivation + HMAC-SHA-256 tag (~10 µs).
- ECDSA sign (~50 µs).
- SQLite transaction: insert
space_record, updatespace_repo, appendspace_record_oplog(~100 µs–1 ms). - Spawn
notifyWriteoutbound call (fire-and-forget, off-path).
Target: p99 < 15 ms (note: faster than public write because no MST traversal).
Public sync hot path (subscribeRepos):
- Live tail = single broadcast channel recv + frame encode → ws write.
- Backfill = streaming CAR read from block store.
Permissioned sync hot path (getRepoOplog):
- SpaceCredential verify (cached fast path).
- SQLite range query on
space_record_oplogordered by(rev, idx)since cursor. - Stream rows + return
{setHash, rev}.
- Identity cache: did → did doc, did → handle, handle → did. TTL: 24h with stale-while-revalidate; bypass on signature-verification failure (per Blacksky lesson). Critical for SpaceCredential verification.
- Lexicon cache: 24h stale, 90d expiration; permission sets same.
- Repo head cache: did → (commit CID, rev, MST root). Updated on every commit. Bounded LRU per process.
- MST node cache: CID → MST node, large LRU shared across accounts.
- SpaceCredential cache: per-(credential-jti-or-hash) → verified-decoded form. TTL = credential remaining lifetime. Avoids re-resolving the owner's DID doc on every request from the same app.
- Member-list cache: (space) → member set. Invalidated on
addMember/removeMember/inboundnotifyMembership. - Block presence Bloom filter to short-circuit "do we have this CID" checks during sync.
- Single-writer per repo: serialize writes per-DID via a per-DID mutex (sharded
DashMap<Did, Mutex<()>>). - Single-writer per space (per user): serialize writes per-(DID, space-URI) similarly.
- Reads are unconstrained.
- Firehose sequencer is a single tokio task fed by a SPSC channel from each writer.
- Outbound
notifyWritecalls run on a separate tokio task pool with bounded concurrency to avoid DoS-amplifying when one space has many members.
| Backend | Pros | Cons | When to use |
|---|---|---|---|
| Per-account SQLite | strong isolation, easy backup/migration, atomic per-account, direct match to Spaces Design Spec, cheap takedown (rm file) | many file descriptors, hard to do cross-account scans, slower for very-many accounts | small-to-medium PDS (≤10k accounts), recommended default |
| Single Fjall keyspace | very low write latency, compactable, single-binary deploy | requires custom Spaces table-equivalent layout, diverges from spec | single-host PDS, performance-priority |
| Postgres | replication, ops familiarity, query power | added latency vs embedded, ops complexity | multi-tenant SaaS PDS, many accounts |
| Hybrid (Fjall public blocks + per-account SQLite for Spaces) | fast public path + spec-fidelity for Spaces | two systems to back up | advanced |
Recommended default for atproto-pds: per-account SQLite for both public actor store and Spaces tables, matching the spec exactly. Optimize within SQLite (WAL mode, mmap, statement caching) before reaching for alternative backends.
- SetHash with XOR-SHA256 placeholder: O(1) add/remove, single-digit µs. ECMH/ltHash will be slower (curve ops 10–100 µs) but still O(1).
- Oplog sync is O(ops since cursor), comparable to MST diff. The PK
(space, rev, idx)makes range scans efficient. - Per-reader credential verification: HMAC + ECDSA both µs-scale; the dominant cost is the owner's DID doc fetch, mitigated by aggressive caching.
- Cross-space aggregation ("show me all my permissioned posts across spaces") needs an index on
(actor, space, repo_rev DESC)to be fast. Plan this from day one.
- Phase 0 — Scaffold: New
crates/atproto-pds/andcrates/atproto-space/inatproto-crates. Wire to existing crates. Extendatproto-repofor Sync 1.1. - Phase 1 —
atproto-spacecrate: SetHash trait + XorSha256 impl, SpaceContext, Commit (HKDF+HMAC+ECDSA), SpaceRepo, SpaceMembers, MemberGrant, SpaceCredential, in-memory storage. Layer-1 unit tests including domain-separation tests. - Phase 2 — Public realm read-only PDS:
getRecord,listRecords,describeRepo,getRepo,getBlob,listBlobs,subscribeRepos(read-only). Validate against Bluesky firehose by replaying. - Phase 3 — Account management & public writes:
createAccount(PLC-managed), session/app password,createRecord/putRecord/deleteRecord/applyWrites,uploadBlob. Emit Sync 1.1 firehose. - Phase 4 — OAuth provider: PAR/PKCE/DPoP, consent UI, permission sets.
atproto-oauth-aipintegration. - Phase 5 — Account migration:
getServiceAuth,importRepo,activateAccount,deactivateAccount,requestPlcOperationSignature,signPlcOperation,submitPlcOperation. - Phase 6 — Moderation, admin, deployment: Admin endpoints, takedown, reports proxy, Docker image, install scripts.
- Phase 7 — Permissioned realm full implementation: SQLite-backed
SpaceRepoStorage/SpaceMembersStorage, allcom.atproto.space.*endpoints,notifyWrite/notifyMembershipoutbound + inbound, dual-auth middleware, space-scoped OAuth scopes, admin tooling for spaces. Layer-2 and Layer-3 tests including multi-PDS sync. - Phase 8 — Production hardening: Switch SetHash to ECMH/ltHash when upstream settles; add OplogGap recovery telemetry; performance tuning; cross-implementation interop tests against the TS reference.
Conformance gating: at each phase, run against Bluesky's interop-test-files, bluesky-social/atproto-interop-tests, goat --verify flags, and atproto-tap (Sync 1.1 reference consumer). For permissioned data specifically, run interop tests against the TS @atproto/pds Spaces implementation as soon as it lands.
The strategic value of atproto-pds is sharper now that we have the Spaces Design Spec in hand. Three things matter most:
- Per-actor SQLite as the default storage layout, matching the spec exactly. This is the single biggest decision — every other implementation decision flows from it. Spaces tables piggy-back on the per-actor DB, which is only architecturally clean if the per-actor DB exists in the first place.
atproto-spaceas a sibling crate, mirroring@atproto/space. This keeps the protocol primitives (SetHash, Commit, SpaceRepo, SpaceMembers, MemberGrant, SpaceCredential) reusable outside the PDS — apps and AppViews will need them too. It also gives us a cleaner test boundary.- Sync 1.1 + Spaces from day one, not as retrofits. The TS reference is building both in parallel; Rust has the chance to do likewise without the legacy weight.
The Spaces Design Spec is explicitly correctness-first, not production-first ("Not production-ready — focused on correctness and protocol exploration"). atproto-pds can be the production-ready Spaces implementation. To do that, track the upstream spec in lockstep, file issues against ambiguities (especially the seven open questions in §15.11), and pick conservative defaults that won't paint us into a corner when the spec firms up. The existing atproto-crates workspace is exceptionally well-positioned for this — identity, lexicon, DASL, OAuth, and PLC primitives are already strong; the PDS plus atproto-space are the integration layer on top.