← start technical walkthrough switch to the plain-language version →
A walkthrough Permissioned data on atproto Holmgren series

How do you put walls around data
in an open network?

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.

00 / The frameThe system this lives inside

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?

ALICE PDS records BOB PDS records CAROL PDS records RELAY firehose aggregates events APPVIEW (BSKY) timeline, search APPVIEW (FORUM) indexes posts APPVIEW (FEED) custom algorithm USER-OWNED HOSTING FIREHOSE APPS BUILD VIEWS
Fig. 0   Public atproto: data flows out → relay fans out → many apps build different views from the same firehose.

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."

01 / Post 1To encrypt or not to encrypt

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.

Confidentiality (E2EE)

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.

Access control

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.

The DM exception. Direct messages are the one place where access-control logic isn't enough — confidentiality is the actual product. Modern messaging is E2EE because you genuinely don't want the server reading. Daniel acknowledges this and sets DMs aside as a separate problem.

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.

02 / Post 2Why "individual ACLs" don't survive contact with reality

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.

Attempt 1: Let the application do it (a "realm")

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.

Attempt 2: ACLs on individual records

Idea: every record has its own ACL. Maximum control. Maximum disaster when membership changes.

EXISTING MEMBERS A 3 records · ACL B 12 records · ACL C 7 records · ACL N records · ACL NEW MEMBER EVERY EXISTING MEMBER MUST UPDATE ACL ON EVERY RECORD THEY OWN N members × M records = explosion of writes
Fig. 2   The combinatorial trap. Every membership change becomes a fan-out write across every member's PDS.

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.

The answer: a shared container with one ACL

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.)

Glossary checkpoint

BUCKET (later: SPACE)
A protocol-level "shared social context." Has one ACL. Holds many records from many members. Examples: one private forum, one Facebook group, one paid newsletter.

03 / Post 3Where does the bucket actually live?

"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.

ALICE PDS owner BOB PDS member CAROL PDS member DAN PDS member BUCKET all posts here all blobs here all moderation here on Alice's PDS CHOKEPOINT copyright? CSAM? storage? takedowns? APP / APPVIEW building the view one fetch
Fig. 3   Toggle the modes. Watch where the data sits — and where the responsibility sits with it.

Why colocated is the wrong kind of simple

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 is the right kind of complex

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."

04 / InterludeThe rename: bucket → space

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.

Final terminology

SPACE (formerly BUCKET)
The shared social context. Authorization + sync boundary. Not a container — a coordination concept.
PERMISSIONED REPO
One user's contribution to one space, sitting on their PDS. A user has many of these — one per space they're in.

"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."

05 / Post 4The whole machine

Now we can put it all together. Here's the big picture, with everything labeled.

SPACE OWNER DID + signing key holds member list (the ACL) issues space credentials MEMBER PDSes ALICE PDS permissioned repo + ECMH commit BOB PDS permissioned repo + ECMH commit CAROL PDS permissioned repo + ECMH commit SPACE (logical boundary) addressed by: owner DID + type NSID + skey type NSID → OAuth scope e.g. com.atmoboards.forum APPLICATIONS ATMOBOARDS syncs space builds forum view FORUMBROWSER different app same space, same data publishes ACL space credential user writes write notif → Sync is PULL-BASED. Members POST notifications via owner. Apps PULL from member PDSes. All authority cascades from the space owner DID.
Fig. 5   The full machine. Every label is a real concept from Daniel's post 4.

The five protocol-level concepts

SPACE OWNER
A DID that's the root of trust. Holds the signing key. Publishes the member list. Issues credentials. For personal data (your bookmarks), this is just you. For a community, it's typically a dedicated DID so ownership can transfer without breaking references.
SPACE TYPE (NSID)
What kind of space this is. 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.)
PERMISSIONED REPO
Each member's slice of one space, on their own PDS. Has a CRUD interface like a public repo, but uses an ECMH commit instead of an MST root.
MEMBER LIST
The single ACL. Tuples of (DID, read|write). Lives at the space owner. Synced to apps over the same protocol as the repos themselves.
SPACE CREDENTIAL
A short-lived (~2–4hr), signed token from the space owner. Lets an app read the space contents from any member PDS. Stateless — verifiable without phoning home.

Two technical pieces worth understanding

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.

Why the change? MST gives you per-record proofs but is heavy. ECMH gives you "do these two repos contain the same set of records?" in one cheap check. For permissioned data, where the threat model is different and you don't need partial proofs to strangers, ECMH wins on overhead.

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.

06 / Putting it in motionOne full read, end to end

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.

STEP 0 / READY
FORUMBROWSER Carol's new app CAROL PDS Carol logs in here SPACE OWNER issues credentials ALICE PDS member BOB PDS member DAN PDS member 1. OAuth 2. grant token 3. space credential ← signed by owner 4. fetch member list 5. pull permissioned repos in parallel — each PDS verifies the credential VIEW stitched in app presented to Carol Carol gets a unified forum view from a brand-new app — without anyone needing to re-grant anything.
Fig. 6   Six steps. Hit play. Each step builds on the last.

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.

07 / Post 5What's in a name? The URI structure

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:// did:plc:space_did / com.example.space.type / skey did:plc:author_did / collection rkey / SCHEME SPACE DID SPACE TYPE (NSID) SPACE KEY AUTHOR DID COLLECTION SPACE ADDRESS did + type + skey RECORD ADDRESS did + collection + rkey DID — NSID — string  ⟂  DID — NSID — string ↑ deliberate symmetry with public atproto record URIs "a record (right half) located inside a space (left half)"
Fig. 7   Six segments. Two halves. The space comes first because the author's authority to write is downstream of the space granting them membership.

Why 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.

Why spaces get their own DID (sometimes)

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:

The feeds problem. Custom feeds on Bluesky are tied to the DID of whoever created them. As feeds become important, the creator can't hand them off to someone else without breaking every existing reference. Bake ownership into the identifier and you've built a one-way trap.

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:

Use the user's DID

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.

Mint a separate space 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.

Space type: making spaces legible without syncing them

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.

Space keys: many spaces, one DID

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."

The symmetry that makes it click

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.

On URI length. Six segments is long. Daniel was tempted by tricks — relative URIs from inside a space, collapsing repeated DIDs. He rejected them all. String equality is the most valuable property of a URI. If two different URIs can refer to the same resource, every comparison needs a canonicalize function. Cost of a few extra bytes versus cost of canonicalization-everywhere is no contest. To quote a colleague of his: "I'll pay for it."

08 / Post 6Boring auth

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.

The single protocol commitment

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.

Writes are reader-enforced

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.

Why this is the right move. Writes are where complexity wants to live. Vouching, role hierarchies, multi-step approval flows are all application semantics. If the protocol tried to encode them, the design would converge on "AWS IAM on atproto" (Daniel's phrase). Pushing the complexity above the protocol keeps the protocol small and lets apps innovate freely.

The narrow waist

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 · APPLICATIONS vouching rules role hierarchies threadgates tier-gated access multi-step approvals follow-based access block / mute "post only on Tuesdays" channel composition arbitrary social/business logic THE PROTOCOL is this DID on the space's member list? single-admin host multi-admin governance admin tiers + roles voting schemes Shamir-split keys DAO-like control community-owned identity ownership transfer mechanics BELOW THE PROTOCOL · SPACE HOSTS · GOVERNANCE
Fig. 8   The hourglass. The protocol's single commitment is the pinch point. All the policy complexity lives above (in apps) or below (in hosts).

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.

The credential cascade, in detail

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.

Apps get the whole space

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.

Application allow/deny lists

"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.

Public permissioned spaces

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.

A note on UCAN

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.

The thesis

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.

09 / Side essayModeling communities: against universal spaces

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 tempting wrong turn

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.

1. It's illegible at the consent screen

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.

2. The access boundary is wrong

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."

Communities as a bundle of spaces

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.

Two app shapes fall out

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.

Where the generic stuff goes. Keeping spaces particular doesn't kill ecosystem standards. It relocates them to the governance layer. Daniel points at The Arbiter (Zicklag and the Roomy folks): a general-purpose, interoperable group-management service that sits on top of permissioned spaces, hosts community DIDs and their spaces, and exposes a standard API for creating spaces and managing membership and roles. The rule of thumb he lands on: put the genericness in the governance layer, keep the spaces themselves particular.

Two questions he's still chewing on

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.

Why this stays loose on purpose. Daniel's own reminder: this whole modeling layer is far more forgiving than the protocol beneath it. The protocol gets standardized and is hard to change, which is exactly why they keep it down to a member list. How you model communities on top is mostly recommendation. It's an open network; if a shape is wrong, people try another. Messy, but that's the point.

10 / The specThe draft proposal: what got concrete

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.

Permissioned writes never touch the firehose

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.

What died. The diary floated several ways a public relay might carry private writes in redacted form: omission modes, redaction modes, cipher modes. None of it survived into the implementation design. Permissioned data simply runs on its own path, parallel to the public one, with its own sync.

The signatures are deliberately deniable

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 credential vocabulary, settled

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?"

Per-user permissioned repos

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.

Policy that isn't a static list

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.

11 / TakeawaysWhat to hold onto

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.