permissioned data #
Permissioned data support is experimental and operator gated. Treat the current
ZDS surface as an alpha implementation that can change incompatibly, not as a
claim of complete parity with the evolving upstream proposal. ZDS documents the
com.atproto.space.* routes as experimental, and only enables the handlers when
explicitly configured:
ZDS_PERMISSIONED_DATA=true
When the flag is off, permissioned-data routes return a structured
MethodNotImplemented response and do not create or mutate permissioned-space
rows. The API reference marks the space routes with
x-zds-experimental: true so clients can treat the namespace as experimental
even when an operator has not enabled it.
The OAuth server follows the same flag. When it is off, a PAR or token request
that asks for a space: scope, or for an include: permission set that expands
to one, is rejected with invalid_scope. Clients can therefore treat the
presence of a space: grant on an issued token as proof that the PDS serves the
space routes: ZDS never hands out a grant it will answer with
MethodNotImplemented.
references #
- Announcement: https://atproto.com/blog/atproto-spaces-alpha (lists ZDS among the spaces-compatible PDS implementations)
- Bulletin, the alpha's example app: https://github.com/bluesky-social/bulletin
(creates one
my.bulletin.boardsimplespace per user; the smallest real client of this surface) - Proposal: https://github.com/bluesky-social/proposals/tree/main/0016-permissioned-data
- Diary 7, signed commits and sync: https://dholms.leaflet.pub/3mqtqvjidqs2p
- Current discussion: https://discourse.atmosphere.community/t/permissioned-data-proposal-discussion/946
- Earlier lexicon discussion: https://discourse.atmosphere.community/t/permissioned-data-pds-lexicons/879
- Member-list direction: https://discourse.atmosphere.community/t/removing-the-member-list/895
- App-layer roles and cross-space relations (Habitat's proposal to standardize reader/writer/manager/owner relationships): https://discourse.atmosphere.community/t/spaces-interoperable-fine-grained-permissioning/1161
- Habitat, "road to release 06: commenting" (one
network.habitat.docsspace per document plus anetwork.habitat.docs.commentsspace, with viewer, commenter, and editor roles derived by relations between the two): https://habitat.leaflet.pub/3mvb55bjhbk2n - Branch: https://github.com/bluesky-social/atproto/tree/permissioned-data
- Lexicons: https://github.com/bluesky-social/atproto/tree/permissioned-data/lexicons/com/atproto/space
- Reference implementation path:
packages/pds/src/actor-store/spaceon the permissioned-data branch - Bluesky proposal PR: https://github.com/bluesky-social/proposals/pull/94
- Local alignment notes: permissioned-data proposal 94
- Migration and CAR conformance experiments: space-host migration
The upstream design is still moving. The May branch included protocol-level
member lists. The later discussion moved toward making space credentials the
protocol substrate and leaving reader/group semantics to applications or
space-host policy. Proposal PR #94 keeps that broad direction and defines a
baseline com.atproto.simplespace.* management surface for PDS-managed spaces.
ZDS targets the October 1, 2026 alpha at atproto commit
679724ad62eb9a02f7f40429c3c4fdd65d3e4c79 and SDK snapshot
0.0.0-spaces-alpha-20261001173819. The
migration reference
covers this release. Use its pinned Lexicons and handlers for wire contracts;
the moving branch can contain later changes.
Feedback should respect the research behind the sketch and avoid overfitting to ZDS or plyr.fm. Name concrete implementation pressure, but frame it as input for the broader protocol shape.
The current local motivation is private data storage on a resident's PDS. That is narrower than proving every group/community use case. Recent proposal discussion reinforces the same posture: groups are the hard case that may justify the primitive, but ZDS should not bake group semantics into the PDS just to get private records and blobs working early.
current prototype surface #
ZDS splits protocol data routes from baseline PDS management routes:
com.atproto.space.*:getSpace,listSpaces,listRepos,getDelegationToken,getSpaceCredential, records, blobs, signed commits, full and incremental repo sync, blob enumeration, notification registration and removal, write notifications, credential revocation, and deletion notificationscom.atproto.simplespace.*:createSpace,getSpace,updateSpace,deleteSpace,putMember,removeMember,listMembers, and the managing-appcheckUserAccesshook
ZDS uses the proposal names for the credential and sync-read surface:
getDelegationToken and listRepoOps.
createSpace anchors the space on the authenticated DID and takes
{spaceType, skey?, readPolicy, writePolicy, appAccess}. Both policies use the
com.atproto.simplespace.defs unions #publicPolicy, #memberListPolicy, or
#managingAppPolicy{managingApp}; appAccess is #open or
#allowList{allowed}. getSpace returns both policies. updateSpace replaces
only supplied fields. putMember upserts required read and write booleans.
The old policy field is rejected and addMember is removed. See the
client and storage migration guide.
listRepoOps pages with an opaque rev/idx cursor: a full page (limit,
default 100, max 1000) carries cursor and no commit; the page that reaches
the head of the oplog carries the signed commit instead. listRepos uses the
same default and maximum, but orders latest writer states by spaceRev.
Each entry contains did, repoRev, hash, and spaceRev. Every nonempty
page returns its last spaceRev as cursor; an empty page omits it. Cursors
are opaque strings and exclusive checkpoints. A writer can reappear during
pagination when it advances; consumers must process it idempotently.
ZDS deliberately does not expose the older protocol member-list routes or the pre-proposal lifecycle names:
getMembersgetMemberStategetMemberOplognotifyMembershipcom.atproto.space.createSpacecom.atproto.space.updateSpaceConfigcom.atproto.space.deleteSpace
com.atproto.space.getSpace is the pre-alpha name for
com.atproto.simplespace.getSpace; both answer with the same body.
getSpace and listSpaces do not return members or isMember.
getSpace accepts the proposal's space credential when called by a syncer.
ZDS also retains resident OAuth/session access for its account UI; that is an
additive local access path, not a substitute for credential-authenticated host
interoperability.
access model #
ZDS treats permissioned data as a space credential and writer-repo substrate, not as a universal group-membership system.
Applications own rich reader/group semantics: label rosters, supporter access, personal private libraries, community roles, follower-only access, and similar policy all belong above the low-level PDS substrate. If an application needs portable access state, it should model that state as application records in a permissioned space rather than relying on a universal PDS-wide member list.
Cross-space role inheritance is the same kind of application policy. Habitat's
Chalk editor (September 2026) puts a document and its comments in two spaces
and derives comment access from document access inside its own space host,
then asks the community to standardize those relations. Nothing in that shape
needs a PDS primitive: a managing-app authority answers checkUserAccess
for the comments space by consulting the document relation itself. The one
substrate limit to keep in mind is that checkUserAccess returns a single
authorized bit, so a viewer/commenter/editor split cannot be enforced by a
ZDS-hosted simplespace alone; an app that needs tiered write access hosts the
space itself or splits tiers across spaces, as Habitat does.
simplespace membership is stored only as baseline space-management policy
state. It is not exposed as a protocol sync surface. For a member-list space,
ZDS checks the member's read flag for credentials and write flag for writer
authorization. A resident may store records in their own repo even when the
authority denies write access; the authority then excludes that update from
writer registration and notification forwarding. Each policy is independent. For
public, any requester can be authorized. For managing-app, the authority
calls the configured service's com.atproto.simplespace.checkUserAccess method
with access=read or access=write and service auth. Write checks omit
clientId. Resolution, transport, and response errors deny access.
The generic PDS implementation of that method returns authorized: false;
policy services replace it with their own application-layer decision.
readPolicy, writePolicy, and appAccess use the lexicon-union wire shape on every
simplespace route: {"$type":"com.atproto.simplespace.defs#memberListPolicy"},
{"$type":"com.atproto.simplespace.defs#managingAppPolicy","managingApp":"did:…#svc"},
{"$type":"com.atproto.simplespace.defs#open"}, or
{"$type":"com.atproto.simplespace.defs#allowList","allowed":[...]}. A
managingApp must be a DID with an optional service fragment; anything else is
UnsupportedPolicy.
Delegation tokens identify the user, not the app. For allow-list spaces, ZDS
verifies the separately supplied clientAttestation against the client's
published metadata and JWKS before using its client_id. Credential issuance
requires an HTTP Message Signature covering authorization, with a P-256
did:key in keyid. The credential binds that key in cnf.kid and, when
attested, records client_id. Credential requests use Authorization: Atproto-Space ... and sign authorization plus atproto-space-audience.
The audience is the repo owner's DID for repo reads and the authority's bare
DID for space-host operations. Signatures are reusable for the same credential
and audience; OAuth DPoP and single-use delegation tokens are unchanged.
Credentials default to 600 seconds and may last at most 3600 seconds, with
five seconds of clock skew. notifyCredentialRevoked accepts 1–100 credential
JWT IDs, authenticated by the exact authority and addressed to a local repo
DID. Revocations persist for 3610 seconds from receipt and are enforced on
credential use. Removing a member prevents new issuance; it does not currently
send revocations automatically.
Writer notifications are keyed by writer repo. notifyWrite verifies service
auth from the writer repo to the authority's #atproto_space_host audience
(the bare authority DID is also accepted). It records repoRev and the SHA-256
digest of the writer's LtHash state, atomically assigning a strictly increasing
space-wide spaceRev. Duplicate/older repo revisions are ignored, and revisions
more than five minutes in the future are rejected. Forwarded notifications
include spaceRev and, after the first update, prevSpaceRev. listRepos is space-credential-only and
returns this authority-maintained writer set; it never exposes the raw LtHash
state or a reader/member list.
registerNotify stores a 24-hour whole-space subscription for a service DID,
resolving either the named service fragment or the DID's PDS endpoint.
unregisterNotify removes that subscription idempotently. Repo writes save
pending notifications in the same SQLite transaction as the records and commit
state. The delivery worker coalesces newer state per writer/space, uses expiring
leases, and retries transient failures with backoff for up to 24 hours per new
revision. Permanent errors stop delivery. Authority-to-syncer fanout remains
best effort: syncers recover gaps through listRepos from their last safely
processed checkpoint, then fetch data from the affected repo hosts.
When notifying a remote authority, ZDS resolves its dedicated
#atproto_space_host service when present and falls back to #atproto_pds.
This preserves the proposal's separation between an authority's identity,
space host, and account PDS.
client-visible contract (verified across implementations, 2026-08-21) #
This historical comparison informed plyr.fm (#1897, #1898). The behaviours were checked
against the reference PDS (permissioned-data branch), rsky, and
atproto-crates so that nobody mistakes them for ZDS quirks and "cleans them
up".
| behaviour | reference | rsky | atproto-crates | ZDS |
|---|---|---|---|---|
scopes_supported lists space:* (or any granular scope) |
no (atproto + transition:* only; "dynamic scopes can't be enumerated") |
no | no | no (it did until 2026-08-21; the hints were a ZDS invention nobody consumed) |
authority=* in a space: grant matches any authority; self resolves to the granting user at token issuance |
yes | yes | yes | yes |
getDelegationToken needs only a covering read grant, signs with the requester's own repo key, and never checks that the authority is local |
yes | yes | yes | yes |
simplespace.listMembers is the authority's private state |
owner-only (read_self scope + owner assert) |
owner-only (manage=update) |
any member with read_self |
owner-only (manage=update) |
Clients must not infer capability from scopes_supported: the grant on the
issued token is the only portable signal. listMembers is the one row where
implementations differ; a client should treat it as owner-only.
The September policy update supersedes the ZDS listMembers scope cell above:
ZDS now requires the owner and a covering read grant. Other implementations
in this historical table have not been rechecked for the October alpha.
storage shape #
ZDS stores many actor repos in one SQLite database, so permissioned data uses explicit space-scoped tables instead of the public repo tables:
permissioned_spaces: canonical authority-space config keyed by space URIpermissioned_space_actor_state: actor-local authority/deleted state keyed by(space, actor_did)simplespace_members: baseline PDS-managed member-list policy state keyed by(space, member_did)permissioned_space_records: current records keyed by(space, repo_did, collection, rkey)with CID, AT JSON value, repo revision, and indexed timestamp; canonical DAG-CBOR is reconstructed and CID-checked for full-state exportpermissioned_space_repos: repo-host state, including the full LtHash state needed to update and sign that writer's local permissioned repopermissioned_space_writers: authority-host writer registry, containing only each known writer's latest repo revision, space revision, and 32-byte commit digestpermissioned_space_record_oplog: incremental record changes by(space, repo_did, rev, idx)permissioned_space_notify_registrations: expiring service-identified write-notification subscriptionspermissioned_space_sequence: persistent last revision for each authority spacepermissioned_space_notify_outbox: durable, coalesced repo-host notificationspermissioned_space_revocations: expiring(space, jti)revocationspermissioned_space_used_delegations: consumed one-use delegation-token IDspermissioned_space_used_client_attestations: consumed one-use client attestation IDs
Space roots use the canonical proposal URI:
at://{authorityDid}/space/{spaceType}/{skey}
Permissioned record URIs append {authorDid}/{collection}/{rkey}. ZDS migrates
the earlier experimental URI scheme in storage and does not accept it at the
HTTP boundary.
listSpaces enumerates actor-local space rows: spaces the resident owns or has
materialized a permissioned writer repo in. Being named in a simplespace member
list does not make a space appear there.
OAuth space grants use authority=, repeated action= record operations, and
separate repeated manage= space-management operations. authority=self is
resolved to the granting account's DID when the token is issued.
OAuth reads are limited to the authenticated account's own permissioned repo. Whole-space writer enumeration and cross-repo reads require a space credential. Delegation tokens and client attestations are short-lived and consumed together in one transaction when the authority mints that credential. A missing or invalid issuance signature does not consume either token. Reusing a consumed delegation token still fails even with a valid signature.
Permissioned records and blobs are not public repo records. They must not be
squeezed into records, repo_blocks, commits, or seq_events.
host roles and blob ownership #
The proposal separates three roles that are often served by one PDS:
- the PDS authenticates its resident and accepts writes
- the repo host stores and serves that resident's permissioned repo and blobs
- the space host describes the space, decides whether to mint space credentials, lists known writers, and routes notifications
The roles may be implemented by different services, but that does not imply centralizing every writer's records at the space host. In the proposal's normal topology, each writer's permissioned repo and referenced blobs remain on that writer's repo host. A syncer obtains a space credential from the space host, discovers writers there, and then pulls records and blobs from each writer's repo host.
ZDS currently serves all three roles for its resident-owned simplespaces. In
the plyr.fm path, com.atproto.repo.uploadBlob places bytes in the resident's
blobstore and a subsequent permissioned record write associates the CID with
that exact space. Public com.atproto.sync.getBlob remains unavailable unless
a public repo record also references the blob. Permissioned
com.atproto.space.getBlob requires both valid read authority and an exact
(space, repo, CID) record reference; a credential for one space cannot be
used to retrieve a blob referenced only by another.
A separate space host may choose to store permissioned records itself as a compatibility layer for PDSes that do not implement permissioned repos. That is a valid experimental deployment shape, but it is not equivalent to the proposal topology: the writer's PDS cannot infer the remote private record's blob reference, so retention and garbage collection require an additional contract. ZDS should not invent that contract locally before interoperability testing establishes the shared requirement.
Before cross-implementation testing, verify these invariants independently:
- a previously uploaded blob becomes reachable only after a record in the requested space references it
- the same CID remains unavailable through another space
- public blob reads remain unavailable for private-only references
- record update and deletion update the space-scoped blob reference index
- old permissioned records are backfilled into that index before stricter read authorization is enabled
The draft proposal and its reference branch remain works in progress. Treat their stated topology and wire contracts as the interoperability target while testing security-sensitive behavior rather than inheriting implementation gaps.
com.atproto.space.listRecords returns current record values by default, in
line with the permissioned-data proposal. Consumers that only need the
collection, rkey, and CID listing can pass excludeValues=true to avoid
materializing record JSON.
com.atproto.space.listRepoOps follows the same value rule. Create and update
operations inline a value only while that operation's CID is still the current
record CID; deletes and superseded operations remain metadata-only.
com.atproto.space.getRepo is the full-state recovery path. Its CAR declares
the freshly generated deniable signed commit and a DAG-CBOR record index as its
two roots. The record index uses canonical DAG-CBOR map ordering (key length,
then bytewise key order), and the commit MAC uses the proposal's direct
HKDF-Expand construction. Blobs are deliberately excluded; listBlobs
enumerates referenced CIDs and getBlob serves the authorized bytes.
The commit signature authenticates only (space, author, rev, ikm), not the
record-set hash. Anyone holding a commit can replace its records and recompute
the hash and MAC without the author's signing key. verifyRepoCarFull checks
the signed context and CAR consistency; callers must establish content
provenance separately, such as through an authenticated fetch from the author's
resolved repo host. Forwarded exports do not provide third-party evidence of
record authorship. This deniability is intentional in
proposal 0016.
LtHash also does not provide a compact record inclusion proof: subtracting an
arbitrary record's contribution and adding it back works even when the record
was never in the set. getRecord returns a URI, CID, and value, not an inclusion
proof. A secondary service serving selected records therefore needs its own
trust relationship with consumers or independent verification against the
source; it cannot use the repo commit as transferable proof of those records.
Zat first #
Before implementing local primitives, check Zat's current public API. ZDS uses
zat for syntax, TIDs, DID/handle resolution, JWT helpers, DAG-CBOR, CAR, MST,
repo verification, OAuth helpers, JSON helpers, and streaming clients.
Likely future Zat candidates:
SpaceUriparsing/formatting- LtHash and set commitment primitives
- proposal-shaped delegation-token and space-credential JWT helpers
Do not edit or patch the sibling zat repo from ZDS without stopping and
making the proposed Zat change explicit.
code boundaries #
Permissioned data is experimental code and should stay segmented:
- HTTP dispatch and XRPC semantics live in
src/atproto/space.zig. - Experimental protocol helpers live in
src/internal/permissioned_data.zig. - The resident private-record browser lives in the
/account/spacessection rendered bysrc/internal/account.zig. - OAuth scope parsing for
space:scopes lives insrc/internal/scopes.zig. - Persistent state lives in permissioned-data tables in
src/storage/store.zig. - API-reference surfacing is marked experimental through the
spacerouter group andx-zds-experimental.
Normal repo writes, sync events, account auth, and appview proxy behavior should not learn permissioned-data special cases unless the protocol requires a shared primitive. Prefer a permissioned-data helper over scattering conditionals into stable PDS code.
October 1 upgrade #
Startup creates the revocation, sequence, and notification tables and backfills
existing writer rows with ordered space revisions. Existing records, blobs,
policies, and memberships retain their format. Old DID-based writer cursors
must be discarded; clients bootstrap again from listRepos without a cursor.
Clients must reacquire credentials and use HTTP signatures. The old Spaces
DPoP scheme and type request field are not compatibility paths.
The local consumers requiring follow-up are plyr.fm's Python client, Doodl's browser/live/preview services, and Lore's extension and website. Doodl and Lore also need the prior release's policy split and all three still have membership callers to update. These are the downstream migration requirements for v0.4.0; ZDS does not translate the previous wire contract for old clients.
The optional SDK interop lane runs against an externally installed October 1
@atproto/space snapshot:
ZDS_SPACE_SDK_DIR=/path/to/sdk-test-project just smoke-permissioned
That project must contain node_modules/@atproto/space and its dependencies.
Pin @atproto/lex-cbor and @atproto/lex-data to the same snapshot if npm
resolves their prerelease ranges to the incompatible stable 0.0.0 packages.
checks #
Run both smoke lanes when touching this area:
just test
just smoke
just smoke-permissioned
git diff --check
zig zen
October 1 release verification #
The implementation follows the October 1 migration prompt and pinned reference
implementation described above. HTTP signature parsing remains local to ZDS;
DID-key decoding and JOSE verification use zat. The published October 1 SDK
passes bidirectional signature interoperability checks, including unpadded
base64, reusable proofs, high-S signatures, and repository audience isolation.
Five alternating local ReleaseFast storage runs against the preceding committed HEAD, with 1,000 seeded records, showed median throughput changes of +2.5% for writes, +0.6% for single-record reads, -2.9% for record listings, -1.5% for repo exports, and -4.7% for writer listings. These are synthetic storage measurements. A separate ReleaseSafe HTTP probe completed 4,000 credential reads without failure; at 16 concurrent callers it measured about 5,800 requests/second and 4.3 ms p95 latency. It is not a sustained production soak.
Cross-PDS performance comparison remains a follow-up: the existing official PDS and Tranquil benchmark probes cover public repos, not equivalent Spaces work. The SDK interop lane establishes wire compatibility, not performance parity. The current CAR/commit format remains intentionally unchanged until the future alpha format migration; downstream application migrations are separate work.