atprotocrates

PDS

Operating atproto-pds

A Personal Data Server in Rust: repository storage, federation, an OAuth 2.1 provider, and permissioned spaces. Two storage profiles, sixty-two environment variables, and three configurations that refuse to start on purpose.

§ 01

Status, stated plainly

From the crate’s own README

“EXPERIMENTAL — FOR THE LOVE OF GOD DON’T USE THIS YET.”

Both that warning and the paragraph under it are true, which is the thing to understand before you decide anything. The server is single-node deployable for federated public traffic. Every foundational subsystem has shipped end to end: storage dispatch, federation gaps, admin endpoints, the OAuth provider, the Sync 1.1 protocol, identity gaps, user-facing endpoints, account migration, and operational hardening. Both heavy storage-layer batches are closed. Every public read and write routes through the PublicRealmBackend trait, and the Postgres accounts cutover routes every accounts-DB call through a runtime-dispatch AccountPool.

What that adds up to: a server that works, under CI gates, and has not yet accumulated the operational history that would justify putting somebody else’s account on it.

CI gates enforced on every push to main and every pull request
GateCommand
Formattingcargo fmt --all -- --check
Lintscargo clippy --workspace --all-targets -- -D warnings
Testscargo test --workspace

§ 02

What it serves

XRPC surface with default features
NamespaceMethods
com.atproto.repo.* getRecord, listRecords, describeRepo, createRecord, putRecord, deleteRecord, applyWrites, importRepo (commit signatures verified against the account’s own key, not merely proved internally consistent), listMissingBlobs
com.atproto.sync.* getLatestCommit, getRepoStatus, getRepo (CAR streaming, ?since=<rev> diff slice), getBlocks, listRepos, subscribeRepos (WebSocket; frames are drained from the durable log in seq order, and the broadcast bus is only a wakeup)
com.atproto.server.* Account lifecycle (createAccount with optional PLC genesis, activate, deactivate, delete), sessions, app passwords, invite codes, service auth, the full email-confirmation and password-reset set, and the migration pair checkAccountStatus and reserveSigningKey
com.atproto.identity.* resolveHandle, updateHandle, requestPlcOperationSignature, signPlcOperation, submitPlcOperation, getRecommendedDidCredentials, refreshIdentity
com.atproto.simplespace.* Owner-side space management: createSpace, updateSpace, deleteSpace, getSpace, addMember, removeMember, listMembers
com.atproto.space.* The permissioned realm — twenty-one methods. Records: createRecord, putRecord, deleteRecord, applyWrites, getRecord, listRecords (keys only), getBlob, listBlobs. Spaces: getSpace, listSpaces. Sync: getRepo, getLatestCommit, getRepoState, listRepos, listRepoOps. Credentials: getDelegationTokengetSpaceCredential. Notification: registerNotify and unregisterNotify, plus the contentless notifyWrite and notifySpaceDeleted inbound hooks
com.atproto.admin.* Account info and search, subject status and takedown, invite-code administration, email/handle/password updates, revokeServiceAuth, forceRepoSync, takedownSpaceRecord, plus an HTML operator dashboard at GET /admin
OAuth 2.1 /oauth/par, /oauth/authorize (HTML consent and JSON POST), /oauth/token (rate-limited; PKCE, DPoP, refresh rotation), /oauth/revoke (RFC 7009), /oauth/jwks, and both well-known metadata documents
Identity discovery /.well-known/atproto-did for handles on this server’s own domain; /.well-known/did.json synthesised from PDS_SERVICE_DID rather than read off disk
Federation Inbound contentless notifyWrite receipts, outbound requestCrawl announcements, and default-pin Atproto-Proxy routing for app.bsky.* with per-request header override

Health checks live at GET /_alive, GET /_ready and GET /xrpc/_health. Prometheus metrics appear at GET /metrics when the metrics feature is compiled in.

The account portal

Not every surface is XRPC. Before the portal, a PDS could be operated but not used without a client application: no way to sign in to the server itself, no way to change an email address or password, no way to see which app passwords and OAuth grants were outstanding or to end them. Pointing a browser at the host produced an empty 404. The portal is what an account holder can do with only a browser, and it is server-rendered HTML with no JavaScript at all.

The five sections under /account, plus the migration wizard
PathWhat it holds
/accountSettings — handle, email and its verification, password, and the server’s policy documents.
/account/sessionsAccess — outstanding sessions and app passwords, each revocable, including revoke-everywhere.
/account/repositoryRepository — a browser over the account’s own collections and records. The only section that goes deep enough to need a breadcrumb trail.
/account/spacesSpaces — space settings and membership.
/account/delegationDelegation — the identities that may act as this account. Present only when PDS_DELEGATION_ENABLED is set; otherwise the section says which prerequisite is missing rather than offering a control that cannot work.
/account/migrateMigration — ten inbound screens behind a cookie-bound migration row, and five outbound steps behind a portal session. The only pages served to visitors who have no account here.

The session is server-side rather than a JWT, and deliberately: the portal is the page that revokes credentials, so its own credential has to be revocable in the same breath. A stateless cookie that outlived “sign out everywhere” would be the one thing that button could not reach.

§ 03

Storage

One store for repositories, and an optional second place for blob bytes. The per-actor store is SQLite and is not a build decision any more; where blobs live is a runtime one.

StoreShapeChosen by
Per-actor store One SQLite file per actor: repositories, records, blocks, blob inventory, the Spaces tables. Matches the upstream 0016 draft exactly. Always. There is no alternative to select.
Accounts database One shared SQLite at PDS_DATA_DIRECTORY/accounts.sqlite, holding cross-account state and the firehose log. Always.
Blob bytes In the per-actor store by default. With object-storage, the bytes go to S3, GCS, Azure or a local directory instead; the inventory and every reference stay in SQLite. PDS_BLOB_STORE_URL, on a build with the feature.

The fjall profile is gone

An earlier release carried a second per-actor store built on fjall, selected with PDS_STORAGE_PROFILE. It was never wired into a deployment and has been removed.

PDS_STORAGE_PROFILE=fjall now refuses to boot rather than being ignored: a server that had been running that profile holds its repositories in a keyspace this binary cannot read, and starting anyway would serve every account as empty. Unset the variable, or set it to sqlite.

§ 04

Building

Cargo features
FeatureDefaultEffect
sqliteyesPer-actor and accounts SQLite via sqlx. Required.
object-storageBlob bytes in S3, GCS, Azure or a local directory, via object_store. Selected at runtime with PDS_BLOB_STORE_URL.
httpyesThe axum router and the subscribeRepos WebSocket.
clapBuilds the pds and atproto-pds-admin binaries.
hickory-dnsyesHickory resolver, via atproto-identity/hickory-dns.
smtpSMTP through lettre. Without it, email-issuing endpoints fall back to dev-only INFO logging.
metricsPrometheus exporter at /metrics plus request-counter middleware.
valkeyValkey/Redis JTI replay guard and sliding-window rate limiter. Wins over --durability-profile when PDS_VALKEY_URL is set.
otelOpenTelemetry OTLP HTTP/protobuf tracing. Activates when PDS_OTEL_ENDPOINT is set.
postgres-live-testsExercises the Postgres accounts adapter against a live instance. Keeps an unsupported adapter compiling and correct. See § 08.
Default SQLite profileListing 1
cargo build --features clap,smtp,metrics,hickory-dns \
  --bin pds --bin atproto-pds-admin
With blob bytes in object storageListing 2
cargo build --features clap,object-storage,smtp,metrics \
  --bin pds --bin atproto-pds-admin
Run it with dev defaultsListing 3
PDS_DATA_DIRECTORY=./.pds-data \
PDS_SERVICE_DID=did:web:pds.example.com \
PDS_JWT_SECRET=$(openssl rand -hex 32) \
PDS_ADMIN_PASSWORD=$(openssl rand -hex 16) \
  cargo run --features clap,smtp,metrics,hickory-dns --bin pds

§ 05

Configuration

Configuration is environment plus flags; pds --help is authoritative. What follows is the full set of PDS_ variables the server reads, grouped by what they govern. Where a default is documented in the crate it is given; where it is not, this page does not guess.

Identity & network
VariableGovernsExample
PDS_SERVICE_DIDThis server’s own DID. The did.json document is synthesised from it.did:web:pds1.example.dev
PDS_HOSTNAMEPublic hostname.pds1.example.dev
PDS_BINDListen address.0.0.0.0
PDS_PORTListen port.3000
PDS_SERVICE_HANDLE_DOMAINSSuffixes this server will issue handles under..pds1.example.dev,.example.dev
PDS_TRUSTED_PROXY_HOPSHow many X-Forwarded-For hops to trust when deriving client IP.
Storage & durability
VariableGovernsExample
PDS_DATA_DIRECTORYEverything on disk, including accounts.sqlite./var/lib/pds
PDS_STORAGE_PROFILESelected the per-actor store when there were two. SQLite is the only one now: unset and sqlite both boot, fjall refuses, any other value is ignored with a warning.
PDS_BLOB_STORE_URLBlob bytes in an object store: s3://bucket/prefix (any S3-compatible service, with AWS_ENDPOINT), gs://, az:// or file:///path. Needs the object-storage feature; a build without it refuses to boot rather than storing blobs somewhere you did not ask for. Only the bytes move — the inventory, the references and the takedown gate stay in the per-actor store.s3://pds-blobs/blobs
PDS_BLOB_STORE_MIGRATE_LOCALMoves blobs already in the per-actor store out to the object store, in the background after boot. Blobs not yet moved are served from where they are, and an interrupted move resumes on the next boot.true
PDS_DURABILITY_PROFILEDurability posture for the replay guard and rate limiter. Overridden by Valkey when PDS_VALKEY_URL is set.sql
PDS_STREAM_EVENT_RETENTION_HOURSHow long subscribeRepos events stay in the durable log. The window exists so a relay or AppView that falls over can reconnect and resume rather than backfilling from getRepo; three days covers a weekend outage, and a consumer that lags further gets OutdatedCursor and re-syncs, which is the documented path rather than a failure. The newest event is never deleted regardless of age — it is what a resume cursor is compared against, and an empty log would make every valid cursor read as FutureCursor.72
PDS_MIGRATION_RETENTION_DAYSDays a finished or abandoned migration row is kept; 0 disables the sweep. Measured from when the attempt started, not when it finished — a row that never finishes is the one worth pruning, and a window on the completion stamp would keep every abandoned attempt forever. A completed migration’s row is deleted; an outbound stand-down’s is not, because it is the receipt for an account that left and the operator has nothing else that says where it went.90
Secrets — none of these belong in an env file committed anywhere
VariableGoverns
PDS_JWT_SECRETSigning secret for issued JWTs.
PDS_ADMIN_PASSWORDCredential for com.atproto.admin.* and the /admin dashboard.
PDS_ADMIN_DIDSIdentities allowed to administer by signing a service-auth token with the #atproto key in their own DID document. Attributable in a way a shared password is not, and withdrawn by editing the list rather than rotating a secret every holder has a copy of. They need no account here.
PDS_OAUTH_KEYS_JWK_SETThe OAuth provider’s JWK set. Multi-key, which is what makes rotation possible.
PDS_PLC_ROTATION_KEY_PRIVATEThe operator’s recovery path for every identity this server issues. One value, the private did:key a key generator prints — P-256 (z42t…) or K-256 (z3vL…). A public key, or any other curve, is refused at boot.
Federation & directory
VariableGovernsExample
PDS_DID_PLC_URLPLC directory host.plc.directory
PDS_CRAWLERSRelays to announce to with requestCrawl. Unset means nothing ever discovers this server.https://bsky.network
PDS_CRAWLER_ANNOUNCE_DELAY_SECSHow long to wait before the first announcement. Retries run at 0s, 15s, 1m, 3m and 15m and stop at the first acceptance; the delay just moves attempt one to when a rolling deploy is actually reachable.15
PDS_BSKY_APP_VIEW_DIDApp View identity for proxied app.bsky.* traffic.did:web:api.bsky.app
PDS_BSKY_APP_VIEW_URLApp View endpoint.https://api.bsky.app
PDS_REPORT_SERVICE_DIDModeration service identity for reports.
PDS_REPORT_SERVICE_URLModeration service endpoint.
Limits & rate control
VariableGovernsDefault
PDS_BLOB_UPLOAD_LIMITOne blob through com.atproto.repo.uploadBlob.16 MiB
PDS_IMPORT_LIMITOne repository CAR through com.atproto.repo.importRepo.1 GiB
PDS_BLOB_GRACE_HOURSHow long an unreferenced blob survives before collection. A blob is legitimately unreferenced for a while — uploadBlob stores bytes before any record names them, and replacing an image drops the old reference and adds the new one as two steps — so the window has to outlast both.24
PDS_RATE_LIMITGeneral request ceiling. Proxied app.bsky.* calls count against it too, because this server forwards them rather than the client reaching an AppView directly.
PDS_RATE_LIMIT_AUTHCeiling on authentication endpoints specifically.
PDS_RATE_LIMIT_WINDOW_SECSSliding-window width.
PDS_RATE_LIMIT_BYPASS_IPSAddresses exempt from the limiter.
PDS_OAUTH_ACCESS_TOKEN_TTL_SECONDSAccess-token lifetime.
PDS_OAUTH_REFRESH_TOKEN_TTL_SECONDSRefresh-token lifetime.
PDS_FIREHOSE_SEND_TIMEOUTHow long one subscribeRepos frame may take to reach a consumer before the subscription is closed with ConsumerTooSlow.60s
PDS_DPOP_NONCE_REQUIREDWhether the OAuth endpoints demand a server-issued DPoP nonce. On by default; with it off a proof is replayable for its whole freshness window with only the JTI guard in the way. The escape hatch exists because turning it on changes what clients must do — one that does not handle a use_dpop_nonce challenge and retry starts failing — so an operator upgrading a live server can wait for a client to catch up instead of choosing between a conformant server and a working one.true

Size the proxy to match

The application enforces both body ceilings itself and refuses oversized requests as XRPC errors, so a client sees the same error shape it sees everywhere else instead of a bare 413 text/plain. That only holds if your reverse proxy is configured to allow at least as much as you set here. Otherwise the proxy rejects first and the operator-facing error is its, not the PDS’s.

Email, spaces & operations
VariableGoverns
PDS_EMAIL_SMTP_URLSMTP endpoint. Needs the smtp feature; without it, mail is logged rather than sent.
PDS_EMAIL_FROM_ADDRESSEnvelope sender.
PDS_EMAIL_LOG_BODIESLogs message bodies. Development only: these contain reset tokens.
PDS_SPACE_CREDENTIAL_TTL_SECONDSLifetime of an issued space credential.
PDS_SPACE_OPLOG_RETENTION_DAYSHow long space operation logs are kept.
PDS_SPACE_NOTIFY_RETRY_MAX_ATTEMPTSRetry ceiling for outbound space notifications.
PDS_SPACE_NOTIFY_RETRY_INITIAL_BACKOFF_MSFirst backoff interval for those retries.
PDS_SPACE_NOTIFY_TIMEOUT_SECSPer-delivery HTTP timeout for the notifier, default 10. Deliveries run serially in a background task — the one outbound path on the server no request timeout covers — so without this bound a recipient that accepts the connection and never answers stalls every other space’s notifications behind it.
PDS_SPACE_REGISTER_NOTIFY_TTL_SECONDSHow long a registerNotify subscription lasts, default 60 days. The 0016 draft requires the host to report an expiry and says it may outlast the space credential that created the registration, but names no duration; this is host policy.
PDS_ACCEPTING_MIGRATIONSWhether an inbound account migration may adopt a DID on this server. On by default.
PDS_DELEGATION_ENABLEDWhether this server can authenticate a delegate against another server’s OAuth. Off by default: delegation is new authority over an account reached without its password, and it also puts this server in the role of an OAuth client against other people’s servers. Needs an HTTPS origin and a P-256 OAuth key; without either, the portal’s Delegation section says which one is missing rather than offering a control that cannot work.
PDS_SHUTDOWN_DEADLINE_SECSHow long to wait for in-flight requests and background workers after SIGTERM before exiting anyway, default 25. This has to fit inside the supervisor’s grace period rather than match it: a platform that sends SIGKILL 30 seconds later kills the process at the exact moment a 30-second deadline expires, losing both the warning that says the drain failed and the telemetry flush after it.
PDS_PRODUCTIONProduction posture. Turns off development affordances.
PDS_ALLOW_DEV_DEFAULTSPermits insecure defaults. Never set alongside PDS_PRODUCTION.
PDS_INVITE_REQUIREDWhether account creation demands an invite code. Leave it on from first boot: a PLC DID created by a stray test is permanent and public.
PDS_LEXICON_DIRDirectory of lexicon documents served by this instance. *.json read recursively at startup, each keyed by its own id. Editing a file needs a restart, and an unreadable path is fatal rather than empty — a typo would otherwise leave a server that looks configured and resolves exactly as it did before.
PDS_POLICY_URLWhere this server’s policy documents are published. Shown to a holder before they agree.
PDS_POLICY_SET_IDIdentifier for an immutable set of those documents, so revising them yields a new identifier and a fresh acceptance rather than silently re-pointing an old one. Takes effect only alongside PDS_POLICY_URL.
PDS_ADMIN_BASE_URLBase URL the admin CLI targets.
PDS_METRICS_BINDAddress the Prometheus exporter listens on, e.g. 127.0.0.1:9090. It binds a listener of its own and /metrics is never mounted on the main one, so where you bind it is where it is reachable. An address that does not parse, or a port already taken, fails the boot. Unset means no exporter.
PDS_OTEL_ENDPOINTOTLP endpoint. Setting it activates tracing export.
PDS_VALKEY_URLValkey/Redis for the JTI replay guard and rate limiter. Setting it on a build without the valkey feature refuses at boot rather than silently falling back to the SQL default.
PDS_VALKEY_KEY_PREFIXKey namespace, for sharing one Valkey across deployments.
PDS_GC_INTERVAL_SECSGeneral garbage-collection cadence.
PDS_ACCOUNT_GC_INTERVAL_SECSAccount-reaper cadence.
PDS_NOTIFIER_INTERVAL_SECSOutbound notifier tick.
A working single-node environmentListing 4
PDS_BIND=0.0.0.0
PDS_PORT=3000
PDS_DATA_DIRECTORY=/var/lib/pds
PDS_PRODUCTION=true
PDS_SERVICE_DID=did:web:pds1.example.dev
PDS_HOSTNAME=pds1.example.dev
PDS_DID_PLC_URL=plc.directory
PDS_DURABILITY_PROFILE=sql
PDS_INVITE_REQUIRED=false
PDS_CRAWLERS=https://bsky.network
PDS_BSKY_APP_VIEW_DID=did:web:api.bsky.app
PDS_BSKY_APP_VIEW_URL=https://api.bsky.app
PDS_SERVICE_HANDLE_DOMAINS=.pds1.example.dev,.example.dev
PDS_SPACE_CREDENTIAL_TTL_SECONDS=7200
PDS_NOTIFIER_INTERVAL_SECS=5
PDS_ADMIN_BASE_URL=http://127.0.0.1:3000
RUST_LOG=info,atproto_pds=info

# PDS_JWT_SECRET, PDS_ADMIN_PASSWORD, PDS_OAUTH_KEYS_JWK_SET and the
# PLC rotation key come from your secret store, not from this file.

§ 06

Running it

Two Dockerfiles sit at the repository root and they build different things. Dockerfile.pds builds the server. The plain Dockerfile builds the CLI tools and does not contain a PDS — deploying that one and expecting a server is the mistake the crate’s own deployment note exists to prevent.

Build the server imageListing 5
docker build -t atproto-pds:dev -f Dockerfile.pds .

The image runs as non-root, and everything durable — the accounts database, each actor’s own store, blobs, the key store — lives under PDS_DATA_DIRECTORY. There is no second service to run and no external database to provision.

Front it with a reverse proxy that terminates TLS, permits request bodies at least as large as your configured ceilings, and passes WebSocket upgrades through for subscribeRepos. Shutdown is coordinated: on SIGTERM or SIGINT the server cancels long-lived workers, lets in-flight requests finish, and closes WebSocket subscribers cleanly, so a rolling restart drains rather than severs.

What the server does to protect itself

Four things sit outside the handlers, and an operator sees all of them in the logs before they see them anywhere else.

GuardWhy it is there
Request deadline Hyper adds none of its own. Without one, a client can open a connection, announce a Content-Length, and then send a byte a minute — holding the task, the socket and a partially buffered body for as long as it likes. Ten thousand such connections cost a few kilobytes a second to maintain, and nothing reclaims them.
Concurrency cap with load-shed A ceiling on requests in flight. Past it the server sheds rather than queues, so overload degrades into refusals instead of unbounded latency.
Panic floor A panicking handler returns a response instead of killing the connection.
Blob collection Nothing used to delete a blob. Replaced avatars and deleted videos stayed for the life of the deployment, and an account could grow the shared volume without bound by writing records and deleting them. Unreferenced blobs are now collected after PDS_BLOB_GRACE_HOURS.

One instance, and it is a correctness setting

SQLite is the durable store and its volume attaches to a single instance. Two replicas either fail to start or, worse, run against separate copies of the same repositories — both signing commits into divergent histories under one DID. There is no read replica to scale onto either. A replica count above one is a data-loss setting here, not a throughput one.

§ 07

Deploying on a platform

The workspace carries railway.toml and a deployment note written against a real Railway deployment. The platform specifics are Railway’s; the traps below are not, and most of them cost somebody an afternoon.

Things that fail quietly
TrapWhat happensFix
The wrong Dockerfile You deploy the CLI-tools image and it contains no server. Build Dockerfile.pds.
Setting PDS_PORT The image deliberately leaves it unset so the binary falls back to the platform’s PORT. Setting it defeats the fallback, the server binds its own default, the platform routes elsewhere, and the health check times out with nothing in the log to explain it. Leave it unset; confirm PORT is injected.
No PDS_CRAWLERS The server is completely healthy — writes commit, putRecord returns a real commit CID — and every AppView shows an empty profile, because nothing was ever told to crawl you. Set it. Watch for requestCrawl: announced.
Default PDS_TRUSTED_PROXY_HOPS Behind a CDN every request arrives from the proxy’s address, so the rate limiter sees one client and throttles all of your users as a single caller. Set it to the number of proxies you operate — usually 1. Inflating it lets a caller forge their address.
SMTP on 465 or 587 Cloud platforms commonly block outbound mail ports. The server sends nothing while every other check — domain verified, SPF and DKIM live, credentials good — passes. Port 2525, scheme smtp://, and ?tls=required appended. All three change together.

Announcing is publication

A relay that accepts your announcement ingests your repository, and so does anyone consuming the firehose. Records may stay cached and indexed after you delete them. Every account created against the real PLC directory is permanent and public for the same reason — the directory is an append-only log, and a DID created by a stray test cannot be withdrawn.

§ 08

Modes that refuse to start

One backend exists in the source tree, compiles behind a Cargo feature, and has tests, but is deliberately not wired into the pds binary. Two further configurations are real and ask for something the binary you built cannot do. All three are documented here so nobody finds out the hard way.

ModeWhere it stops
PostgreSQL accounts DB
postgres · PDS_POSTGRES_URL
AccountDirectory::open_postgres exists and 57 of 59 accounts-DB query sites already dispatch per dialect. Thirteen production call sites do not: the OAuth state store, the JTI replay guard, the rate-limit SQL backend, the GC loop, the notifier, the sequencer, four files of the spaces subsystem, and the repository writer’s signing-key lookup all take a SQLite-only pool accessor that panics on a Postgres pool.
Object storage on a build without it
object-storage · PDS_BLOB_STORE_URL
Supported, and wired in — but only if the feature is compiled in. Setting the URL on a binary built without object-storage refuses at boot rather than quietly keeping blobs in the per-actor store. See § 03.
The fjall per-actor store
PDS_STORAGE_PROFILE=fjall
Removed. A deployment that ran it holds its repositories in a keyspace this binary cannot read, so the value is refused rather than ignored — booting anyway would serve every account as empty. Export each repository with a build that still has fjall, import it into a SQLite deployment, and unset the variable.
Valkey on a build without it
valkey · PDS_VALKEY_URL
Supported, but only if the feature is compiled in. Setting the URL on a binary built without valkey now refuses at boot instead of leaving the durability profile at its in-memory default — which used to fail later, and further away, as a confusing complaint about memory durability not being allowed in production.

They refuse at boot, on purpose

PDS_POSTGRES_URL and PDS_BLOB_STORE_URL used to be parsed and silently ignored, so an operator who configured either believed they had it and got neither. A documented mode that does not work is worse than an absent one; a mode that fails loudly at startup is neither. The valkey check follows the same rule, and deliberately runs ahead of the production gate so the error names the variable you actually set. Object storage has since been implemented, so its refusal now means only that the feature is missing from the build.

§ 09

Spaces

This is the second PDS implementation anywhere to serve permissioned data spaces, and the first in Rust. That makes it useful in a way its maturity does not otherwise justify: interoperability work on the 0016 Permissioned Data draft needs a second implementation to test against, and this is it.

A public repository is public by construction — the MST leaks its key set even to a reader who cannot decrypt the records. A space is the permissioned alternative: membership gates reads, and the commit structure is built so a leaked commit does not become evidence of what it contained. The concept page covers the primitives; this section is what the server actually exposes.

Two namespaces

The split matters. simplespace is the owner’s control plane — who exists, who is a member. space is the data plane, and it is the one members talk to.

com.atproto.simplespace.* — owner-side management, seven methods
MethodDoes
createSpaceMint a space under the owner’s repository.
getSpaceRead one space’s metadata. Named in both namespaces; this is the owner’s view.
updateSpaceChange its metadata.
deleteSpaceRemove it, and notify members through notifySpaceDeleted.
addMemberAdmit an identity.
removeMemberRevoke it.
listMembersEnumerate the member list.
com.atproto.space.* — the permissioned realm, twenty-one methods
GroupMethods
Records createRecord, putRecord, deleteRecord, applyWrites, getRecord, listRecords, getBlob, listBlobs
Spaces getSpace, listSpaces
Sync getRepo, getLatestCommit, getRepoState, listRepos, listRepoOps
Credentials getDelegationToken, getSpaceCredential
Notification registerNotify, unregisterNotify, notifyWrite, notifySpaceDeleted

listRecords returns keys, not records

In the public realm listRecords hands back record values. In a space it returns keys only. Reading content is a per-record fetch, because the enumeration and the contents are separately authorised — which is the whole point of a permissioned realm, and a difference that will surprise anyone porting public-realm code across.

Getting in: the two-step exchange

A member does not authenticate to a space with their ordinary session. They exchange it. getDelegationToken issues a delegation token, and getSpaceCredential trades that for a short-lived space credential — both JWTs, defined in atproto-space as DelegationToken and SpaceCredential.

The exchange is replay-protected by a JTI guard. In its default form that guard is in-memory, which means it is per-process and it forgets on restart. Setting PDS_VALKEY_URL moves it to Valkey or Redis, where it survives both.

Telling members something changed

Writes do not push content. A recipient registers with registerNotify and then receives contentless notifyWrite events — just { space, repo, rev } — and fetches for itself if it wants the data. Deletion is announced the same way through notifySpaceDeleted.

Outbound delivery is retried with backoff and has a dead-letter queue behind it. Five variables govern delivery, and how far behind a member may fall before catching up stops being incremental:

Notifier and retention
VariableGoverns
PDS_NOTIFIER_INTERVAL_SECSHow often the notifier ticks.
PDS_SPACE_NOTIFY_RETRY_MAX_ATTEMPTSHow many times a failed delivery is retried before it lands in the DLQ.
PDS_SPACE_NOTIFY_RETRY_INITIAL_BACKOFF_MSThe first backoff interval; subsequent ones grow from it.
PDS_SPACE_NOTIFY_TIMEOUT_SECSPer-delivery HTTP timeout. Deliveries run serially, so an unbounded one stalls every other space behind it.
PDS_SPACE_OPLOG_RETENTION_DAYSHow long the operation log backing listRepoOps is kept. A member offline longer than this cannot catch up incrementally.

notifyMembership is gone

A member-sync notifyMembership route was removed in the 0016 re-alignment, along with getMemberState and getMemberOplog; only notifyWrite and notifySpaceDeleted are served. The name survives in migration comments, the notifier’s dead-letter schema and a test comment, which is the only place you will meet it. The crate README no longer lists it.

Membership is the check that matters

A space endpoint has two gates and they answer different questions. Scope authorisation asks what an OAuth token is permitted to do — and returns early for anything that is not OAuth, because an app-password session carries no scopes and is full-authority over its own account. Membership asks whether this caller belongs to this space at all. Only the second one is about the space.

Three paths were relying on the first alone and now perform the second: createRecord (the only space write that lacked it, while putRecord, deleteRecord and applyWrites all had it), and the sync reads getRepoState and listRepoOps. If you are implementing the other side of this protocol, it is worth checking which of your own endpoints assert membership rather than merely authenticating.

notifySpaceDeleted had a related problem in its audience binding: the handler took aud from the unverified payload and handed it back as the expected audience, comparing a claim against itself. The audience is now decided from verified claims.

§ 10

Administration & tests

atproto-pds-admin covers invite-code issuance, account inspection, takedown and the rest of the operational surface; the same operations are available over com.atproto.admin.* and through the HTML dashboard at GET /admin.

Test matrixListing 6
# Default profile — SQLite, http, clap
cargo test -p atproto-pds --features http,clap

# Adds the object-storage suite
cargo test -p atproto-pds --features http,clap,object-storage

# Postgres accounts adapter (see § 08 before you rely on it)
cargo test -p atproto-pds --features http,clap,postgres

# Live Postgres round-trip; skips and reports OK when the DSN is unset
PDS_POSTGRES_TEST_URL=postgres://pds:pds@127.0.0.1:5432/pds_live \
  cargo test -p atproto-pds --features postgres-live-tests \
    --test feature_postgres_live