atproto pds in zig pds.zat.dev
zds docs permissioned-data.md
36 kB · 667 lines
Markdown
at main

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 #

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 methods createSpace, getSpace, updateSpace, and deleteSpace; the account-migration methods exportSpaceMeta, importSpaceMeta, and importRepo; 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 notifications
  • com.atproto.memberspace.*: putMember, removeMember, and listMembers
  • com.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, optional allowedApps, publishPolicy, and publishMembers, all booleans default false. Otherwise members' read and write flags decide.
  • com.atproto.managedspace.policy: managingApp (a DID with an optional service fragment) decides every read and write; optional publishPolicy.

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:

  • getMembers
  • getMemberState
  • getMemberOplog
  • notifyMembership
  • com.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. A 200 here means it said authorized: false or omitted the field; 401 means 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:web and deliver checkUserAccess to 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 aud comparison against the bare DID on the app's checkUserAccess route would produce exactly the reported symptom. It is not confirmed: the same app accepted forwarded notifyWrite calls, whose aud has 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 normalized policy_json on spaces this host is the authority for
  • permissioned_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 export
  • permissioned_space_repos: repo-host state, including the full LtHash state needed to update and sign that writer's local permissioned repo
  • permissioned_space_writers: authority-host writer registry, containing only each known writer's latest repo revision, space revision, and 32-byte commit digest
  • permissioned_space_record_oplog: incremental record changes by (space, repo_did, rev, idx)
  • permissioned_space_notify_registrations: expiring service-identified write-notification subscriptions
  • permissioned_space_sequence: persistent last revision for each authority space
  • permissioned_space_notify_outbox: durable, coalesced repo-host notifications
  • permissioned_space_revocations: expiring (space, jti) revocations
  • permissioned_space_used_delegations: consumed one-use delegation-token IDs
  • permissioned_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_kem key 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 by data
  • getRecord uses type atproto-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:

  • SpaceUri parsing/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 in src/internal/space_mac.zig.
  • The resident private-record browser lives in the /account/spaces section rendered by src/internal/account.zig.
  • OAuth scope parsing for space: scopes lives in src/internal/scopes.zig.
  • Persistent state lives in permissioned-data tables in src/storage/store.zig.
  • API-reference surfacing is marked experimental through the space router group and x-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 getSpaceCredential as a JSON body with credentialKid and delegationToken, and stop signing the credential request
  • present credentials as Atproto-Space-Credential and sign under the atproto-space-credential label with the matching tag
  • read getRepo as STAR rather than CAR, and stop passing excludeValues
  • name the authority as #atproto_pds in 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_kem key and send Atproto-Space-Want-Mac on getLatestCommit, listRepoOps, getRepo, and getRecord
  • verify Atproto-Space-Mac against the shared secret instead of a commit signature
  • read commits as {ver, repo, space, rev, data}; hash, mac, ikm, and sig are gone, in JSON and in the STAR metadata
  • treat getLatestCommit on a repo with no writes as RepoNotFound; 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.