Daniel Holmgren spent a series of posts building up a proposal for permissioned data on atproto, and it's now an actual draft spec. Here's the system, the way I'd want it explained. Context first, then the parts, with diagrams you can watch move.
Before the new thing makes sense, the old thing has to be in your head. Public atproto, in a sentence: users own their data, it lives on their PDS, and applications crawl PDSes to build views. That's the loop. Authority lives at the user's DID. Records get broadcast over a firehose. Anyone can read, anyone can index, anyone can build a competing app over the same data.
Permissioned data has to fit inside that worldview without breaking it. The whole exercise is: how do we add "not public" without abandoning the model?
Hold this in your head. Every design decision in the next four posts is judged against whether it preserves this shape. Daniel calls that fidelity "the atproto ethos."
The first decision is the easiest to explain and the easiest to misunderstand. People hear "private data" and think "encryption." Daniel's first move is to separate two things people conflate.
Threat model: the server itself is the adversary. Government subpoenas. Intercepted traffic. Compromised infrastructure. Only the endpoints should be able to read the bytes. Think iMessage, Signal, WhatsApp.
Threat model: some users should see this, others shouldn't. The server is trusted to enforce who's in. Think private subreddits, Patreon, Facebook groups, paid newsletters. The host reads the data — that's required to provide search, notifications, "trending in this community."
The metaphor: encryption is a sealed envelope. Access control is a velvet rope outside a club. Both keep people out. They're solving different problems and people inside the rope still need the bouncer to see them to recognize them.
Then the practical case: E2EE at scale is brutal. The MLS group-key protocol caps out around 50k members in theory, much lower in practice. Real humans lose phones, forget passwords, refuse to manage keys. Every client developer in an open ecosystem would inherit that complexity. And — the killer — apps would be unable to build the indexes that make modern social media work, because they can't see the data.
Permissioned data and E2EE aren't competing approaches — they operate at different layers. Apps that need confidentiality can still layer E2EE on top.
Decision: build for access control, leave the cryptographic envelope for individual apps to add when they need it.
If we're doing access control instead of encryption, the next question is: what is the access control attached to?
Daniel walks through two failed attempts before landing on the answer. Both failures are instructive — they show you why the answer has the shape it does.
Idea: define an NSID like com.atmoboards.forum. When a user grants an app OAuth access, it gets all of that user's content in that realm. The app applies access rules itself.
This works until two users in the same forum use different apps. Bob is on AtmoBoards, Carol is on ForumBrowser. Carol can't see Bob's post unless Bob individually grants ForumBrowser OAuth access — to all his AtmoBoards content across every forum. The boundary is too coarse. Niche apps die. Centralizing force.
Idea: every record has its own ACL. Maximum control. Maximum disaster when membership changes.
Per-record ACLs make every social interaction a coordination problem. Add a member? Every existing member must update every record they ever wrote in this group. Every interaction is a synchronization event.
What we actually want, says Daniel: a named place. One ACL on the place. Everything posted into it inherits that ACL. Add a member to the place, they can see everything in it. Remove them, they can't.
He calls it a bucket. (We'll fix that name in two posts.)
"Bucket" sounds like a thing. Like a container in a place. The third post is about resisting that intuition.
There are two physical models. They look superficially similar. They have very different consequences.
Colocated looks simpler. One source of truth. One sync target. One enforcement point. But Alice's PDS — which existed to host Alice's data — is suddenly hosting:
If Alice's PDS goes down, Bob can't post. He has no relationship with Alice's PDS. He's just a member of a forum. His app is broken because of someone else's infrastructure. That's not a protocol. That's a centralization machine wearing protocol clothes.
Partitioned says: the bucket is a logical abstraction. Each user keeps their own contributions on their own PDS, just like they always have for public data. The bucket is held together by all members agreeing on the same identity, the same ACL, the same sync boundary.
Bluesky threads work this way already. Each post lives on its author's PDS. The "thread" is constructed by the app, by virtue of the fact that every post points at the same root.
The cost is real: apps now have to talk to N PDSes instead of 1, sync state is harder, access control gets fiddly. But these are engineering problems. The colocated problems were architectural — broken trust assumptions, broken responsibility lines. You can't engineer your way out of "your PDS is now responsible for strangers."
Short post, important consequence. Daniel realized "bucket" implies a container that lives somewhere — exactly the colocated intuition he'd just spent a post fighting. The community pushed back. He ate it.
"Space" is intentionally vague about location. "Repo" reuses the existing atproto word and gives us a clean name for "the part of a space that lives on one user's PDS."
Now we can put it all together. Here's the big picture, with everything labeled.
app.bsky.group, com.atmoboards.forum, app.bsky.personal. This is the OAuth consent boundary — when you log into an app, you grant access by space type. (Realms came back, just at a different layer.)(DID, read|write). Lives at the space owner. Synced to apps over the same protocol as the repos themselves.ECMH instead of MST. The public protocol uses a Merkle Search Tree — every record verifiable, partial sync supported. The permissioned protocol uses an elliptic curve multiset hash. Adding a record? Single point operation. Removing one? Single point operation. Two repos with the same live records produce the same hash regardless of history.
HMAC-then-sign for deniability. When you read a permissioned repo, the commitment is authenticated with a randomly generated HMAC key — that key is what the user's atproto signing key actually signs. Each reader gets a different HMAC key. Result: even if a signature gets exposed, it doesn't prove the contents to a third party. Repudiability is a feature here, not a bug. This is the explicit anti-pattern Daniel called out earlier: he doesn't want permissioned data to be asymmetrically signed in a way that lets one reader rebroadcast it as proof.
Theory's done. Watch the actual sequence: ForumBrowser, a brand new app Carol just signed up for, wants to show her the Protocol Nerds forum. Hit play.
The remarkable thing about this sequence: Carol, Alice, Bob, and Dan never had to do anything to enable ForumBrowser specifically. Carol's OAuth into ForumBrowser is enough. The credential cascade does the rest. That's the property "realms" couldn't deliver, because it required every member to re-authorize every new app. The space-credential design is what lets the long tail of niche apps actually work.
Post 5 looks like a deep dive into URI structure. It is — but more importantly, it's where several earlier open questions get answered, because the URI is the place where the philosophy of the system has to commit to specific bytes on the wire.
Daniel half-jokes that if you can figure out the URI structure, the rest of the protocol writes itself. Not quite true, but the URI is where every design decision becomes concrete. Here's what they landed on:
ats:// instead of at://This isn't just bikeshedding. Three real reasons:
The honest framing: choosing a new scheme is admitting this is a new data and sync protocol, not an extension of the existing one.
This was the open question from post 4 — "is the authority the user DID or the space owner DID?" Post 5 settles it: the space DID is authoritative, and for shared spaces, that's usually not the user's DID.
Daniel walks through why a slug doesn't work (no resolver, no collision prevention), why a random nonce doesn't help (same problem), and why hanging spaces off the user's DID creates the Bluesky feeds problem:
For spaces, communities change hands all the time. Mods leave, ownership transfers, organizations evolve. Baking ownership into the space's identifier would recreate the feeds problem at scale.
So spaces get their own DID. But there's a clean rule for when:
For personal spaces: bookmarks, mutes, drafts, private posts, your newsletter. These are yours. They follow you. They will never belong to someone else. No reason to mint a new DID.
For shared spaces: anytime two or more people share a space and the space could ever change hands. Even 1:1 DMs qualify — one person might delete their account while the other wants to keep the thread.
The decision rule, from Daniel: "Could this space ever change hands?" If yes, separate DID. If no, user's DID is fine.
This implies a lightweight "controlled DID" system on the PDS so users can manage and transfer space DIDs. Daniel is explicit that this should stay narrow — not a generic managed-account system.
This is the NSID we already saw in post 4 — but post 5 articulates what it's for.
A space, abstractly, is just a perimeter. You can't tell what's inside without syncing it. That's a problem for OAuth: how do you write a consent screen for a space when you can't introspect it cheaply?
"Without a type, we're left saying 'do you want to give this app access to did:example:group/protocol-nerds, did:example:other/cat-pics, and did:example:yet-another/besties?'"
The type is what lets the consent screen instead say "do you want to give this app access to your AtmoBoards forums?" — by grouping all spaces of one type under one human-readable scope.
Daniel also explicitly rejects the comparison to Google+ Circles. Circles were multi-modality — one Circle attached to many kinds of content across the network. That accumulates the same complexity that killed per-record ACLs in post 2. A space is single-modality on purpose.
The skey is the part that lets multiple spaces share a single DID. Two cases where this matters:
Same mechanic as the record-key (rkey) you already know from public atproto. Arbitrary string. Might be human-readable, might be a TID, might be "self."
Here's the thing about the URI that's genuinely satisfying. A public atproto record is addressed by DID, collection, rkey. A space is addressed by DID, type, skey. They're the same shape. A permissioned record URI is just the space address followed by the record address. A record located in a space.
Daniel notes he originally wanted the author DID at the front (identity-based authority is the atproto ethos). But the more he built, the more it felt wrong — like "adding tomato to the fruit salad." The author only has authority to write inside a space because the space's member list granted them that. So the author's authority is downstream of the space's, and the URI reflects that.
Daniel titled this one "Boring Auth" on purpose. The post is the story of resisting the temptation to be clever about authorization. Six diaries in, the shape of the design looks less like an invention and more like a series of refusals to be clever.
Daniel opens by abstracting atproto down to three primitives: identities (DIDs), repos (the format identities use to publish), and lexicons (the schemas describing what gets published). Permissioned data adds exactly one new primitive: a space, which is a list of accounts bounded by an access perimeter.
The protocol's commitment about that space is exactly one thing: if your DID is on the list, you can read and sync everything in the space. Nothing else lives at the protocol layer. Roles, hierarchies, voting, vouching, tier-gating, follow-based access: all of it lives somewhere else.
The corollary is the more interesting move. Write access is not encoded in the protocol at all. Anyone can claim to write a record into a space. The protocol doesn't check. Indexing applications check the member list (and any application-level policy records) when deciding what to display.
This is the same pattern as Bluesky reply enforcement. Anyone can post a reply to any Bluesky post. The protocol doesn't stop you. Bluesky-the-app applies blocks, mutes, and threadgates when deciding which replies to show. Enforcement happens at read time, by indexers, rather than at write time, by the writer's PDS.
The member list is the narrow waist of the design. Like the IP hourglass in network design, complexity radiates outward in two directions while the protocol itself stays small.
Above the protocol: social and business logic. Records published in the space describe policy. "Members need 3 vouches before posting" becomes a policy record plus vouches as records, and the indexing app applies the rule when deciding what to display. Apps can compose multiple spaces too: a forum with a moderators-only space alongside the main forum, a Discord-like app with a separate space per channel.
Below the protocol: governance logic. Each space is governed by a DID, controlled by key material. The space host decides how that key material gets used. Most communities will be fine with a single-admin host. High-stakes communities can split the key with Shamir Secret Sharing so no single admin acts unilaterally.
What looked like one wristband in the previous chapter is actually three nested tokens. Each one has a specific job:
The user logs into an app. The app gets an OAuth credential, scoped to whichever spaces the user granted (by type, per the previous chapter on URIs). The app trades that OAuth credential at the user's PDS for a member grant: a one-time-use token bound to both the specific space and the app's client ID. The app presents the member grant to the space owner and receives a space credential: short-lived, asymmetrically signed, usable with any member PDS to sync that member's permissioned repo.
The asymmetric signature is what makes verification stateless. Any member PDS can check the credential against the space owner's known public key, no live coordination required.
If an app loses all its member OAuth sessions, it can't renew its space credential, and access naturally lapses. An app may also serve multiple users with read access to a space, in which case it can race multiple credential flows or pick whichever OAuth session is freshest.
The property that does the work: an app holding a space credential can read every member's permissioned repo for that space. The whole forum, not just the granting user's slice.
The natural reaction is "wait, is that ok?" Daniel says the team had the same reaction. The answer is yes, and the reasoning matters: how else would the app present the forum? If AtmoBoards could only see Alice's slice, it couldn't show Alice the forum at all. Just a Notes app's worth of her own posts.
The realm model from diary 2 required every member to individually authorize every app. Fine for two or three dominant apps. Slowly suffocating for everyone else. Tiny experimental apps could never reach the critical mass of individual authorizations needed to function.
The wristband design lets a single member grant access to any well-behaved app, and the app then gets enough data to be useful. That's how the network stays remixable across many apps.
"Well-behaved" isn't entirely up to the app. The space owner threads the requesting app's client ID through both the member grant and the space credential. This means the space owner can run a policy on which apps get credentials at all.
Most spaces will use default-allow plus a deny list: any app a member brings in gets in, unless the space owner has explicitly banned it. Privacy- or safety-sensitive spaces can flip this to default-deny plus an allow list: no app gets in unless explicitly approved. The extreme version is single-app pinning, where only one specific app can access the space, period.
The member PDS also has discretion. Even if the space owner signed a credential, a member PDS can decline to honor it. Both sides of the door have a say.
The implication that drops out of all this: a space owner can choose to give a credential to anyone who asks. A public permissioned space. Anyone can join, but the data is still distributed party-to-party rather than blasted onto the public firehose.
Why use this instead of public atproto? Different delivery model. Public atproto is for public broadcast: signed, redistributable, archival, on the record. Public permissioned spaces are still party-to-party in transport. Some content wants to be visible-on-request but not blasted to the world. And if a space might flip back and forth between public and private (a chat that becomes a published transcript later, say), staying within one protocol is easier than migrating between two.
Daniel co-wrote UCAN and originally prototyped atproto on it. He goes out of his way to explain why he didn't use it for permissioned data.
Capability-based auth is an acquired taste, and atproto already taxes newcomers with enough novel concepts; adding another would be costly. Revocation gets harder when the resource lives across every member's PDS: a revoked capability has to fan out to every writer, not just be torn off one server. Users want to authoritatively know who has access to their content, and capability models don't give you a materialized list to hold onto. And atproto is stateful, with content authentic when signed by the right author. UCAN is invocation-based, with validity checked at a point in time. UCANs could be stored as data, but they're bigger than signatures and would need continual refresh as they expire.
Worth flagging because UCAN is the closest spiritual neighbor to this design, and the most common alternative people propose.
Authorization is the hardest part. Daniel's team spent the most time on it, second-guessed it the most, and ended up back at the same conclusion every time: the simplest thing that could possibly work, with a clean place for apps to do their own thing on top. That's the design. Boring on purpose.
Between diaries 5 and 6, Daniel published a non-diary post working through a data-modeling question that shows up the moment people try to build communities on permissioned data. The short version: don't model a community as one big space. Model it as many spaces under one identity.
The obvious move is a universal "community space": one Lexicon, one space per community, holding every modality at once. Forum posts, microblogs, events, chat, photos, all in a single container. atproto gives you a universal data model, so why not model universal communities directly?
Daniel argues against it on two concrete grounds.
The space type is what lets you explain, in human words, what an app is asking for. "Give this app access to your AtmoBoards forums" works because a forum is a recognizable thing. "Give this app access to your Open Communities" grounds the request in nothing. Try to layer the two ("let this app create AtmoBoards posts in your Open Communities") and you are stitching text from two different Lexicon authors into one sentence and burying the user in variables. That isn't only a UX problem. It's a security problem: the more scopes and variables a consent screen carries, the more users skim, and skimmed consent is unsafe consent.
A space is an all-or-nothing access and sync boundary by design. Read access to a space is read access to everything in it. If a community is one universal container and you log into an event app to add a single event, that app now holds your chat, your photos, and your forum posts too. There's no protocol-level way to scope it down to "just events."
The fix leans on a primitive that already is universal: the DID. Every space sits under a DID, the space authority. So instead of one community equals one space, model a community as many spaces under one community DID. To add a new kind of content you don't widen a container. You create a new typed space under the same DID, usually with the same member list.
"Protocol Nerds" becomes a forum space, an events space, a chat space, and a video feed, all hanging off one community DID. Each keeps its own particular type, its own legible consent string, its own access perimeter. The universal community still exists. It just gets assembled in the app layer by grouping the spaces that share a community DID.
Capturing this in OAuth scopes splits client applications along two axes:
Modality-specific, granted by type. AtmoBoards does forums, across every forum of that type on the network. The OAuth resource reads "access to your AtmoBoards forums."
Community-specific, granted by authority. A "Protocol Nerds" client does many modalities, but only for one community. The OAuth resource reads "everything under the Protocol Nerds community."
For the by-authority grant to be legible, a bare DID won't do on a consent screen. The handle system already solves this for users, so give community DIDs handles too. Now the screen can say "read and write all @protocol-nerds.network spaces." Communities become things you can name, not just apps you log into.
The post is openly unfinished. Two problems he flags without solving:
A cross-modality scope that's hard to name. Some apps are high-cardinality on both axes. Picture the Bluesky app growing a "communities" feature: it surfaces only the Bluesky modality, but it also wants to act as the lobby, delivering notifications across every modality for the communities that show up in Bluesky. The logic is "if I'm a member of an app.bsky.group space, grant me the other spaces under that same authority that I'm also a member of." That's by-authority, triggered by-type, and it resists being written as a clean scope. His two candidate answers: surface it in the consent UX with an authority-picker (like the iOS photo picker, default-filled by the trigger logic), or own the limitation and require a pointer record in the Bluesky space, where the event itself lives in the event space and only an "event notification" envelope gets published into the Bluesky space.
Who runs the arbiter, and who hosts the community? If you spin up a community inside the Bluesky app, the only services you have a relationship with are your PDS and the app itself. The PDS is the natural host, but it will only ship the imperative space and member-list APIs, not a full arbiter. So either the arbiter runs statelessly, holding an OAuth credential for a PDS and steering spaces through it, or the arbiter becomes part of each app, and your communities scatter across every app you ever used to create one. Either way, migration between arbiters needs to be a protocol-level priority.
The diary was Daniel thinking out loud. There's now an actual draft proposal in the atproto proposals repo (PR #94, "permissioned data"), backed by a PDS implementation design known as the Spaces Design Spec. Where the two disagree, the spec wins: it's the authoritative source for mechanics, and the diary is now background reading. A few things that were hand-wavy in the diary got pinned down, and one or two diary speculations quietly died. The proposal also opens with its own warning that it's early and the names will move, so hold the vocabulary loosely.
This is the load-bearing fact, and it's easy to miss. A permissioned write fires no #commit event. It doesn't go on the public firehose at all. The firehose stays public-only, and relays are unaffected by permissioned data because they never see it.
So how does anyone learn a write happened? Push, then pull. The member's PDS sends a fire-and-forget notifyWrite to the space owner's PDS. The space owner's PDS looks up which apps are syncing that space and relays the notification to each. Each syncing app then pulls the actual operations with getRepoOplog against the member's PDS, presenting its space credential to get in. The push is a cheap nudge that says "something changed." The pull carries the data and is gated on the credential.
Public atproto records are signed so that anyone, forever, can prove the author wrote them. That's a feature for public broadcast and a liability for a private room. So permissioned commits use a different primitive: a set hash over the repo's contents, signed with a symmetric key shared inside the space (an HMAC) rather than the author's public signing key.
The effect: a member's app can verify, in the moment, that a write genuinely came from a fellow member. But a commit that leaks out of the space proves nothing to an outsider, because anyone in the space could have produced it. Authenticated inside, deniable outside. Public posts are on the record forever; private writes are built to be repudiable.
The spec names the three credentials precisely, and it's worth aligning the diary's looser language to it. The user's PDS mints a delegation token (single-use, short-lived, the diary's "member grant"). When a space gates on app identity, the app also presents a client attestation that it signs itself, proving which app it is, separate from the user it's acting for. The space authority checks both and issues the space credential: short-lived, presentable to any member's repo host to read. Two signers, two questions. The delegation token answers "is this app really acting for a member?" The client attestation answers "which app is this?"
The spec is explicit about where the data sits, and it's the same answer the diary built toward. Each member stores their own records for a given space in a permissioned repo on their own repo host, which is their PDS. One permissioned repo per (member, space) pair. The space itself is nothing more than the union of those per-user repos across the network, plus the member list that says whose repos count. Going private never relocates your data onto the owner's server or the app's server. It stays on your PDS, beside your public repo, in a separate authenticated tree.
The member list isn't always a literal list. The baseline space implementation (the spec calls it simplespace) supports a mint policy that can be public (anyone who asks gets a credential), a fixed member list, or delegated to a managing app. That last one is the interesting case: the space owner can defer the let-them-in decision to an app that knows something dynamic, like "is this person currently a paid subscriber" or "do they follow me right now," without anyone maintaining a roster by hand. The app-access dimension is separate and orthogonal: open to any app, or restricted to an allow list checked against the client attestation above.
If you forget everything else, keep these six.
1. Access control ≠ encryption. Different threat models, different problems. atproto's permissioned data system is access control. E2EE can layer on top per-app.
2. The unit of access is a shared context, not a record. Per-record ACLs explode combinatorially. Per-app ACLs centralize. The middle path: a named space with one member list.
3. The space is logical, not physical. Members keep their own data on their own PDSes, one permissioned repo per space. The space is the union of those repos plus the member list. Apps stitch.
4. The space owner is the trust root. A DID. It holds the signing key, publishes the member list, and issues short-lived credentials apps use to read. Communities are many spaces under one such DID.
5. The atproto ethos survives. Users own their data. It lives on their PDS. Apps build views by crawling. Authority lives in DIDs. Permissioned data is the same model with a perimeter drawn around it.
6. Private data rides a separate path. Permissioned writes never hit the public firehose. Apps learn of them by push-then-pull (notifyWrite then getRepoOplog), and the commits are signed to be deniable outside the space.
What post 6 settled: how the application allow/deny lists work (default-allow plus deny list, with the option to flip to default-deny plus allow list), the existence of public permissioned spaces, the three-token credential cascade in full, and why UCAN didn't make the cut. What the draft proposal then made concrete: the sync protocol itself. Permissioned writes ride a push-then-pull path off the public firehose, and commits are signed to be deniable. The diary's earlier guesses about relays carrying redacted private writes didn't survive into the spec. It's still an early draft by its own admission, and Daniel has flagged that quantum-resistant-crypto developments may yet move parts of the sync design, so even settled-looking pieces can shift.
The thing I find most elegant: the rejected ideas didn't disappear. Realms came back as the OAuth scope at the space-type layer. Per-record ACLs would never work as the access primitive, but they're still expressible inside an app's logic. Even E2EE survives, at the layer where it actually fits. Each idea got demoted to where it belongs rather than thrown out. And now the same principle applies to authorization itself: the protocol's commitment shrank to one question (is this DID on the list), and everything else got pushed outward, into apps above and hosts below.