Permissioned Data Shapes: Notifications

Notifications are the most personal cross-app data in the atmosphere, and today they live nowhere you control. A self-keyed permissioned-data space turns them into records you own: every app gets an append-only sink to write into, you pick the reader that turns them into pushes and emails, and allowing or revoking an app to write is an access control mechanic you have automatically.

This is the seventh post in a series applying atproto's permissioned data proposal to the shapes I expect people to actually build, after self-only bookmarks , private events , community-moderated content , forums , polls , and feeds . The proposal remains a draft, so treat the specifics here as tracking a moving target.

Notifications are an interesting case because they need some amount of persistence and structure, but their semi-confidential nature means they can't go out to a relay or be disclosed. "Mattie RSVP'd to your party" is nobody else's business, and it's also exactly the kind of small, structured, time-ordered record that repositories are good at. Right now every app keeps its own pile: Bluesky has its notification index, every other app has one too, and none of it is yours. This shape gives that data a home.

The Shape

One space per identity, keyed self, back where this series started:

at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space/com.atproto.notifications.collection/self

The bookmarks post used the self shape for data I create about the world. This one flips the direction: a self space that the world writes into, one app at a time, with me deciding who holds a pen. The authority is my DID, the member list never grows past me, and the interesting design all happens in the grants.

One deliberate signal in the naming: this strawman sits under com.atproto.*, not community.lexicon.*. Everything else in this series has been chartering material, vocabulary a community can adopt or ignore. Notifications feel different to me. Every identity would have exactly one of these, apps benefit from it being predictably present, and a PDS could reasonably provision it the way it provisions your repository, which makes it a candidate for a highly opinionated, enforced standard at the protocol level rather than an optional convention. That's a proposal to the protocol's stewards, and the namespace says so.

Declaring the Types

{
"lexicon": 1,
"id": "com.atproto.notifications.collection",
"defs": {
"main": {
"type": "space",
"description": "An identity's notification inbox",
"key": "literal:self",
"name": "Notifications",
"collections": ["com.atproto.notifications.notification"]
}
}
}

The key is literal:self rather than any, unlike every other declaration in this series, because there is no second notifications space. The record is deliberately small:

{
"lexicon": 1,
"id": "com.atproto.notifications.notification",
"defs": {
"main": {
"type": "record",
"description": "A notification written by an application or service",
"key": "tid",
"record": {
"type": "object",
"required": ["text", "source", "createdAt"],
"properties": {
"text": { "type": "string", "maxLength": 3000, "maxGraphemes": 300 },
"facets": {
"type": "array",
"items": { "type": "ref", "ref": "app.bsky.richtext.facet" }
},
"source": { "type": "ref", "ref": "#source" },
"priority": {
"type": "string",
"knownValues": ["low", "medium", "high"]
},
"createdAt": { "type": "string", "format": "datetime" }
}
}
},
"source": {
"type": "object",
"description": "The application or service that created the notification",
"required": ["name"],
"properties": {
"name": { "type": "string", "maxLength": 640, "maxGraphemes": 64 },
"url": { "type": "string", "format": "uri" },
"did": { "type": "string", "format": "did" }
}
}
}
}

Yes, that's a com.atproto.* lexicon referencing app.bsky.richtext.facet. A real protocol-level standard would want facets hoisted out of the Bluesky namespace first, which is its own long-running conversation, and I'm borrowing the ref here because rich text in notifications is too useful to leave out of the demonstration.

Writers Hold a Pen, Not a Key

When I use applications like Bluesky, Leaflet, AtmoRSVP, or Sifa, each one makes an additional permissioned-data OAuth request against my notifications space. The grant they get is one action:

space:com.atproto.notifications.collection?action=create

Create, and nothing else. No read, no update, no delete, and since read_self isn't in the grant either, no path to the credential flow. The space becomes an append-only sink from the writer's side, enforced by the scope grammar rather than by anyone's good behavior. When AtmoRSVP wants to tell me something, it writes through my session:

POST /xrpc/com.atproto.space.createRecord

{
"space": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space/com.atproto.notifications.collection/self",
"collection": "com.atproto.notifications.notification",
"record": {
"$type": "com.atproto.notifications.notification",
"text": "@mattie.thegem.city is going to Backyard birthday party",
"facets": [
{
"index": { "byteStart": 0, "byteEnd": 19 },
"features": [
{
"$type": "app.bsky.richtext.facet#mention",
"did": "did:plc:nysigjs52ta6f2l7fdnevadv"
}
]
}
],
"source": { "name": "AtmoRSVP", "url": "https://atmorsvp.example" },
"priority": "medium",
"createdAt": "2026-07-21T13:05:00.000Z"
}
}

That lands at at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/space/com.atproto.notifications.collection/self/did:plc:cbkjy5n7bk3ax2wplmtjofq2/com.atproto.notifications.notification/3m2p4kwzvac2f, and the birthday party it mentions is the one from the private events post, because a good notification is usually about something in another space.

Two access controls arrive free, exactly the way the events post argued they should feel. I selectively allow applications to write notifications by granting the scope, one consent screen at a time. And I revoke an application's ability the same way I revoke anything: end its OAuth session, and its pen stops working. No notification settings page an app maintains about me, no unsubscribe flow it might ignore. The permission is mine and lives on my side of the relationship.

One limit of this arrangement: the scope grammar has no "read only the records you created" grant, so a writer that could read at all would see every app's notifications, which is why writers here read nothing. An app that wants to show "what we've sent you" keeps its own ledger or asks the reader. There's a stricter arrangement for services that want more: give the service its own seat, add its DID as a space member, and let it write notifications into its own permissioned repo within my space. Then read_self means exactly its own notifications, revocation is removeMember, and provenance stops being a claim, because the author segment of the record's URI names the service. It costs services a repo host and me a member list, and for high-trust senders it might be worth every bit of that.

Readers and Choice

On the read side I get a choice of notification reader, and the difference between a reader and a writer is just the permissions. A reader holds the broad grant:

space:com.atproto.notifications.collection

Full read, write, and delete across the space, which is what triage takes: delete is dismiss, updates cover whatever read-state convention emerges, and read is the inbox. The reader registers for space subscriptions with com.atproto.space.registerNotify, and this is the one place the self shape needs a step the shared shapes got automatically, since a personal space's repo host and space host are the same server and nothing auto-registers. From then on, every write triggers a notification to the reader, the reader pulls the oplog the way every syncer in this series has, and what happens next is product, not protocol: debounce the burst from a busy thread, buffer the low-priority pile for a digest, push the high ones to iOS immediately, email the weekly summary, etc. The priority field exists so writers can hint and readers can decide.

Nothing says there's only one. A minimal viewer that just lists and links can run on action=read and never touch a thing, a heavyweight reader can do cross-app digests, and switching readers is revoking one grant and issuing another, with every notification still sitting in the space that outlives them both.

The Permission Surface

# every writing app: an append-only pen
space:com.atproto.notifications.collection?action=create

# the reader I choose: full triage
space:com.atproto.notifications.collection

# a read-only viewer or an export tool
space:com.atproto.notifications.collection?action=read

Three grants, no overrides, and the whole system's access model fits on a consent screen: this app may leave me notes, this app may read and manage them, this one may only look.

Trade-offs

source is self-asserted under the OAuth arrangement. A create-only writer can claim to be anyone in the record body, so readers should treat source as a label, not an identity, and the service-as-member variant is the answer when provenance has to be cryptographic.

An append-only pen can still scribble. Revocation handles bad actors after the fact, but a chatty or malicious writer can flood the space until then, and rate limiting lands on the PDS and the reader rather than the protocol.

Choosing a reader is choosing who sees your whole app life. The full grant reveals every application that writes to you, which is a more intimate census than any one app's notification tab, and it deserves the same care as choosing a PDS.

This is perimeter privacy, as always in this series. The PDS reads everything, the reader reads everything, and semi-confidential means exactly that.

Read state needs a convention. Dismissed, seen, and acted-on all want cross-reader agreement, and an enforced standard would have to say more than this post does about what updates mean.

An enforced standard needs an enforcer. com.atproto.* is a proposal aimed at the protocol's stewards, and whether provisioning, quotas, and record semantics belong in the PDS is a bigger question than a strawman settles.