spaces (permissioned data)
spaces (permissioned data)
announced 2026-08-20: https://atproto.com/blog/atproto-spaces-alpha. a space is a gated mini-network: a space authority (any DID) decides which DIDs and apps may participate; records live in per-space permissioned repos on each author's PDS; sync is point-to-point (no relay), access-controlled but not encrypted.
where the contract lives
- lexicons:
lexicons/com/atproto/{space,simplespace}on thepermissioned-databranch of bluesky-social/atproto (PR #5187). the alpha packages were published from commit2f77206; the branch keeps moving afterwards, so diff against the tip, not a proposal doc. - reference handlers:
packages/pds/src/api/com/atproto/{space,simplespace}/*.ts, policy mapping inpackages/pds/src/simplespace/{manager,config}.ts. - example app: https://github.com/bluesky-social/bulletin — one
my.bulletin.boardsimplespace per user,managingAppPolicypointed atdid:web:bulletin.my#bulletin,appAccess: open. it vendors only the lexicon subset it calls, so it's a client-side check, not the spec.
shapes that bit
simplespace.createSpaceis{type, skey?, policy, appAccess}; the space is anchored on the authenticated DID.policy/appAccessare$typeunions fromcom.atproto.simplespace.defs(#publicPolicy | #memberListPolicy | #managingAppPolicy{managingApp},#open | #allowList{allowed}). unknown variants →UnsupportedPolicy/UnsupportedAppAccess. amanagingAppmust start withdid:(service fragment optional).simplespace.getSpace(notspace.getSpace) returns{uri, policy, appAccess}.space.listRepoOpspages with an opaquerev/idxcursor; a full page hascursorand nocommit, the last page has the signedcommit. default 100, max 1000.listRepossame sizes, cursor only on a full page.space.deleteRecordis idempotent, likerepo.deleteRecord.getSpaceCredentialerrors:InvalidDelegationToken,SpaceDeleted,UserNotAuthorized,AppNotAuthorized,InvalidClientAttestation.- a space credential is a JWT whose
expis authoritative — hosts clamp the lifetime anywhere from a minute to a day — so a client readsexp(with a small margin) rather than assuming the two-hour default. simplespace.listMemberson the alpha host can return acursoron an already-exhausted page (the last member's DID); only a full page means there may be more. (pdsls guards for this.)
oauth
apps request include:<nsid> permission sets; the PDS resolves the lexicon (DNS + lexicon record) and expands to space:<type>?authority=…&collection=…& action=…&manage=… at token time. bulletin's client-side capability check reads the expanded scope from the token response, so the PDS must return it expanded.
implementation drift
an implementation named in an announcement can still be speaking a pre-alpha wire format: the lexicons move after the packages publish, so parity means diffing the branch tip, not the announcement, and then exercising the reference app from a real account. and log rejected xrpc requests — a server that stays silent on 4xx makes every client integration failure invisible from the server side.
what is a space, per app (2026-08-21)
per-app design docs live in each repo: pollz (docs/spaces-secret-ballots.md), doodl (docs/spaces-private-albums.md), pensieve (docs/memory.md), plyr.fm (docs/internal/architecture/permissioned-private-media.md). plyr.fm's is in its repo (docs/internal/architecture/permissioned-private-media.md, issues #1684, #1573, #1384; drift fix #1876 → PR #1877).
| plyr.fm | pollz | doodl | bulletin | pensieve | |
|---|---|---|---|---|---|
| unit | artist library self | one poll (skey = poll rkey) | one album (skey tid) | one board self | one memory self, packs inside |
| authority | artist | poll author | album owner | board owner | identity |
| writers | artist | every voter | owner + invitees | mutuals | identity (via pensieve) |
| readers | artist | pollz only | invitees | followers | invitees |
| policy | memberList (owner-only) | public | memberList | managingApp | memberList |
| appAccess | open | allowList[pollz] | open | open | open (keyset later) |
| who needs a spaces PDS | artist | author + every voter | owner + every invitee | owner + every reader | owner + every reader |
| server role | syncer + auth blob proxy | syncer + tallier | none | syncer + policy service | builder + credentialed reader + discovery |
| membership changes via | — | nothing (public) | addMember | follow graph | addMember |
patterns:
- the user is always the authority; the app always owns the semantic.
- an app never stores membership: the list is host-internal, authority-only, and off the firehose, so the only question it can ask is "mint me a credential for this user" — and the only state worth holding is that answer, for the credential's
exp(a refusal briefly). a copy refreshed by an owner action in the app makes the owner's attention a dependency of other people's access. - oauth-session reads inside a space are limited to your own repo (
read_self); cross-repo reads always go through a space credential. space.getBlobneeds an exact(space, repo, cid)record reference; a credential for one space cannot fetch a blob referenced only by another.listSpacesexcludes spaces you are merely a member of — discovery of "spaces i was added to" is the app's job.appAccess: #allowListis checked only at credential minting, never on writes, and needs a client attestation only a confidential client can sign.managingApp'scheckUserAccesscannot distinguish read from write. the workable combinations:- delegation tokens are one-use and consumed atomically; mint with backoff, and dedupe concurrent mints for one (user, space) so they share a result. there is no freeze: flipping policy to lock writers also locks the app's own credential minting.
@atproto/space'sdist/dpop.jsis browser-safe, but the barrel export drags node-onlysync/*in — import the file or hand-roll the proof.- the one user-access bit (write ⇔ read) is the recurring wall. pollz routes around it with app identity; doodl can't (no confidential client).
- every participant (reader or writer) needs a spaces-capable PDS because
getDelegationTokenis served by the requester's own PDS. plyr.fm was the right MVP because only the artist needs one. - no discovery primitive for "spaces i was added to"; apps will invent public pointers.
- no close/freeze; no hot-linkable private blobs.
- a blob belongs to whichever records reference it, not to a container:
uploadBlobstores bytes, a record write associates the cid with a space or the public repo, and a cid referenced from both is reachable both ways. one record body can therefore be published privately first and publicly later by writing a second record over the same cids. - the write bit cuts both ways: a member may write into the space under their own repo, so a consumer of authority-authored data must filter
repo == authorityat the read, not in the ui. - derived artifacts (transcodes, indexes, embeddings) inherit the access boundary of what they derive from. public input does not make public output.
- zat needs a spaces client (credential exchange, DPoP-bound reads, LtHash + commit verify, paging) — pollz is the natural second consumer after zds.
indexers and feeds
a space credential is whole-space: no per-collection, per-record, or metadata-only read grant exists in any implementation. a service that wants only engagement data (who liked what) without content has one option — the app writes that data into a separate space with its own policy.
there is no service-reader role. an indexer gets a credential either as an oauth client acting for a member (habitat's sap syncer: it holds member sessions, and losing them loses the space) or by having its own DID added as a member. the substrate does not distinguish "member who reads" from "service that indexes"; that is app or space-host policy.
the read-time acl pattern in use: index everything reachable, and at query time re-mint a credential for the requesting user before returning anything. a stale index yields a hole in the results, never a leak, because the final hydrate goes through the PDS's own check. this makes acl synchronization a liveness problem, not a safety one.
nobody has a convention for advertising or pinning a third-party service (feed, search) to a space; the nearest prior art is the arbiter's "authoritative config records in the space host's own repo".
sources
- atproto spaces alpha announcement — 2026-08-20
- bluesky-social/atproto —
permissioned-databranch (PR #5187); bluesky-social/bulletin — reference app - pollz —
docs/spaces-secret-ballots.md - doodl —
docs/spaces-private-albums.md - pensieve —
docs/memory.md - plyr.fm —
docs/internal/architecture/permissioned-private-media.md - habitat — "road to release 02: sap" (2026-08-14); zicklag — "the arbiter" (2026-04); ngerakines — "permissioned data: space access" (2026-08-14); dholms — "diary 6: boring auth" (2026-06); via pub-search
- pdsls —
src/lib/spaces.ts, spaces support 2026-08
Did you enjoy this article?
Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.