Permissioned Data Shapes: Self-Only Bookmarks

A self-only space is the smallest shape permissioned data can take, with one authority, one member, and one repo all under the same DID. This post works through the whole thing for bookmarks, from the space type declaration to the sync flow.

Last month I published Reading Proposal 0016 , a close read of Daniel Holmgren's permissioned data draft . This series is the follow-up. Instead of reading the spec, I want to apply it: a tour of the shapes I expect permissioned data spaces to take in practice, with real URIs, records, and requests for each one. As with that post, this is an early draft and its details are likely to change, so consider all of this a snapshot of something still moving.

The first shape is the smallest one: a space with exactly one member, me.

Why Bookmarks

Bookmarks are an old interest of mine. I worked on del.icio.us back in the day, and the question of where bookmarks live and who gets to see them has followed me around ever since. The atmosphere already has a good public answer (disclaimer: I helped author it). community.lexicon.bookmarks.bookmark is a small, boring, widely understood record: a subject URI, some tags, a timestamp. Apps write them into your public repository, and any app view can index them.

The problem is that the public repository is the only place to put them. Some bookmarks are meant for sharing. A lot of them are not: research for a job hunt, gift ideas, an article you want to save but not broadcast, etc.. Today those either go into an app's silo or they don't get saved at all. What I want is both kinds under one identity, in one record shape, indexed by the same tooling, where the only difference between a public bookmark and a private one is the address it was written to.

The proposal makes that possible, and the personal case is small enough that most of the protocol's machinery drops away. That's what makes it a good first shape.

The Shape

A space is identified by three values: an authority DID, a space type NSID, and a space key. For personal data, the proposal says the authority can simply be your own DID. Dedicated space DIDs exist so that shared spaces can transfer between people, and nothing about my bookmarks ever needs to transfer. The space key is an arbitrary string with rkey syntax, and the spec reserves self for exactly this situation: the one canonical instance of a type under an authority.

So my bookmarks space is:

at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space/community.lexicon.bookmarks.collection/self

And a record inside it looks like this:

at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space/community.lexicon.bookmarks.collection/self/did:plc:cbkjy5n7bk3ax2wplmtjofq2/community.lexicon.bookmarks.bookmark/3lzvmc2yk5s2a

Segment by segment, that URI is:

at://did:plc:cbkjy5n7bk3ax2wplmtjofq2        space authority (me)
/space                                     literal marker
/community.lexicon.bookmarks.collection    space type
/self                                      skey
/did:plc:cbkjy5n7bk3ax2wplmtjofq2          author (also me)
/community.lexicon.bookmarks.bookmark      collection
/3lzvmc2yk5s2a                             rkey

Permissioned URIs reuse the at:// scheme. The literal space segment sits where a collection NSID would appear in a public URI, and since a collection NSID always contains at least two dots and space contains none, the two are never ambiguous. My DID appears twice in that record URI, once as the space authority and once as the record author. That looks redundant here because in this shape they are always the same person. It's the price of a URI structure that keeps working when the authority is a community DID and the authors are ten thousand different members. If you're implementing this, don't special-case the personal shape; the doubled DID is what makes it the same code path as everything else.

Declaring the Type

Space types are NSIDs that resolve to a Lexicon definition with "type": "space". There isn't one for bookmarks yet, so treat this as a strawman for chartering one under the Lexicon Community namespace:

{
"lexicon": 1,
"id": "community.lexicon.bookmarks.collection",
"defs": {
"main": {
"type": "space",
"description": "A collection of bookmarks",
"key": "any",
"name": "Bookmarks",
"collections": [
"community.lexicon.bookmarks.bookmark"
]
}
}
}

Two fields do real work here. The key field recommends a space key type the same way record key types work, and any keeps this type open: self is the convention for the one personal collection, and a TID or a slug can key additional collections of the same type. That matters later in the series, because the same declaration serves shared collections under other authorities without changes. The collections field is not documentation: it's the default collection set for the OAuth scope, which I'll come back to. The name is what appears on consent screens, so a user grants an app access to "Bookmarks" rather than to a raw NSID.

One thing worth knowing: the protocol doesn't constrain which collections can be written into a space. The declaration sets expectations and scope defaults, but if I want to drop private draft notes into this same space later, nothing stops me. I'd probably charter a sibling type for notes instead, mostly so the consent language stays honest.

Standing It Up

My PDS is both the repo host and the space host here, because that's the fallback the spec defines. A space authority resolves through two optional DID document entries, #atproto_space for the credential signing key and #atproto_space_host for the host endpoint. When they're absent, everything falls back to the #atproto signing key and the #atproto_pds service endpoint. For the self shape, I don't touch my DID document at all.

Space creation goes through com.atproto.simplespace, the management implementation every PDS is required to support. An app holding a manage=create grant calls:

POST /xrpc/com.atproto.simplespace.createSpace

{
"spaceType": "community.lexicon.bookmarks.collection",
"skey": "self"
}

and gets back the space URI. One note on the examples in this post: the method names come straight from the proposal, but the request and response bodies are my reading of the draft lexicons , which are still moving. Querying com.atproto.space.getSpace afterward surfaces the simplespace configuration, which for me stays at its defaults:

{
"uri": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space/community.lexicon.bookmarks.collection/self",
"spaceType": "community.lexicon.bookmarks.collection",
"skey": "self",
"config": {
"policy": "member-list",
"appAccess": {
"$type": "com.atproto.simplespace.defs#open"
}
}
}

policy: member-list means the authority authorizes readers from an explicit list, and mine never grows past me. Whether the creator sits on that list as entry number one or is implicitly authorized as the authority is a simplespace detail the drafts will settle. Either way, addMember is a method I never call. appAccess: open means I'm not gating on client identity, so client attestations never come up either. That removes two of the proposal's subsystems, membership management and app allowlisting, from the picture entirely.

Writing a Bookmark

This is the part I care about most. Writing a permissioned record is the same motion as writing a public one. Here's a public bookmark today:

POST /xrpc/com.atproto.repo.createRecord

{
"repo": "did:plc:cbkjy5n7bk3ax2wplmtjofq2",
"collection": "community.lexicon.bookmarks.bookmark",
"record": {
"$type": "community.lexicon.bookmarks.bookmark",
"subject": "https://dholms.leaflet.pub/3mqtqvjidqs2p",
"tags": [
"atproto",
"permissioned-data"
],
"createdAt": "2026-07-17T14:25:00.000Z"
}
}

And here's the private one:

POST /xrpc/com.atproto.space.createRecord​

{
"space": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space/community.lexicon.bookmarks.collection/self",
"collection": "community.lexicon.bookmarks.bookmark",
"record": {
"$type": "community.lexicon.bookmarks.bookmark",
"subject": "https://dholms.leaflet.pub/3mqtqvjidqs2p",
"tags": [
"atproto",
"permissioned-data"
],
"createdAt": "2026-07-17T14:25:00.000Z"
}
}

The record is byte-for-byte identical. Same lexicon, same validation, same CID computation. The repo parameter became a space parameter, and that's the entire difference between a bookmark the whole network can see and one only I can. The storage underneath differs, since permissioned repos are committed with a set hash instead of an MST , which I covered in the proposal read-through. But at the layer where applications live, a bookmark is a bookmark. An app view that indexes community.lexicon.bookmarks.bookmark records doesn't need a second code path for the private ones. It needs a second address to read from, and permission to read it.

Reading It Back

I've been using "app view" loosely, so let me be specific about the three consumers I actually want here, because they get to use three different amounts of the protocol.

A browser extension that captures bookmarks writes records and reads back what it wrote. The OAuth scope grammar has a grant made for this: read_self covers the read and sync methods for my own repo in the space and nothing else. In a self-only space, my repo is the whole space, so a capture extension gets everything it needs from:

space:community.lexicon.bookmarks.collection?action=create&action=read_self

It never mints a delegation token, and it can't reach anyone else's data even in principle, because the grant doesn't include the method that would start that flow.

A web client front end gets the same result with a wider grant. A bare space:community.lexicon.bookmarks.collection scope is the whole personal package: authority defaults to self, skey defaults to any, the collections default to what the type declaration lists, and the actions default to full read and write. The proposal uses a bookmarks space as its example of this default, which tells you the authors expect the self shape to be the common case. The front end calls com.atproto.space.listRecords and getRecord against my PDS with its OAuth session and renders my private bookmarks next to my public ones.

An indexing app view is the one that runs a real service: search across everything, tag aggregation, an XRPC surface that a thin client or the extension can call. It syncs, so it walks the full credential flow, and in the self shape the flow becomes a very short loop. It asks my PDS for a delegation token:

{
"typ": "atproto-space-delegation+jwt",
"alg": "ES256K",
"kid": "#atproto"
}
{
"iss": "did:plc:cbkjy5n7bk3ax2wplmtjofq2",
"sub": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space/community.lexicon.bookmarks.collection/self",
"aud": "did:plc:cbkjy5n7bk3ax2wplmtjofq2#atproto_space_host",
"iat": 1784298300,
"exp": 1784298360,
"jti": "f47ac10b58cc4372a5670e02b2c3d479"
}

Single use, sixty seconds, signed with my account key. Note that iss and the authority in aud are the same DID: my PDS mints a token addressed to itself. The indexer exchanges it via com.atproto.space.getSpaceCredential on that same PDS and receives a space credential:

{
"typ": "atproto-space-credential+jwt",
"alg": "ES256K",
"kid": "#atproto"
}
{
"iss": "did:plc:cbkjy5n7bk3ax2wplmtjofq2",
"sub": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space/community.lexicon.bookmarks.collection/self",
"iat": 1784298300,
"exp": 1784305500,
"jti": "9f8e7d6c5b4a3210fedcba9876543210"
}

Two hours, multi-use, and no aud, because it's meant to be presented to every repo host in the space. My space has one repo host. The indexer can call com.atproto.space.listRepos for the writer set, get back a list of one, and sync me incrementally:

GET /xrpc/com.atproto.space.listRepoOps
?space=at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space/community.lexicon.bookmarks.collection/self
&since=3lzvhw3xakc2p

{
"ops": [
{
"rev": "3lzvmc2yk5s2a",
"collection": "community.lexicon.bookmarks.bookmark",
"rkey": "3lzvmc2yk5s2a",
"cid": "bafyreib2rxk3rw6snvxdefabc1234567890abcdefabcdef",
"prev": null,
"value": {
"$type": "community.lexicon.bookmarks.bookmark",
"subject": "https://dholms.leaflet.pub/3mqtqvjidqs2p",
"tags": [
"atproto",
"permissioned-data"
],
"createdAt": "2026-07-17T14:25:00.000Z"
}
}
],
"commit": {
"ver": 1,
"rev": "3lzvmc2yk5s2a",
"hash": "<sha256 of the LtHash state>",
"ikm": "<32 random bytes>",
"sig": "<signature over the context>",
"mac": "<hmac binding hash to context>"
}
}

Each operation is {rev, collection, rkey, cid, prev} with record values inlined, and when a response reaches the end of the oplog it carries the current signed commit so the syncer can compare its running set hash against mine. The commit signature is intentionally deniable, so a leaked commit proves nothing about what I bookmarked. I'll dig into the sync layer properly later in the series. For staying current, the indexer registers for write notifications with com.atproto.space.registerNotify. Here the self shape drops one more piece of machinery: in a shared space, a PDS auto-registers the space authority as a notification subscriber on first write, but the spec skips that for personal spaces because the repo host and the space host are the same server wearing both hats.

The Permission Surface

Since the point of the shape is that access is legible, here is the full set of grants the whole system runs on:

# a full bookmarks client: read and write my space
space:community.lexicon.bookmarks.collection

# capture extension: write, and read only my own repo
space:community.lexicon.bookmarks.collection?action=create&action=read_self

# export or backup tool: read my repo, touch nothing
space:community.lexicon.bookmarks.collection?action=read_self

# first-run setup: allowed to create the space itself
space:community.lexicon.bookmarks.collection?manage=create

For apps that would rather present something friendlier than a scope string, the same grant bundles into a permission set:

{
"type": "permission-set",
"title": "Bookmarks",
"detail": "Read and save your bookmarks",
"permissions": [
{
"type": "permission",
"resource": "space",
"spaceType": "community.lexicon.bookmarks.collection",
"collection": [
"community.lexicon.bookmarks.bookmark"
],
"action": [
"read",
"create",
"update",
"delete"
]
}
]
}

Revoking an app is revoking its OAuth session. When an indexer loses its last session for my account, it can no longer renew its space credential, and access decays on its own within the credential's lifetime. That's the entire offboarding story, and none of it is code I have to write.

Trade-offs

The proposal is a draft, and the authors expect terminology and behavior to change. The bookmarks space type doesn't exist yet and would need to go through Lexicon Community chartering, which I'd like to help with. And permissioned data is access control, not confidentiality . My PDS can read these records, and any indexer I authorize can read these records. That's what lets them provide search and aggregation, and it means the applications are part of the trust relationship, which is the love triangle I wrote about in February. If a bookmark is sensitive enough that I don't want my own hosting provider seeing it, this is the wrong tool, and encryption is a layer an app should add on top. For keeping my reading list out of the public firehose, it's exactly the right one.

What's Next

What I get from this shape is my whole archive under one identity, in one record shape, with the private half addressed instead of siloed. It migrates when my account migrates, it goes away when I delete the space, and every app that already understands a bookmark record understands these.

Two things I'm deliberately leaving for later: moving a bookmark between the public repository and the space, which today is a delete and a re-create under a new URI, and what account migration looks like when you hold one permissioned repo per space, which the proposal describes only at a high level. Both deserve their own posts.