Permissioned Data Shapes: Private Events

A private events space is the first shape with other people in it: a member list of friends and family, guest RSVPs that live on the guests' own PDSes, and an app allowlist that controls which services ever see the venue address.

This is the second post in a series applying atproto's permissioned data proposal to the shapes I expect people to actually build. The first, Permissioned Data Shapes: Self-Only Bookmarks , covered a space with a member list that never grows past me. This one grows the list. Everything here still carries the proposal's own caveat that it's an early draft and likely to change.

Why Events

The atmosphere already does public events well. The community.lexicon.calendar.* lexicons at Lexicon Community define events and RSVPs, and app views like Smoke Signal, Open Meet, and Atmo RSVP let people view and manage them. What they share is the limit everything in the atmosphere shares: every event is public. That's fine for a conference or a meetup. It's wrong for a birthday party at my house, where the location field would be my home address and the guest list is a dozen people I know.

What I want are circles of friends as spaces. Subgraphs, effectively: a family circle for birthdays and cookouts, a close-friends circle for game nights, each holding events, RSVPs, and posts about them. Every circle carries two lists I control, the identities in it and the app views allowed to read it, so my address never lands in some service I've never heard of. Space keys keep the circles flexible. Standing circles get slugs like family and close-friends; one-offs get TIDs, like a surprise birthday party whose guest list is everyone in close-friends except James.

The Shape

Same triple as before: an authority DID, a space type NSID, and a space key. The authority is my own DID again, since these are my circles. The type is community.lexicon.calendar.collection, mirroring the bookmarks collection from last time. The skey is where this shape diverges from the self-only one. Instead of the reserved self, each circle gets its own key:

at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space/community.lexicon.calendar.collection/family
at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space/community.lexicon.calendar.collection/close-friends
at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space/community.lexicon.calendar.collection/3m2fjw6r3ms2k

My event records look almost exactly like the bookmark records did, written to my own repo in the space. The record that makes this shape interesting is the one I don't write. When a guest RSVPs, their record lands in their own permissioned repo, on their own PDS, addressed under my authority:

at://did:plc:cbkjy5n7bk3ax2wplmtjofq2 space authority (me)
/space literal marker
/community.lexicon.calendar.collection space type
/family skey
/did:plc:nysigjs52ta6f2l7fdnevadv author (@mattie.thegem.city)
/community.lexicon.calendar.rsvp collection
/3m2gwz2ldkc2j rkey

In the bookmarks post those two DIDs were always the same person and the doubled segment looked redundant. Here it starts paying rent. A space is the union of per-member repos across the network, and the URI carries both halves of that: whose social context this is, and who actually wrote the record. Nobody's data moves to my server. My family's RSVPs stay on my family's PDSes, and an application stitches the circle together.

Declaring the Type

Like last time, this space type doesn't exist yet, so treat it as a strawman for Lexicon Community chartering:

{
"lexicon": 1,
"id": "community.lexicon.calendar.collection",
"defs": {
"main": {
"type": "space",
"description": "A collection of calendar events",
"key": "any",
"name": "Events",
"collections": [
"community.lexicon.calendar.event",
"community.lexicon.calendar.rsvp",
"app.bsky.feed.post"
]
}
}
}

The key is any for the same reason the bookmarks declaration ended up there: family and close-friends are slugs, one-off guest lists are TIDs, and the type shouldn't care. The collections list is doing the same double duty as before, setting reader expectations and the default write targets for a bare OAuth grant. The third entry is worth a beat. There's no chartered lexicon for casual discussion posts, and the protocol doesn't constrain which collections a space can hold, so I'm reusing app.bsky.feed.post for chatter about the event. A post record inside a space is just a post record with a different address, the same trick the whole series is built on. If a community discussion lexicon gets chartered later, it slots into this list without breaking anything, since the default collection set follows the declaration as it changes.

Standing It Up

Creating the circle looks like the bookmarks setup:

POST /xrpc/com.atproto.simplespace.createSpace

{
"spaceType": "community.lexicon.calendar.collection",
"skey": "family"
}

The difference is what happens next. Last time I never called addMember. This time it's the whole point:

POST /xrpc/com.atproto.simplespace.addMember

{
"space": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space/community.lexicon.calendar.collection/family",
"did": "did:plc:nysigjs52ta6f2l7fdnevadv"
}

One call per guest. The member list is private to the space, and it's the entire authorization model: a list of DIDs the authority is willing to mint read credentials for. How a guest finds out they've been added is an application concern. The protocol doesn't do invitations; it only decides whether a credential request succeeds. My event viewer sends the "you're invited" message, however it likes.

The other difference is the space configuration. The bookmarks space ran on defaults. This one doesn't:

{
"uri": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space/community.lexicon.calendar.collection/family",
"spaceType": "community.lexicon.calendar.collection",
"skey": "family",
"config": {
"policy": "member-list",
"appAccess": {
"$type": "com.atproto.simplespace.defs#allowList",
"allowed": ["https://events.ngerakines.me/oauth-client-metadata.json"]
}
}
}

policy stays at member-list, but appAccess switches from open to an allowlist. Now a credential is minted only when the requesting user is on the member list and the requesting application is on the allowed list. That symmetry is what makes this feel natural to me. As the space owner, I already decide which identities are in the circle: my friends, my family, my colleagues. Deciding which app views are in the circle is the same move applied to software, and a space holding my home address deserves both lists. The default-open posture is still the right one for most of the network, because default-deny ices out small and experimental clients, and I argued as much when I read the proposal . Guests can RSVP through my viewer or not at all.

What makes the allowlist real instead of decorative is that it's checked against the attested client_id, not a self-reported one. I've wanted client identity in inter-service auth for a while, and the client attestation in this proposal is that idea landing where I hoped it would.

Writing the Event

The event record is the same community.lexicon.calendar.event that the public event apps already write. Nothing about the schema changes. The address is what changes, because now there's somewhere for it to go:

POST /xrpc/com.atproto.space.createRecord

{
"space": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space/community.lexicon.calendar.collection/family",
"collection": "community.lexicon.calendar.event",
"record": {
"$type": "community.lexicon.calendar.event",
"name": "Backyard birthday party",
"description": "Cake at 3. Bring a chair.",
"createdAt": "2026-07-17T16:05:00.000Z",
"startsAt": "2026-08-08T19:00:00.000Z",
"endsAt": "2026-08-08T23:00:00.000Z",
"mode": "community.lexicon.calendar.event#inperson",
"status": "community.lexicon.calendar.event#scheduled",
"locations": [
{
"$type": "community.lexicon.location.address",
"country": "US",
"region": "OH",
"locality": "Oakwood",
"street": "555 Nowhere",
"postalCode": "45419",
"name": "Our house"
}
]
}
}

The reminder from the first post applies throughout: method names come from the proposal, request and response bodies are my reading of the draft lexicons , and the calendar and location records are the published community lexicons.

The Guest's Side

A guest opens my viewer and signs in with their own account. The scope their client requests is where this shape departs from bookmarks. A bare space: grant only covers spaces under the granting user's own authority, so a guest's client asks for spaces under other people's authorities:

space:community.lexicon.calendar.collection?authority=*

On the consent screen, the space type declaration turns that into something a person can read: "Events" rather than an NSID, and when a scope names a specific authority, the spec says to show its bidirectionally validated handle. A guest granting access to my family circle sees my handle, not my DID, which is the consent experience these declarations exist to make possible.

To read the space, the guest's session walks the credential flow from the bookmarks post, plus one new credential. Their PDS mints the delegation token, addressed to my space host:

{
"typ": "atproto-space-delegation+jwt",
"alg": "ES256K",
"kid": "#atproto"
}
{
"iss": "did:plc:nysigjs52ta6f2l7fdnevadv",
"sub": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space/community.lexicon.calendar.collection/family",
"aud": "did:plc:cbkjy5n7bk3ax2wplmtjofq2#atproto_space_host",
"iat": 1784305800,
"exp": 1784305860,
"jti": "3c9d1f42ab8845aa9e07c6d21f5b0e88"
}

Because the space gates on app identity, the viewer also presents a client attestation, a short-lived JWT signed with its own key and verified against the JWKS published in its client metadata:

{
"typ": "atproto-client-attestation+jwt",
"alg": "ES256",
"kid": "key-1"
}
{
"iss": "https://events.ngerakines.me/oauth-client-metadata.json",
"sub": "https://events.ngerakines.me/oauth-client-metadata.json",
"aud": "did:plc:cbkjy5n7bk3ax2wplmtjofq2#atproto_space_host",
"iat": 1784305800,
"exp": 1784305860,
"jti": "b71c04e9d2af43f1a6e85c3097d4b210"
}

My PDS, acting as the space host, verifies the delegation token against the guest's signing key, resolves the attestation's iss to fetch the client's published keys, verifies the signature, checks the guest against the member list, checks the attested client_id against the allowlist, and mints the space credential. Two independent checks, signed by two different parties, evaluated separately. The tokens themselves look just like the ones in the bookmarks post, except the issuers finally differ from the authority.

RSVPing is a write, and writes go through the guest's own PDS with their own OAuth session:

POST /xrpc/com.atproto.space.createRecord

{
"space": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space/community.lexicon.calendar.collection/family",
"collection": "community.lexicon.calendar.rsvp",
"record": {
"$type": "community.lexicon.calendar.rsvp",
"subject": {
"uri": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space/community.lexicon.calendar.collection/family/did:plc:cbkjy5n7bk3ax2wplmtjofq2/community.lexicon.calendar.event/3m2gkq5xvv22p",
"cid": "bafyreie4x2ccnqk7m3vslhw4dfg25rtq6xj4a3lzs7bkmswy2h5nchdvxu"
},
"status": "community.lexicon.calendar.rsvp#going",
"createdAt": "2026-07-17T18:40:00.000Z"
}
}

This is their first write into the space, so their PDS creates a permissioned repo for it on the spot. Two pieces of machinery that sat idle in the self-only shape wake up here. First, the guest's PDS resolves my space host endpoint and auto-registers it as a notification subscriber for that repo, which is how I learn about writes I didn't make. Second, reader-enforced writes start to matter: the guest's PDS doesn't check the member list before accepting the write, because it can't and shouldn't. My viewer checks membership at read time and simply never renders records from DIDs that aren't in the circle, the same way the Bluesky app enforces threadgates. An uninvited RSVP isn't blocked; it's ignored.

Syncing the Circle

My viewer holds one space credential and does everything with it. com.atproto.space.listRepos on my space host returns the writer set, every account that has written at least one record into the space, along with each repo's current revision and commit hash. For the family circle that's me plus whoever has RSVP'd or posted so far, which is a meaningfully longer list than the list of one from the bookmarks post. The viewer runs the same listRepoOps loop I walked through last time, once per repo, and assembles the circle: my events, everyone's RSVPs, everyone's posts. When a guest writes, their PDS notifies my space host, my space host forwards the notification to the viewer, and the viewer pulls. Readers are never enumerated at the protocol level, only writers, so a guest who lurks without RSVPing never shows up in any sync surface.

The Permission Surface

The circle runs on three grants:

# my host app: read and write my circles, manage the spaces and member lists
space:community.lexicon.calendar.collection?manage=create&manage=update

# a guest's client: spaces they're invited to under any authority
space:community.lexicon.calendar.collection?authority=*

# a guest's export tool: their own RSVPs and posts only, nothing of mine
space:community.lexicon.calendar.collection?authority=*&action=read_self&collection=*

The manage verbs are what separate my session from a guest's. In simplespace, manage=update authorizes updateSpace along with addMember and removeMember, so the app that runs my circles carries it and nobody else's needs to. The last grant is the one I'd point privacy-minded guests at: read_self covers an app's view of their own repo in my spaces and nothing more, and it doesn't include the method that starts the credential flow, so an export tool built on it cannot reach my address at all.

After the Party

Removing a guest is removeMember. Their next credential renewal fails and access decays within the credential's lifetime, a couple of hours by default. Worth being honest about the edges here. There's no protocol-level eviction signal, which the community has already flagged as a gap, and nothing claws back what an authorized app has already synced. That app was inside the trust boundary when it read the data, which is the love triangle doing what it always does. The allowlist doesn't change the triangle; it decides who's standing in the third corner. For this space that's a service I operate, and that's precisely as much control as the spec can honestly give me.

Deleting a space after a one-off event has a nice ending. I stop issuing credentials, delete my own repo, and send notifySpaceDeleted to registered syncers and my guests' repo hosts. Syncers are expected to delete every copy they hold. Guests' PDSes flag rather than erase, because the RSVPs and posts in those repos belong to my guests, and their hosts decide how to surface that data back to them. Everyone keeps what they wrote. The circle just stops being a circle.

The confidentiality boundary deserves restating for this shape, because the stakes went up with an address in the record. The event lives in my repo on my PDS, so my PDS can read it, and the allowlisted viewer can read it, and my guests can read it. What's easy to miss is what can't: my guests' PDSes never store my event record. The partitioned model means each host holds only what its own user wrote, so a guest's PDS holds their RSVP and nothing of mine. The address rests in exactly two services, both of which I chose. That's not encryption, and it's not supposed to be. It's a short, legible list of who I'm trusting.

What's Next

Two threads I'm leaving for later. Circles I co-host with someone else don't fit an authority anchored on my personal DID, and pull toward the dedicated space DIDs and management layers that the groups shape will need. And invitations deserve real design, since "the protocol doesn't do invitations" is an answer for the spec, not for my aunt.