···7272zig zen
7373```
74747575+Also run `just smoke-permissioned` when touching `com.atproto.space.*`,
7676+permissioned-data storage, or the `/spaces` resident view.
7777+7578Use `just invite` for admin-minted invite codes. Assume `ZDS_ADMIN_TOKEN` is
7679set in the repo `.env`; source it before running the target, for example
7780`set -a; . ./.env; set +a; just invite`. Use `just bench ...` for local
-68
PLYR_OAUTH_PAR_HANDOFF.md
···11-# ZDS `/oauth/par` rejecting plyr.fm's permission-set includes → login broken on pds.zat.dev
22-33-**From:** plyr.fm side investigation (2026-06-09 ~01:20 UTC)
44-**Symptom:** signing into plyr.fm staging as a `pds.zat.dev` account (e.g. `bufo.uk`,
55-`did:plc:b64lsctzqnzpv6vd4ry3qktw`) fails. Accounts on *other* PDSes (e.g.
66-`zzstoatzz.io` on `pds.zzstoatzz.io`) sign in fine. So this is specific to ZDS,
77-and it is **new** — `bufo.uk` signed in successfully at 23:34 UTC, then broke.
88-Something changed ZDS-side since then (a redeploy?).
99-1010-## Where it fails
1111-1212-plyr's OAuth client (`atproto_oauth`) dies at the **PAR step** of
1313-`start_authorization`, *after* successfully fetching ZDS's `.well-known`
1414-metadata (both 200) and *before* a usable PAR response. On plyr's side the
1515-exception arrived with an empty `str()` (we just shipped a fix to log the real
1616-type/traceback — next failed login will show it precisely in our logfire).
1717-1818-## The ZDS-side lead (your logs)
1919-2020-`fly logs -a zds-pds` shows, for plyr's client:
2121-2222-```
2323-error oauth validate include failed scope=include:fm.plyr.stg.privateMedia err=InvalidPermissionSet
2424-error oauth authorize scope_html failed client=https://api-stg.plyr.fm/oauth-client-metadata.json \
2525- scope=atproto blob:*/* include:fm.plyr.stg.authFullApp include:fm.plyr.stg.privateMedia \
2626- err=InvalidPermissionSet
2727-```
2828-2929-So ZDS is rejecting plyr's permission-set `include:` scopes with
3030-**`InvalidPermissionSet`**. plyr's *current* (post-revert) login requests only:
3131-3232-```
3333-atproto blob:*/* include:fm.plyr.stg.authFullApp
3434-```
3535-3636-If ZDS now fails to resolve **`include:fm.plyr.stg.authFullApp`** the same way it
3737-failed `privateMedia`, PAR aborts and login dies.
3838-3939-## What to check
4040-4141-1. Resolve `include:fm.plyr.stg.authFullApp` (and `include:fm.plyr.stg.privateMedia`)
4242- from ZDS right now — why `InvalidPermissionSet`? The permission-set records are
4343- published as `com.atproto.lexicon.schema` on the **plyr.fm** account
4444- `did:plc:vs3hnzq2daqbszxlysywzy54` (PDS `chalciporus.us-west.host.bsky.network`),
4545- resolved via DNS `_lexicon.stg.plyr.fm` TXT → that DID. Confirm ZDS can still
4646- fetch + parse those records (DNS, DID resolution, getRecord, `buildExpandedScope`).
4747- This worked earlier today, so suspect a regression in the last ZDS deploy.
4848-4949-2. `/oauth/par` returns **HTTP 500** on bad input rather than a clean 4xx OAuth
5050- error. Example (my probe, missing client assertion):
5151- `error oauth client_auth rejected missing_assertion_type` → `failed to serve
5252- /oauth/par: MissingFormField` → 500. A 500 (especially with an empty/aborted
5353- body) surfaces on the client as an opaque `httpx.RemoteProtocolError`. Returning
5454- a proper `{"error": "...", "error_description": "..."}` 4xx would make failures
5555- debuggable on both sides.
5656-5757-## Please ignore in the logs
5858-5959-My manual `/oauth/par` probes around **01:19–01:20 UTC** (`client=test`, and
6060-`client=...plyr...` *without* a client assertion) are mine, not plyr's — they
6161-lack the confidential-client assertion and are not representative. plyr always
6262-sends `client_assertion` (private_key_jwt).
6363-6464-## To reproduce faithfully
6565-6666-Have a `pds.zat.dev` account sign into `https://stg.plyr.fm` and watch the
6767-`/oauth/par` handler + permission-set resolution for
6868-`client=https://api-stg.plyr.fm/oauth-client-metadata.json`.
+4
README.md
···3737- [operations](docs/operations.md)
3838- [invite codes](docs/invite-codes.md)
3939- [passkeys](docs/passkeys.md)
4040+- [permissioned data](docs/permissioned-data.md)
4041- [references](docs/references.md)
4142- [benchmarks](bench/README.md)
4243···128129 credential IDs, public keys, counters, names, and last-use timestamps.
129130- `/security` is the local account-security page for passkey and app-password
130131 management.
132132+- `com.atproto.space.*` permissioned-data routes are experimental and operator
133133+ gated with `ZDS_PERMISSIONED_DATA`. ZDS implements the storage and credential
134134+ substrate, while reader/group semantics are left to applications.
131135132136## references
133137
+6-6
docs/benchmarking.md
···9999100100Permissioned-data measurements should stay separate from the existing repo,
101101blob, sync, and HTTP route matrices. The experimental `com.atproto.space.*`
102102-surface has its own storage model, actor-local space state, LtHash-backed
103103-member/record state, oplogs, credentials, and ranged blob reads, so mixing it
104104-into the public repo tables would hide the actual access pattern.
102102+surface has its own storage model, actor-local space state, writer repo state,
103103+record oplogs, credentials, and ranged blob reads, so mixing it into the public
104104+repo tables would hide the actual access pattern.
105105106106The first benchmark slice should be small and diagnostic rather than exhaustive:
107107108108-- seed one self-owned private space and one member repo.
109109-- measure `createSpace`, member add/remove, record write, record read,
110110- `listRecords`, ranged `getBlob`, and oplog catch-up separately.
108108+- seed one self-owned private space and one writer repo.
109109+- measure `createSpace`, record write, record read, `listRecords`, ranged
110110+ `getBlob`, and writer repo oplog catch-up separately.
111111- report p50/p95/p99/max for the concurrent paths, but keep write, read, blob,
112112 and oplog rows separate.
113113- compare to Daniel's permissioned-data branch only after we can run that PDS
+5-3
docs/operations.md
···8181- `ZDS_PERMISSIONED_DATA`: set to `true` to opt into experimental
8282 `com.atproto.space.*` permissioned-data routes. Default: disabled. The
8383 current implementation is intended to track the upstream experimental
8484- permissioned-data branch, including space lifecycle, membership, private
8585- records, grants, credentials, oplogs, ranged blob reads, and notify routes.
8686- Use `just smoke-permissioned` for the experimental smoke lane.
8484+ permissioned-data work while following the newer direction that removes
8585+ protocol member lists. ZDS provides space lifecycle, private records, grants,
8686+ credentials, writer repo state/oplogs, ranged blob reads, and notify routes.
8787+ Reader/group semantics are application policy. Use `just smoke-permissioned`
8888+ for the experimental smoke lane.
8789- Passkeys do not require a separate deployment secret. WebAuthn RP ID is
8890 derived from the public URL host.
8991
+81-92
docs/permissioned-data.md
···11-# permissioned data plan
11+# permissioned data
2233-Permissioned data support is experimental. ZDS should ship it behind an
44-operator opt-in flag, off by default, before any `com.atproto.space.*` route is
55-publicly reachable.
66-77-Planned flag:
33+Permissioned data support is experimental and operator gated. ZDS only exposes
44+`com.atproto.space.*` when explicitly enabled:
8596```sh
107ZDS_PERMISSIONED_DATA=true
118```
1291313-Until that flag is enabled, ZDS should reject permissioned-data routes as
1414-unimplemented and should not create or mutate permissioned-space rows.
1515-1616-When enabled, ZDS should expose the full experimental
1717-`com.atproto.space.*` surface described by the current permissioned-data
1818-lexicons. Downstream apps should treat the namespace as experimental protocol
1919-support, not as a plyr.fm-specific private-media slice.
1010+When the flag is off, permissioned-data routes stay dark and should not create
1111+or mutate permissioned-space rows. When the flag is on, the API reference marks
1212+the space routes with `x-zds-experimental: true`.
20132114## references
22152316- Discourse: <https://discourse.atprotocol.community/t/permissioned-data-pds-lexicons/879>
1717+- Member-list direction:
1818+ <https://discourse.atprotocol.community/t/removing-the-member-list/895>
2419- Branch: <https://github.com/bluesky-social/atproto/tree/permissioned-data>
2520- Lexicons:
2621 <https://github.com/bluesky-social/atproto/tree/permissioned-data/lexicons/com/atproto/space>
2722- Reference implementation path:
2823 `packages/pds/src/actor-store/space` on the permissioned-data branch
29243030-The Discourse post rates the lexicon shape higher than the implementation. Use
3131-the lexicons as the main contract, and use the branch implementation to
3232-understand storage and flow rather than copying it blindly.
2525+The upstream design is still moving. The May branch included protocol-level
2626+member lists. The later discussion is moving toward making space credentials
2727+the protocol substrate and leaving reader/group semantics to applications or
2828+space-host policy. ZDS follows that newer direction because this deployment has
2929+no external production users, so subtractive experimental changes are cheaper
3030+than carrying compatibility for a shape we no longer want.
33313434-Feedback should respect the research already behind the sketch and avoid
3535-overfitting to ZDS's idiosyncratic needs. The protocol is trying to cover likely
3636-use cases from large shared spaces down to small contextual ones, while leaving
3737-substantial application-specific authorization and UX in user land. ZDS feedback
3838-should name concrete implementation pressure, but frame it as design input for
3939-that broader scope.
3232+Feedback should respect the research behind the sketch and avoid overfitting to
3333+ZDS or plyr.fm. Name concrete implementation pressure, but frame it as input for
3434+the broader protocol shape.
40354141-## surface
3636+## current surface
42374343-The branch adds these `com.atproto.space.*` methods:
3838+ZDS currently keeps the permissioned-data substrate:
44394540- space lifecycle: `createSpace`, `getSpace`, `listSpaces`,
4641 `updateSpaceConfig`, `deleteSpace`
4747-- membership: `addMember`, `removeMember`, `getMembers`, `getMemberState`,
4848- `getMemberOplog`, `getMemberGrant`, `notifyMembership`
4949-- records: `createRecord`, `putRecord`, `deleteRecord`, `applyWrites`,
5050- `getRecord`, `listRecords`, `getBlob`, `getRepoState`, `getRepoOplog`,
5151- `notifyWrite`
5252-- credentials and deletion fanout: `getSpaceCredential`, `notifySpaceDeleted`
4242+- records and blobs: `createRecord`, `putRecord`, `deleteRecord`,
4343+ `applyWrites`, `getRecord`, `listRecords`, `getBlob`
4444+- writer state: `getRepoState`, `getRepoOplog`, `notifyWrite`
4545+- credentials and deletion fanout: `getMemberGrant`, `getSpaceCredential`,
4646+ `notifySpaceDeleted`
4747+4848+ZDS deliberately does not expose protocol member-list routes:
4949+5050+- `addMember`
5151+- `removeMember`
5252+- `getMembers`
5353+- `getMemberState`
5454+- `getMemberOplog`
5555+- `notifyMembership`
5656+5757+`getSpace` and `listSpaces` do not return `members` or `isMember`.
5858+5959+## access model
6060+6161+ZDS treats permissioned data as a space credential and writer-repo substrate,
6262+not as a universal group-membership system.
6363+6464+Applications own reader/group semantics: label rosters, supporter access,
6565+personal private libraries, community roles, follower-only access, and similar
6666+policy all belong above the PDS substrate. If an application needs portable
6767+access state, it should model that state as application records in a
6868+permissioned space rather than relying on a PDS-wide member list.
6969+7070+For private spaces, ZDS currently mints space credentials only to the space
7171+owner DID unless the space is public. That is the conservative default until
7272+the protocol settles on an application or space-host policy hook for broader
7373+credential issuance.
53745454-This is not a thin wrapper around public repo writes. It is a separate
5555-space-scoped data model with its own read perimeter, write notifications,
5656-member state, record state, oplogs, and signed set commitments.
7575+Writer notifications are keyed by writer repo. `notifyWrite` verifies service
7676+auth from the writer repo to the space DID, then fans out to registered
7777+credential recipients.
57785879## storage shape
59806060-The reference branch stores these tables inside each actor store:
8181+ZDS stores many actor repos in one SQLite database, so permissioned data uses
8282+explicit space-scoped tables instead of the public repo tables:
61836262-- `space`: URI, owner/member flags, app access policy, creation/deletion
6363- timestamps
6464-- `space_member`: member DIDs and member revision
6565-- `space_record`: current records by `(space, collection, rkey)` with CID,
6666- DAG-CBOR value, repo revision, and indexed timestamp
6767-- `space_repo`: record set hash and current repo revision
6868-- `space_member_state`: member set hash and current member revision
6969-- `space_record_oplog`: incremental record changes by `(space, rev, idx)`
7070-- `space_member_oplog`: incremental membership changes by `(space, rev, idx)`
7171-- `space_credential_recipient`: registered services to notify for writes
8484+- `permissioned_spaces`: canonical owner-space config keyed by space URI
8585+- `permissioned_space_actor_state`: actor-local owner/deleted state keyed by
8686+ `(space, actor_did)`
8787+- `permissioned_space_records`: current records keyed by
8888+ `(space, repo_did, collection, rkey)` with CID, DAG-CBOR value, repo revision,
8989+ and indexed timestamp
9090+- `permissioned_space_repos`: writer repo state and current record-set hash
9191+- `permissioned_space_record_oplog`: incremental record changes by
9292+ `(space, repo_did, rev, idx)`
9393+- `permissioned_space_credentials`: short-lived grants and credentials
9494+- `permissioned_space_credential_recipients`: services to notify for writes and
9595+ space deletion
72967373-ZDS has one public repo namespace per DID: `records`, `repo_blocks`,
7474-`commits`, `seq_events`, `blobs`, plus account/auth state. Permissioned data
7575-must not be squeezed into those public repo tables. It uses space-scoped
7676-storage in `src/storage/store.zig` and route behavior in
7777-`src/atproto/space.zig`.
9797+Permissioned records and blobs are not public repo records. They must not be
9898+squeezed into `records`, `repo_blocks`, `commits`, or `seq_events`.
78997979-Because ZDS stores many actor repos in one SQLite database instead of one
8080-actor-store per DID, it must preserve the same actor-local semantics explicitly:
8181-8282-- canonical space config lives in `permissioned_spaces`, keyed by space URI
8383-- actor-local owner/member/deleted state lives in
8484- `permissioned_space_actor_state`, keyed by `(space, actor_did)`
8585-- member-list state remains space-owner state
8686-- record state and oplogs carry the member `repo_did` explicitly
100100+Existing databases from the earlier experiment drop the dedicated
101101+permissioned-space member tables during migration:
871028888-Do not collapse actor-local space state back into a single global row. The same
8989-space URI can legitimately have a different local view for different actors on
9090-the same ZDS process.
103103+- `permissioned_space_members`
104104+- `permissioned_space_member_state`
105105+- `permissioned_space_member_oplog`
9110692107## Zat first
931089494-Before implementing local primitives, check Zat's current public API. Recent
9595-Zat work is relevant:
9696-9797-- `zat.mst.Mst` has the performance-oriented middle layer ZDS already uses:
9898- lazy CID stubs, cached clean-node CIDs, direct DAG-CBOR node serialization,
9999- borrowed-key insertion, `collectBlocks`, and ordered `walk`.
100100-- `zat.car.streamBlocks` supports zero-allocation CAR iteration for large repo
101101- workflows.
102102-- `zat.cbor` owns DAG-CBOR values, CIDs, encoding, and parsing helpers.
103103-- `zat.Tid`, `zat.Did`, `zat.Nsid`, `zat.Rkey`, and `zat.AtUri` own syntax
104104- parsing and formatting for standard AT Protocol identifiers.
105105-- `zat.Keypair`, `zat.jwt`, `verifyJose`, and strict repo verification helpers
106106- keep the JOSE-vs-repo-signature distinction explicit.
107107-- `zat.oauth` owns OAuth client/primitive helpers, while ZDS owns PDS policy,
108108- stored grants, and route behavior.
109109+Before implementing local primitives, check Zat's current public API. ZDS uses
110110+`zat` for syntax, TIDs, DID/handle resolution, JWT helpers, DAG-CBOR, CAR, MST,
111111+repo verification, OAuth helpers, JSON helpers, and streaming clients.
109112110113Likely future Zat candidates:
111114···116119Do not edit or patch the sibling `zat` repo from ZDS without stopping and
117120making the proposed Zat change explicit.
118121119119-## implementation milestones
120120-121121-Current implementation notes:
122122-123123-1. Permissioned data remains behind `ZDS_PERMISSIONED_DATA`.
124124-2. Spaces, members, member/repo LtHash state, record oplogs, member oplogs,
125125- grants, owner-signed space credentials, credential-gated reads, ranged blob
126126- reads, and best-effort notify fanout live in ZDS.
127127-3. The current branch is still experimental because the upstream proposal is
128128- experimental. Do not narrow future work to one downstream app's needs unless
129129- the protocol shape itself narrows.
130130-131131-Each step should keep permissioned data dark unless `ZDS_PERMISSIONED_DATA` is
132132-enabled.
133133-134122## code boundaries
135123136124Permissioned data is experimental code and should stay segmented:
137125138126- HTTP dispatch and XRPC semantics live in `src/atproto/space.zig`.
139139-- Experimental protocol primitives live in
140140- `src/internal/permissioned_data.zig`.
127127+- Experimental protocol helpers live in `src/internal/permissioned_data.zig`.
128128+- The resident private-record browser lives in
129129+ `src/internal/private_spaces.zig`.
141130- OAuth scope parsing for `space:` scopes lives in `src/internal/scopes.zig`.
142131- Persistent state lives in permissioned-data tables in
143132 `src/storage/store.zig`.