Permissioned Data Shapes: Feeds

Public feed generators drink from a firehose that permissioned data never enters. A feed that includes space content has to become a syncer, keep a live model of who can see what, and serve a different skeleton to every viewer, because a feed over permissioned data is identity-aware or it's a leak.

This is the sixth 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 , and polls . This one is different from the rest. It isn't a new shape so much as what the shapes do to a primitive the atmosphere already loves, and the answer is that they change almost everything about how it's built. The draft caveat rides along as usual: the proposal is still moving, and this post moves with it.

How Feeds Work Today

A feed is a record plus a service. The record lives in the publisher's repo and points at the service:

{
"$type": "app.bsky.feed.generator",
"did": "did:web:feeds.ngerakines.me",
"displayName": "Dayton, All Of It",
"description": "Public Dayton posts, plus the private neighborhood categories you can already see.",
"createdAt": "2026-07-20T21:00:00.000Z"
}

That record sits at at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.generator/dayton, and the service at did:web:feeds.ngerakines.me answers app.bsky.feed.getFeedSkeleton with an ordered list of post URIs. The app view hydrates the skeleton into rendered posts.

Everything upstream of that is pretty straight forward. A public feed generator subscribes to the firehose, or Jetstream, indexes whatever candidate posts its algorithm cares about, and never asks who anyone is. The viewer's identity arrives at serving time as an optional personalization input, and plenty of feeds ignore it entirely. Stateless consumption in, one skeleton fits all out.

What Spaces Take Away

Permissioned records never touch the firehose. There's no relay in the permissioned model at all, so the entire drink-from-the-stream architecture stops at the space boundary. A feed generator that wants space content has to become the thing this series has been calling a syncer: hold a space credential per space, pull each member repo's oplog, register for write notifications, and maintain a per-space index, the same loop the community app views in the last three posts run, except a feed generator wants to do it across many spaces it doesn't operate.

Whether it's even allowed to depends on each space's posture, and the series has already built both. The Gem City Forum runs appAccess: open, so my feed service, authorized by a member, can credential into the Oakwood space and index its categories. The Dayton Pokémon Club allowlists exactly one client, its own app view, so my feed service cannot index the club at all, no matter how many members would love a combined feed. That's not a flaw in either design. The club chose a single window and feeds are outside it; the forum chose interoperability and feeds are the payoff. A feed provider's coverage is now a function of other people's appAccess decisions, which is a sentence that has never been true of a public feed generator.

Membership Is the Feed

The feed generator must have an accurate model of membership available at all times, because membership is what decides whose skeleton a space post may appear in. Serve a private forum post to a non-member and you've leaked, and the leak happens at the URI, before hydration, because a space-form URI names the space, the author, and the collection all by itself.

The protocol doesn't provide third parties a membership list. Member lists are private to the space, readers aren't enumerated anywhere, and listMembers belongs to the authority's management surface. What a feed generator gets instead is the credential flow, and the credential flow turns out to be the membership oracle. My feed service has no standing access to anything. When Mattie subscribes to the feed, her session grants the service a read scope, her PDS mints delegation tokens, and the forum authority exchanges them for space credentials, which succeeds precisely because she's a member. The service's model of "Mattie can see the Oakwood space" is not a database row it maintains by rumor. It's the fact that her delegation exchanges cleanly, re-verified every time a credential renews.

Removal decays on the same clock. When the forum drops a member, their next delegation exchange fails, the service's credential ages out within its lifetime, a couple of hours by default, and the space falls out of their skeleton. The staleness of the membership model is bounded by credential TTL rather than by anyone remembering to send an update, and a paranoid feed service can tighten the bound by re-exchanging delegations more often than it strictly needs to.

Serving Becomes Authorization

getFeedSkeleton today can be answered from a cache without reading the auth header. For permissioned content, that header is the whole ballgame. The request arrives with the viewer's service-auth JWT:

{
"iss": "did:plc:nysigjs52ta6f2l7fdnevadv",
"aud": "did:web:feeds.ngerakines.me",
"lxm": "app.bsky.feed.getFeedSkeleton",
"iat": 1784529900,
"exp": 1784529960
}

The service verifies the signature against the viewer's signing key, resolves the viewer to their membership model, and only then assembles a skeleton. Mattie, a member of the Oakwood space, gets the merged feed:

{
"feed": [
{ "post": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/3m2n2fwkq2s2j" },
{ "post": "at://did:web:forum.thegem.city/space/city.thegem.forum/oakwood/did:plc:nysigjs52ta6f2l7fdnevadv/community.lexicon.forum.post/3m2m7cnk5zs2b" }
]
}

A signed-out request, or a viewer with no Oakwood membership, gets the same feed URI with a smaller truth:

{
"feed": [
{ "post": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.post/3m2n2fwkq2s2j" }
]
}

One feed, per-viewer skeletons. That single fact ripples through the implementation: candidate sets are compartmentalized per space, ranking runs over a mix that differs by viewer, and the easy global cache becomes a cache keyed by the set of spaces a viewer can see. This is the complexity I meant in the standfirst, and I won't pretend it's small. A feed provider that touches permissioned data needs a full and correct implementation of compartmentalized, identity-aware serving, and every shortcut is a disclosure.

Hydration Needs Credentials Too

The skeleton is only half a feed. Whatever consumes it has to hydrate those URIs into posts, and a hydrator can't resolve a space-form URI without its own space credential. The big public app views will skip records they can't read, so a permissioned entry served into an incapable client is just a hole in the timeline. In practice that means feeds over permissioned data pair with app views that hold the same kind of access the generator does, the club and forum app views from earlier in the series being the obvious homes. A feed generator can hedge by serving permissioned entries only when the calling app view is one it knows can hydrate them, which is one more way identity, this time the caller's, reaches into serving.

The Permission Surface

# what the feed service asks each subscriber for: read the spaces you can already see
space:city.thegem.forum?authority=did:web:forum.thegem.city&action=read

# what it can't usefully ask for: the club's allowlist admits only the club's app view
# (no grant exists that gets a third-party feed service into club.dayton-pokemon.feed)

The service's grants are read-only on purpose; a feed generator has no business writing into anyone's space. Its own client identity matters too, since an allowlisted space checks the attested client_id before minting a credential, which is exactly how the Pokémon club keeps it out. And the serving-side JWT above is a permission record in its own right, the one the viewer presents rather than grants.

Trade-offs

The complexity is real and front-loaded. A public feed generator is a weekend project; a permissioned-aware one is an authorization system with a ranking function attached, and it gets very complex very quickly.

Coverage depends on other people's postures. Open spaces like the forum invite feed generators in, allowlisted spaces like the club keep them out, and a feed's completeness is now negotiated space by space.

Membership accuracy is bounded, not perfect. The credential flow keeps the model honest within a token lifetime, so a removed member can see stale space content in their skeleton for up to the TTL, and a service that can't tolerate that window has to re-verify aggressively.

Skeletons are disclosures. A space-form URI in the wrong response leaks a space's existence, an author's participation, and a collection name before any content loads, so the serving path has to fail closed.

Per-viewer serving breaks the economics of caching. Skeleton caches key on membership sets instead of feed URIs, and popular feeds pay for it in memory or latency.

Expect stratification. Many feed generators will stay public-only and lose nothing by it, while community app views absorb permissioned feed serving for their own spaces, which is roughly where the last three posts already ended up.