Skip to content

spaces (permissioned data)

nate
Aug 20, 20267 min read

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 the permissioned-data branch of bluesky-social/atproto (PR #5187). the alpha packages were published from commit 2f77206; 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 in packages/pds/src/simplespace/{manager,config}.ts.
  • example app: https://github.com/bluesky-social/bulletin — one my.bulletin.board simplespace per user, managingAppPolicy pointed at did: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.createSpace is {type, skey?, policy, appAccess}; the space is anchored on the authenticated DID. policy/appAccess are $type unions from com.atproto.simplespace.defs (#publicPolicy | #memberListPolicy | #managingAppPolicy{managingApp}, #open | #allowList{allowed}). unknown variants → UnsupportedPolicy / UnsupportedAppAccess. a managingApp must start with did: (service fragment optional).
  • simplespace.getSpace (not space.getSpace) returns {uri, policy, appAccess}.
  • space.listRepoOps pages with an opaque rev/idx cursor; a full page has cursor and no commit, the last page has the signed commit. default 100, max 1000. listRepos same sizes, cursor only on a full page.
  • space.deleteRecord is idempotent, like repo.deleteRecord.
  • getSpaceCredential errors: InvalidDelegationToken, SpaceDeleted, UserNotAuthorized, AppNotAuthorized, InvalidClientAttestation.
  • a space credential is a JWT whose exp is authoritative — hosts clamp the lifetime anywhere from a minute to a day — so a client reads exp (with a small margin) rather than assuming the two-hour default.
  • simplespace.listMembers on the alpha host can return a cursor on 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.fmpollzdoodlbulletinpensieve
unitartist library selfone poll (skey = poll rkey)one album (skey tid)one board selfone memory self, packs inside
authorityartistpoll authoralbum ownerboard owneridentity
writersartistevery voterowner + inviteesmutualsidentity (via pensieve)
readersartistpollz onlyinviteesfollowersinvitees
policymemberList (owner-only)publicmemberListmanagingAppmemberList
appAccessopenallowList[pollz]openopenopen (keyset later)
who needs a spaces PDSartistauthor + every voterowner + every inviteeowner + every readerowner + every reader
server rolesyncer + auth blob proxysyncer + talliernonesyncer + policy servicebuilder + credentialed reader + discovery
membership changes vianothing (public)addMemberfollow graphaddMember

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.getBlob needs an exact (space, repo, cid) record reference; a credential for one space cannot fetch a blob referenced only by another.
  • listSpaces excludes spaces you are merely a member of — discovery of "spaces i was added to" is the app's job.
  • appAccess: #allowList is checked only at credential minting, never on writes, and needs a client attestation only a confidential client can sign. managingApp's checkUserAccess cannot 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's dist/dpop.js is browser-safe, but the barrel export drags node-only sync/* 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 getDelegationToken is 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: uploadBlob stores 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 == authority at 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-data branch (PR #5187); bluesky-social/bulletin — reference app
  • pollzdocs/spaces-secret-ballots.md
  • doodl — docs/spaces-private-albums.md
  • pensieve — docs/memory.md
  • plyr.fmdocs/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
  • pdslssrc/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.

Across the AtmosphereDiscussions