Permissioned Data Shapes: Community-Moderated Content

A community can own spaces the way a person does. This post works through a club whose members post to each other through a dedicated community DID, with a feed space for content, a moderation space for notes, a labels space for filtering, and a single app view as the only window into any of it.

This is the third post in a series applying atproto's permissioned data proposal to the shapes I expect people to actually build. The first covered a space with one member , the second covered circles of invited guests , and both anchored their spaces on my personal DID. This scenario is different because it doesn't. It's also the most involved shape so far, because it stacks several ideas on top of each other: a community identity, three space types under one authority, moderation as permissioned records, and labels that never touch the public label stream. The usual caveat applies. The proposal is an early draft, and everything here is likely to shift with it.

A Community Identity

The Dayton Pokémon Club is a group of people who play Pokémon Go together. The club has its own identity, did:plc:rkh7hrrprgtarfraofpbh4vt, with the handle @dayton-pokemon.club bidirectionally validated against it. The club identity is the space authority for everything that follows, which is what dedicated space DIDs were designed for: the club's spaces survive any individual member leaving, including whoever created them, because authority transfers by updating the DID document rather than by migrating anyone's account.

I'm going to leave community management alone in this post. Who controls the club DID, how moderators get chosen, how membership changes propagate into member lists: that's a longer, more involved post, and it depends on ideas still being worked through by groups like the atmosphere groups working group. For now it's enough that the club runs an app view where people join and leave, and that the club's space member lists reflect whatever that management layer decides. The DID document needs nothing exotic either. The optional #atproto_space and #atproto_space_host entries can point at club infrastructure, or be omitted so everything falls back to the signing key and PDS endpoint the club account already has.

Three Spaces, Three Types

The club's content lives in three spaces, and each modality gets its own space type:

at://did:plc:rkh7hrrprgtarfraofpbh4vt/space/club.dayton-pokemon.feed/general
at://did:plc:rkh7hrrprgtarfraofpbh4vt/space/club.dayton-pokemon.moderation/main
at://did:plc:rkh7hrrprgtarfraofpbh4vt/space/club.dayton-pokemon.labels/main

The general space is for members and holds the actual content. The moderation space is for the mod team and holds their notes. The labels space holds the club labeler's records, the labels the app view uses to filter what members see. Each has its own member list, its own perimeter, and, because the space type is the OAuth consent unit, its own consent boundary. A member granting the club app access sees one legible ask, "Dayton Pokémon Club" under @dayton-pokemon.club, and their grant covers the feed type and nothing else. Moderator tooling asks for the moderation type by name, and the labeler's session asks for the labels type, so nobody consents to a space their role never touches. The per-space member lists still do the runtime gating: a grant is permission for an app to act for a user, not proof the user belongs, and the authority declines credential requests from anyone who isn't on the list.

Declaring the Types

The strawman declarations, in the pattern the last two posts established. The feed type carries the member-facing collections:

{
"lexicon": 1,
"id": "club.dayton-pokemon.feed",
"defs": {
"main": {
"type": "space",
"description": "Member feed spaces for the Dayton Pokémon Club",
"key": "any",
"name": "Dayton Pokémon Club",
"collections": [
"app.bsky.feed.post",
"app.bsky.feed.like",
"app.bsky.feed.repost",
"app.bsky.feed.postgate"
]
}
}
}

The declared collections are all app.bsky.*. There's no rule that a space type's collections come from its own namespace, and the whole premise of this shape is that community content is ordinary Bluesky feed records with a different address. The moderation and labels spaces get their own types rather than hanging off the feed type as extra skeys, one particular space type per kind of thing, so every declaration honestly describes what a grant against it can touch:

{
"lexicon": 1,
"id": "club.dayton-pokemon.moderation",
"defs": {
"main": {
"type": "space",
"description": "Moderation spaces for the Dayton Pokémon Club",
"key": "any",
"name": "Dayton Pokémon Club Moderation",
"collections": ["club.dayton-pokemon.moderation.note"]
}
}
}

{
"lexicon": 1,
"id": "club.dayton-pokemon.labels",
"defs": {
"main": {
"type": "space",
"description": "Label spaces for the Dayton Pokémon Club",
"key": "any",
"name": "Dayton Pokémon Club Labels",
"collections": ["club.dayton-pokemon.label"]
}
}
}

The consent names are doing the disambiguation on purpose. "Dayton Pokémon Club" is what a member grants, "Dayton Pokémon Club Moderation" is what a moderator's tooling grants, and a consent screen never has to explain a collection override, because each declaration already names exactly the collections its spaces carry.

The General Space

The general space is configured the way the family circle was in the events post, with a member list and an app allowlist:

{
"uri": "at://did:plc:rkh7hrrprgtarfraofpbh4vt/space/club.dayton-pokemon.feed/general",
"spaceType": "club.dayton-pokemon.feed",
"skey": "general",
"config": {
"policy": "member-list",
"appAccess": {
"$type": "com.atproto.simplespace.defs#allowList",
"allowed": ["https://app.dayton-pokemon.club/oauth-client-metadata.json"]
}
}
}

The member list holds every club member, kept in sync by the management layer I'm not writing about today. The allowlist holds exactly one client, the club's own app view. The rationale is different from the events post, where the allowlist kept an address from leaking. Here the moderators want more control over how members interact with the content they manage, and constraining the app view list is one way to get it: labels, gates, and removals only mean something if the window rendering the content honors them, and with one allowlisted client, it does. The moderation and labels spaces carry the same config shape with much shorter member lists.

Members post the way they'd post anywhere. For the examples I'll use two club members, @mattie.thegem.city and me, @ngerakines.me . Here's Mattie sharing a raid:

POST /xrpc/com.atproto.space.createRecord

{
"space": "at://did:plc:rkh7hrrprgtarfraofpbh4vt/space/club.dayton-pokemon.feed/general",
"collection": "app.bsky.feed.post",
"record": {
"$type": "app.bsky.feed.post",
"text": "Raikou raid at the RiverScape fountain, 6pm. Need two more.",
"langs": ["en"],
"createdAt": "2026-07-17T21:10:00.000Z"
}
}

That's an unmodified app.bsky.feed.post, validated by the same lexicon Bluesky uses, written to Mattie's own PDS into her permissioned repo for the club's space. The address is where the series pattern pays off a third time:

at://did:plc:rkh7hrrprgtarfraofpbh4vt space authority (the club)
/space literal marker
/club.dayton-pokemon.feed space type
/general skey
/did:plc:nysigjs52ta6f2l7fdnevadv author (@mattie.thegem.city)
/app.bsky.feed.post collection
/3m2hcw4qzns2j rkey

In the bookmarks post the two DIDs were the same person. In the events post they were me and a guest. Here the authority isn't a person at all: it's an organization, the author is a member, and the URI carries both facts. Interactions are the ordinary records too. When I like Mattie's post, my like goes into my own repo in the same space, with a strong reference whose URI is the space-form address:

{
"$type": "app.bsky.feed.like",
"subject": {
"uri": "at://did:plc:rkh7hrrprgtarfraofpbh4vt/space/club.dayton-pokemon.feed/general/did:plc:nysigjs52ta6f2l7fdnevadv/app.bsky.feed.post/3m2hcw4qzns2j",
"cid": "bafyreihkm2v6yqp3xduo4c5tzfa7g2lrw6sbnq3e4jd5mxky7hzcavw3le"
},
"createdAt": "2026-07-17T21:14:00.000Z"
}

That like lands at its own space-form address, with me as the author:

at://did:plc:rkh7hrrprgtarfraofpbh4vt/space/club.dayton-pokemon.feed/general/did:plc:cbkjy5n7bk3ax2wplmtjofq2/app.bsky.feed.like/3m2hdq2vfnk2c

Reposts work the same way, and app.bsky.feed.postgate records ride along so an author can disable replies or detach quotes. Gates were always reader-enforced in public Bluesky, applied by the app view rather than the protocol, and nothing about that changes inside a space. The club's app view honors them the way bsky.app does.

The App View

The club AppView is doing three jobs: it's the membership surface, which I've deferred; a feed server assembling timelines and raid-alert feeds from the general space; and a hydrator turning post references into full post views with author profiles, like counts, and applied labels.

The sync side is the machinery from the earlier posts, scaled up. The app view holds one space credential per space, obtained through the delegation flow from the events post with the club's key signing the credential instead of mine. listRepos on the club's space host returns the writer set, every member who has written at least one record, Mattie and me included, and the listRepoOps loop from the bookmarks post runs once per repo. Write notifications route through the club's space host to the app view, so a new post shows up in feeds within a pull. At read time the app view enforces membership, dropping records from anyone not on the member list, which is also where moderation gets its teeth.

Every member's posts and likes sit in their own repos on their own PDS with the same export and migration story as everything else they write, a line I've drawn before and will keep drawing. And "private to the community" means the member list plus the allowlisted service, not secrecy. Any member can screenshot a post. The perimeter is about where the data flows by default, not what a person inside it can do.

The Moderation Space

Moderators need somewhere to talk about the community that isn't the community. The moderation space holds club.dayton-pokemon.moderation.note records, a strawman lexicon for this post:

{
"lexicon": 1,
"id": "club.dayton-pokemon.moderation.note",
"defs": {
"main": {
"type": "record",
"description": "A moderation note about an identity or a record",
"key": "tid",
"record": {
"type": "object",
"required": ["subject", "text", "createdAt"],
"properties": {
"subject": {
"type": "string",
"format": "at-uri",
"description": "An identity (at://did) or a record in a club space"
},
"text": { "type": "string", "maxLength": 3000 },
"createdAt": { "type": "string", "format": "datetime" }
}
}
}
}
}

The subject is an AT-URI either way, because an authority-only at:// URI is valid and names an identity. A note about a person:

{
"$type": "club.dayton-pokemon.moderation.note",
"subject": "at://did:plc:over-sharer",
"text": "Keeps posting exact meetup addresses publicly after being asked to keep them in the club. Two warnings so far.",
"createdAt": "2026-07-17T22:02:00.000Z"
}

A note about a post uses the full space-form record URI as its subject instead. Each moderator's notes live in that moderator's own repo within the moderation space, synced by the app view's mod tools with a credential regular members can't obtain. The notes get the same portability, sync, and deniable commits as everything else in the series. Private mod deliberation without a side-channel Discord is a small thing that I think communities will feel immediately.

The Labels Space

Labels are the odd primitive in atproto. The label spec defines them as free-standing, self-authenticated objects: a source DID, a subject URI, a short value, a timestamp, and a signature made with the issuer's #atproto_label key. They aren't stored in repositories. A labeler service emits them through its own endpoints, and the public com.atproto.label.subscribeLabels stream broadcasts them to anyone listening. That distribution model is exactly wrong for a private community, and the proposal says so directly: public labels about records in a permissioned space leak metadata about the space, so labels should live inside the same access boundary as the content they describe.

When I asked @dholms.at where his current thinking on labels and permissioned data sits, the answer was simpler than I expected: labelers get added to a space "just like any other user," and they create their labels as permissioned records in the space. No parallel subsystem, no special role in the protocol. The labeler is a member.

So the club runs one. did:plc:dayton-pokemon-club-labeler carries the handle @labels.dayton-pokemon.club, publishes an #atproto_label signing key in its DID document, and holds a seat on the labels space member list next to the mod team, plus one on the general space so it can read what it's labeling. Its labels are ordinary TID-keyed records:

{
"lexicon": 1,
"id": "club.dayton-pokemon.label",
"defs": {
"main": {
"type": "record",
"description": "A label applied by the club labeler to an identity or record",
"key": "tid",
"record": {
"type": "object",
"required": ["uri", "val", "createdAt"],
"properties": {
"uri": {
"type": "string",
"format": "uri",
"description": "Subject: an at:// record URI or a did:"
},
"cid": { "type": "string", "format": "cid" },
"val": { "type": "string", "maxLength": 128 },
"exp": { "type": "string", "format": "datetime" },
"createdAt": { "type": "string", "format": "datetime" }
}
}
}
}
}

Notice what's missing compared to the wire format: ver, src, sig, and neg. The spec's own self-label section explains why repository storage can shed them. The record's author supplies the source, the commit provides authenticity, the CID handles versioning, and deleting the record replaces negation. All of that carries over to a permissioned repo, with the twist this series keeps returning to: the commit that authenticates a label inside the boundary is deniable outside it, so a leaked label proves nothing about what the club flagged.

An applied label:

{
"$type": "club.dayton-pokemon.label",
"uri": "at://did:plc:rkh7hrrprgtarfraofpbh4vt/space/club.dayton-pokemon.feed/general/did:plc:over-sharer/app.bsky.feed.post/3m2hf6bzuxk2e",
"cid": "bafyreidwq5mzr2nffcx3k4jyo7vlt6ba2sehg4u3pdmi5xanb7ykzqc4t4",
"val": "sexual",
"createdAt": "2026-07-17T22:15:00.000Z"
}

The app view hydrates these at read time and applies them the way clients apply labeler output today: sexual gets the content warning interstitial, and the spec's bang convention covers behavior values like !hide, which quietly drops a post from feeds while its author still sees it, the shadow-filter case. Whether shadow filtering is good governance is a community question; the mechanism is just records in a space. The rule that makes the whole thing hold together is on the consuming side: the app view only honors label records authored by the club labeler identity. Since the labeler is a member like any other, anyone on the labels space member list could write label-shaped records, and reader enforcement decides that exactly one author counts. Moderators drive the labeler through its tooling rather than labeling from their own accounts.

If the club wants its labels consumable outside its own app view, the spec has already left that door open. Labeler endpoints aren't required to be public, and access control on com.atproto.label.queryLabels is explicitly acceptable. The labeler identity can serve a gated endpoint in front of an index of its own repo in the space, synthesizing full wire labels on the way out by adding ver and src, signing with #atproto_label, and emitting negations for records it has since deleted. Authorized clients then consume the club's labels through an interface they already understand.

The Permission Surface

The grants cover everyone in the club, and with the types split, none of them needs a collection override:

# the club app acting for a member
space:club.dayton-pokemon.feed?authority=did:plc:rkh7hrrprgtarfraofpbh4vt

# moderator tooling: mod notes, plus the feed they moderate
space:club.dayton-pokemon.moderation?authority=did:plc:rkh7hrrprgtarfraofpbh4vt
space:club.dayton-pokemon.feed?authority=did:plc:rkh7hrrprgtarfraofpbh4vt

# the labeler's session: labels, plus read-only access to the feed
space:club.dayton-pokemon.labels?authority=did:plc:rkh7hrrprgtarfraofpbh4vt
space:club.dayton-pokemon.feed?authority=did:plc:rkh7hrrprgtarfraofpbh4vt&action=read

# a member's export tool: their own contributions, nothing else
space:club.dayton-pokemon.feed?authority=did:plc:rkh7hrrprgtarfraofpbh4vt&action=read_self&collection=*

The member grant names the feed type and nothing else, so even the club's own app carries no standing authorization against the moderation or labels spaces when acting for a regular member. Moderator tooling and the labeler each stack a feed grant alongside their own type, since both need to read the content they're acting on, and the labeler's feed access is read-only. The per-space member lists decide whose credential requests actually succeed. The export grant is the same one from the last two posts, and it means a member can walk away with every post, like, and repost they ever wrote without asking the club's permission, which is exactly how it should be.

Space management grants are conspicuously missing from that list. manage verbs against spaces anchored on a community DID are the heart of the deferred management question, and I'd rather write that post than hand-wave it here.

Trade-offs

This whole shape leans on a management layer I've declared out of scope, and until that work settles, "the member list reflects club membership" is doing a lot of quiet lifting. The single-window design is both the feature and the fragility: constraining all viewing to one allowlisted service is what keeps the content, and the moderation of it, inside the community, and it also means the community's view of itself is only as available as that one service. The confidentiality boundary is the same one as always. The club's infrastructure and every member's own PDS read what they hold, nothing is encrypted, and the third corner of the triangle is occupied by a service the club itself operates. And the writer set enumerates every member who has ever posted, so participation itself is visible to anyone holding a space credential, even though lurkers never appear anywhere.