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; the October 29
release replaces its baseline simplespace surface with a single space
policy. ZDS targets the 2026-10-29 Spaces alpha: the permissioned-data
branch at atproto commit 5d88b414a, published as
@atproto/space@0.0.0-spaces-alpha-20261029171913. The account-migration
methods (exportSpaceMeta, importSpaceMeta, space.importRepo) arrived in
v0.7.1; see the upgrade guide. Use the pinned branch 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.*: the lifecycle methodscreateSpace,getSpace,updateSpace, anddeleteSpace; the account-migration methodsexportSpaceMeta,importSpaceMeta, andimportRepo;listSpaces,listRepos,removeWriter,getDelegationToken,getSpaceCredential, records,clearRecords, blobs, MAC'd commits, full and incremental repo sync, blob enumeration, notification registration and removal, write notifications, credential revocation, and deletion notificationscom.atproto.memberspace.*:putMember,removeMember, andlistMemberscom.atproto.managedspace.checkUserAccess: the managing-app hook
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?, policy}. The policy is an open union discriminated by
$type:
com.atproto.personalspace.policy: only the authority reads or writes.com.atproto.memberspace.policy:publicRead,publicWrite, optionalallowedApps,publishPolicy, andpublishMembers, all booleans default false. Otherwise members'readandwriteflags decide.com.atproto.managedspace.policy:managingApp(a DID with an optional service fragment) decides every read and write; optionalpublishPolicy.
ZDS stores the policy with defaults filled and unknown fields dropped, and
answers any other $type with UnsupportedPolicy. getSpace returns
{uri, policy} to the owner only, over OAuth with read_self or a session.
updateSpace replaces the policy wholesale; a new $type deletes the previous
policy's state, so a member list does not survive a round trip through another
policy. The member methods answer PolicyMismatch outside a memberspace. See the
October 29 upgrade guide.
With publishPolicy, the authority's own repo in the space holds
com.atproto.space.policy/self {policy}; with publishMembers, it holds one
com.atproto.memberspace.member/<did> {read, write} per member. They are
written in the same transaction as the configuration change, advance the repo,
and send notifyWrite; clearing a flag deletes them. A did:web member's
percent-encoded port is keyed unencoded (did:web:localhost%3A2583 becomes
did:web:localhost:2583). Direct createRecord, putRecord, deleteRecord,
and applyWrites to either collection are refused with 400 in any repo.
removeWriter drops an account from the writer set: it leaves listRepos
until its next authorized write. removeMember and putMember with
write: false do the same. updateSpace does not re-evaluate the writer set.
clearRecords deletes every record in the caller's repo in one transaction and
advances it to a new rev with an empty oplog; the empty repo's digest is
e5a00aa9…b183ad. The authority's repo keeps its published records. Under
OAuth the caller needs a delete grant for each other collection it holds;
clearing an empty repo changes nothing.
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 commit instead. listRepos uses the
same default and maximum, but orders latest writer states by spaceRev.
Each entry contains did, repoRev, data, 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.updateSpaceConfig- every
com.atproto.simplespace.*method
getSpace and listSpaces do not return members or isMember. A space
credential does not reach getSpace; syncers read a published policy record.
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 space alone; an app that needs tiered write access hosts the
space itself or splits tiers across spaces, as Habitat does.
The member list is stored only as memberspace policy state. It is not
exposed as a protocol sync surface except through records the policy chooses to
publish. ZDS checks a 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. For managedspace, the
authority calls the managing app's com.atproto.managedspace.checkUserAccess
with access=read or access=write and service auth (lxm is the same NSID).
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 decision. Under
every policy the authority itself is authorized without asking anyone.
A managing-app denial reaches the client as SpaceNotFound whatever the
cause, so the cause is in the server log (since v0.5.0):
space managing app unresolved … err=…: the app's DID or service fragment did not resolve to an endpoint.space managing app check failed … err=…: the request to the app failed in transport.space managing app denied … status=…: the app answered. A200here means it saidauthorized: falseor omitted the field;401means it rejected the service-auth token, which an app that logs only inside its handler will report as never having received the request.
The service-auth token's aud is the managingApp value exactly as
configured, service fragment included (did:web:app.example#svc), as in the
reference PDS. An app that compares aud with its bare DID rejects every
check before its handler runs. With verifyJwt from @atproto/xrpc-server
0.13.4, a zds checkUserAccess token verifies when the app passes its full
service id as ownDid and fails with BadJwtAudience when it passes the bare
DID.
One denial predates that logging and is not explained: on 2026-10-05 a
managing-app space on pds.zat.dev (v0.4.1) refused a requester hosted on
another PDS, and the app's operator saw no checkUserAccess arrive. What was
established afterwards, without the operator:
- v0.4.1 and v0.5.0 builds both resolve that app's
did:weband delivercheckUserAccessto it, so resolution, request shape, and transport are not at fault in either version. - With the authority on one local zds, the requester on a second, and a managing app that verifies the token's signature against the authority's DID document, v0.5.0 issues the credential. The cross-PDS path and the token are valid.
- An
audcomparison against the bare DID on the app'scheckUserAccessroute would produce exactly the reported symptom. It is not confirmed: the same app accepted forwardednotifyWritecalls, whoseaudhas the same form.
The stored policy row for that space was not read.
Delegation tokens identify the user, not the app. For allowedApps, ZDS
verifies the separately supplied clientAttestation against the client's
published metadata and JWKS before using its client_id. getSpaceCredential
takes everything in its JSON body: space, a P-256 did:key as
credentialKid, and optionally delegationToken and clientAttestation. The
request carries no authorization header or signature. The user is checked
before the app: a user the policy refuses gets SpaceNotFound (400), and only
then can allowedApps answer AppNotAuthorized (400). Without a delegation
token, only a memberspace with publicRead issues a credential, still subject
to allowedApps; every other space answers SpaceNotFound. The credential binds credentialKid in
cnf.kid and, when attested, records client_id. Credential uses send
Authorization: Atproto-Space-Credential ... and sign authorization plus
atproto-space-audience under the atproto-space-credential label with
tag="atproto-space-credential"; keyid is optional and must match.
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.
Delegation tokens and client attestations name the authority as
<did>#atproto_pds, and space credentials are signed only with its #atproto
key.
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_pds 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.
notifySpaceDeleted is the exception: deleting a space queues one notification
per live registration, retried with the same backoff until accepted or until
the registration would have expired.
The space host is the authority's PDS. The October 21 alpha dropped the
dedicated #atproto_space_host service, so ZDS resolves #atproto_pds when
notifying a remote authority.
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 |
listMembers (then simplespace) 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 space rows keyed by space URI, with the normalizedpolicy_jsonon spaces this host is the authority forpermissioned_space_actor_state: actor-local authority/deleted state keyed by(space, actor_did)memberspace_members: memberspace member-list 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 memberspace 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 spaces. 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. It returns a
STAR-lite v1 archive (application/octet-stream): magic 2a 6c 01 00, DRISL
metadata holding $type: "atproto-space-repo" and the commit, then one record
path / record DRISL pair per record in strict bytewise path order. Space
records are limited to 1 MiB of DRISL. Blobs are deliberately excluded;
listBlobs enumerates referenced CIDs and getBlob serves the authorized
bytes.
commit MACs #
A commit (com.atproto.space.defs#commit) is {ver, repo, space, rev, data},
where data is the SHA-256 digest of the repo's LtHash state. It carries no
signature. The repo host authenticates it to each syncer with a MAC keyed by a
per-request shared secret:
- every account has a P-256
#atproto_kemkey in its DID document, generated at account creation and published in the PLC genesis operation - the syncer sends
Atproto-Space-Want-Mac: type="atproto-space-repo", ver=1, key_share="did:key:..."with a fresh ephemeral P-256 key per request - the host derives the shared secret with DHKEM(P-256, HKDF-SHA256) from RFC
9180 §4.1 and answers
Atproto-Space-Mac: type="atproto-space-repo", ver=1, mac=:<base64>:, where the MAC is HMAC-SHA256 over"atproto-space-repo-v1"and the length-prefixed space, repo, and rev, followed bydata getRecorduses typeatproto-space-record, MACing the length-prefixed record URI and the record CID's bytes
getLatestCommit, listRepoOps, getRepo, and getRecord require the header
when called with a space credential and answer 400 BadSpaceMac when it is
missing or malformed, names another type or version, or carries a key share
that is not a P-256 did:key. An account reading its own repo over OAuth may
omit it. These responses send Vary: atproto-space-want-mac and expose
atproto-space-mac to browsers. listRepoOps pages without a commit carry no
MAC; their ops are checked when the final page's commit matches the syncer's
running set hash. listRecords, getBlob, and listBlobs carry no MAC.
The MAC shows the host vouched for the commit to whoever holds the shared
secret, and to nobody else: the syncer could compute the same MAC, so a leaked
response proves nothing to a third party. Anyone holding a shared secret can
substitute records and MAC the forgery. verifyRepoStarFull checks the MAC
and archive consistency; content provenance still comes from the authenticated
fetch from the author's resolved repo host.
Accounts created before KEM keys existed get one from a background backfill
that runs with ZDS_PERMISSIONED_DATA=true, as do accounts created with a
caller-built PLC operation, which cannot carry a key the caller has never seen.
It rescans every ten minutes. For each account without a key,
it publishes atproto_kem through a PLC operation signed by the PDS rotation
key, stores the key, and emits an identity event. An account whose document
this PDS cannot update (did:web, a DID not on the directory, a rotation key the
PDS does not hold, or an account served elsewhere) still gets a key, logged so
its owner can publish it. Failures, such as an unreachable directory, are
retried after a minute, backing off to an hour. Until then, MAC'd reads
for that account fail with InternalServerError.
getRecommendedDidCredentials includes atproto_kem, and
submitPlcOperation rejects an operation that drops or changes it.
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 KEM, commit and record MACs, and their headers live insrc/internal/space_mac.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 external @atproto/space
build. Install the published snapshot with pnpm; npm rejects the snapshot's
workspace: dependency ranges:
pnpm add @atproto/space@0.0.0-spaces-alpha-20261022141228 \
@atproto/crypto@0.0.0-spaces-alpha-20261022141228 \
@atproto/lex-data@0.0.0-spaces-alpha-20261022141228 \
@atproto/lex-cbor@0.0.0-spaces-alpha-20261022141228
ZDS_SPACE_SDK_DIR=/path/to/sdk-test-project just smoke-permissioned
That project must contain node_modules/@atproto/space and its dependencies.
checks #
Run both smoke lanes when touching this area:
just test
just smoke
just smoke-permissioned
git diff --check
zig zen
October 21 upgrade #
This is a hard cut with no compatibility path; Spaces is alpha. Clients must:
- send
getSpaceCredentialas a JSON body withcredentialKidanddelegationToken, and stop signing the credential request - present credentials as
Atproto-Space-Credentialand sign under theatproto-space-credentiallabel with the matchingtag - read
getRepoas STAR rather than CAR, and stop passingexcludeValues - name the authority as
#atproto_pdsin delegation and service-auth audiences
The local consumers that mint credentials are plyr.fm's Python client, Doodl's
browser and sync worker, and Lore's extension and website. Stash and recite
use only self-owned spaces through OAuth and are unaffected. Clients can now
check describeServer.spacesAlpha instead of probing with an authenticated
request.
The interop lane passed against branch source 232b2cdce: body-carried
credential requests, tagged signatures including high-S and unpadded forms,
cross-repo audiences, and STAR export verified by the upstream verifier.
Three alternating local ReleaseFast runs of zds-bench --scenario space --records 1000 against the preceding release commit 6a1baad gave median
getRepo throughput of 643 ops/s for STAR against 587 ops/s for CAR, with the
archive about 30% smaller because it carries no CIDs or index. Full
verification measured 240 against 247 ops/s, since STAR verification derives
each record CID instead of reading it from the index. These are synthetic
storage measurements; no official PDS or Tranquil probe covers Spaces exports.
October 22 upgrade #
This is a hard cut with no compatibility path. Syncers holding a space credential must:
- resolve the author's
#atproto_kemkey and sendAtproto-Space-Want-MacongetLatestCommit,listRepoOps,getRepo, andgetRecord - verify
Atproto-Space-Macagainst the shared secret instead of a commit signature - read commits as
{ver, repo, space, rev, data};hash,mac,ikm, andsigare gone, in JSON and in the STAR metadata - treat
getLatestCommiton a repo with no writes asRepoNotFound; it previously answered{}
OAuth reads of the caller's own repo are unaffected unless they opt in to the header.
The interop lane passed against the published
0.0.0-spaces-alpha-20261022141228 snapshot: the SDK's encapsulate,
verifyCommit, verifyRecord, and verifyRepoStarFull accept ZDS responses
for all four reads. Unit tests pin the RFC 9180 §A.3.1 KEM vector, KEM, commit,
and record MAC vectors from that snapshot, and a STAR archive it serialized.
One known difference from the reference PDS at 3a0b8c6: ZDS answers
RepoNotFound with HTTP 404, as it does for every other not-found read, where
the reference throws InvalidRequestError (400). The error name is the same,
and clients key off the name.
October 29 upgrade #
The October 29 alpha replaces com.atproto.simplespace.* with a single space
policy. ZDS cuts over without a compatibility path; the
upgrade guide covers the client changes, the startup
migration, the pre-flight query, and where ZDS differs from the reference.
The interop lane passed against 0.0.0-spaces-alpha-20261029171913. That
snapshot's @atproto/lex-cbor range also resolves 0.0.0, which still names
workspace:* dependencies, so pin the @atproto packages with pnpm overrides
(pnpm 11 reads them from pnpm-workspace.yaml):
packages: []
overrides:
"@atproto/car": "0.0.0-spaces-alpha-20261029171913"
"@atproto/crypto": "0.0.0-spaces-alpha-20261029171913"
"@atproto/lex-cbor": "0.0.0-spaces-alpha-20261029171913"
"@atproto/lex-data": "0.0.0-spaces-alpha-20261029171913"
"@atproto/space": "0.0.0-spaces-alpha-20261029171913"
"@atproto/syntax": "0.0.0-spaces-alpha-20261029171913"
then pnpm add @atproto/space@0.0.0-spaces-alpha-20261029171913 @atproto/crypto@0.0.0-spaces-alpha-20261029171913 in that project. Beyond the
October 22 checks, the lane reads the authority's published records back with
readRepoStar and verifies a cleared repo with verifyRepoStarFull.
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. Downstream application migrations are separate work.