Permissioned Data Shapes: Forums

A forum with public categories, private categories, and one set of record shapes across both. Members own their posts and replies, the forum owns the submission wrappers that admit them into categories, moderators deactivate into tombstones instead of deleting, and a control space tells the app view which permissioned spaces the forum spans.

This is the fourth 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 , and community-moderated content . It's the most layered shape yet, and it builds directly on the last post's community machinery. Same disclaimer as always: the proposal is a draft, and the details underneath this post will move.

The Forum Entity

The forum is The Gem City Forum, at https://forum.thegem.city/. Its identity is did:web:forum.thegem.city with the handle @forum.thegem.city, and using did-method-web was deliberate: the identity bound to the domain, which suits an institution whose name, hosting, and keys all live with one operator. Like any account it can carry an app.bsky.actor.profile record at at://did:web:forum.thegem.city/app.bsky.actor.profile/self, so the forum is discoverable the way anything else in the atmosphere is.

Two design commitments shape everything below. First, the forum describes its content with wrapper records, the submission pattern, rather than with labels. Second, the forum is deliberately open to app views. The Gem City community runs the primary app view for the instance, but nothing discriminates against other clients reading the same records, because members should be able to view forum content anywhere they like. The last post's Pokémon club allowlisted a single window; this forum takes the opposite posture on purpose, and it costs one config value.

Categories

A forum is a list of categories, and each category is a record the forum authors, with a slug for its record key:

at://did:web:forum.thegem.city/community.lexicon.forum.category/general
at://did:web:forum.thegem.city/community.lexicon.forum.category/oregon-district
at://did:web:forum.thegem.city/community.lexicon.forum.category/restaurant-reviews

No space segment in those URIs: public categories are plain public records in the forum's public repository, so the public portion of this forum needs no permissioned machinery at all. The record vocabulary is community.lexicon.forum.*, a strawman set in the chartering pattern this series keeps using, because a forum vocabulary shouldn't belong to one instance: categories, posts, submissions, and moderation notes are the same records whether the forum is this one or the next one, and any app view can index any forum that speaks them.

The category record carries a name, a description, and whatever high-level metadata a category needs:

{
"lexicon": 1,
"id": "community.lexicon.forum.category",
"defs": {
"main": {
"type": "record",
"description": "A forum category, authored by the forum entity",
"key": "any",
"record": {
"type": "object",
"required": ["name", "createdAt"],
"properties": {
"name": { "type": "string", "maxLength": 640, "maxGraphemes": 64 },
"description": { "type": "string", "maxLength": 3000 },
"createdAt": { "type": "string", "format": "datetime" }
}
}
}
}
}

What the record deliberately doesn't include is ordering. The app view lists general first, then regional categories like the Oregon District, then thematic ones like restaurant reviews, and that's presentation, configured in the app view rather than encoded in records. The same goes for thread sorting inside a category: discussions order by creation or last activity, computed at index time, and the wrappers below carry no ordering or index attributes at all.

Threads and Wrappers

I start a thread in general by writing a post. The post is mine, in my repository:

{
"$type": "community.lexicon.forum.post",
"text": "It is really hot in Dayton this weekend.",
"createdAt": "2026-07-19T15:05:00.000Z"
}

That lands at at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/community.lexicon.forum.post/3m2m6kwv3ns2h. The app view then has the forum write the wrapper into the forum's public repo:

{
"$type": "community.lexicon.forum.submission",
"subject": {
"uri": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/community.lexicon.forum.post/3m2m6kwv3ns2h",
"cid": "bafyreihg4c2mzqx7t5one3vwljb6akyfd2snurr5p4bqe6mtwzhkyc4a3e"
},
"category": "at://did:web:forum.thegem.city/community.lexicon.forum.category/general",
"status": "community.lexicon.forum.submission#active",
"createdAt": "2026-07-19T15:05:01.000Z"
}

The wrapper is the forum's answer to one question: does this post appear in this category. A post without a submission isn't in the forum, whatever it says. The lexicons reflect this behavior:

{
"lexicon": 1,
"id": "community.lexicon.forum.post",
"defs": {
"main": {
"type": "record",
"description": "A forum post or reply, authored by a member",
"key": "tid",
"record": {
"type": "object",
"required": ["text", "createdAt"],
"properties": {
"text": { "type": "string", "maxLength": 30000, "maxGraphemes": 3000 },
"reply": { "type": "ref", "ref": "#replyRef" },
"createdAt": { "type": "string", "format": "datetime" }
}
}
},
"replyRef": {
"type": "object",
"required": ["root", "parent"],
"properties": {
"root": { "type": "ref", "ref": "com.atproto.repo.strongRef" },
"parent": { "type": "ref", "ref": "com.atproto.repo.strongRef" }
}
}
}
}

{
"lexicon": 1,
"id": "community.lexicon.forum.submission",
"defs": {
"main": {
"type": "record",
"description": "A forum-authored wrapper admitting a post into a category",
"key": "tid",
"record": {
"type": "object",
"required": ["subject", "category", "status", "createdAt"],
"properties": {
"subject": { "type": "ref", "ref": "com.atproto.repo.strongRef" },
"category": { "type": "string", "format": "at-uri" },
"status": {
"type": "string",
"knownValues": [
"community.lexicon.forum.submission#active",
"community.lexicon.forum.submission#deactivated"
]
},
"createdAt": { "type": "string", "format": "datetime" }
}
}
}
}
}

Replies Reference Posts, Not Wrappers

@mattie.thegem.city replies to my thread. Her post carries a reply reference to my post record, directly:

{
"$type": "community.lexicon.forum.post",
"text": "Wow, it sure is.",
"reply": {
"root": {
"uri": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/community.lexicon.forum.post/3m2m6kwv3ns2h",
"cid": "bafyreihg4c2mzqx7t5one3vwljb6akyfd2snurr5p4bqe6mtwzhkyc4a3e"
},
"parent": {
"uri": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/community.lexicon.forum.post/3m2m6kwv3ns2h",
"cid": "bafyreihg4c2mzqx7t5one3vwljb6akyfd2snurr5p4bqe6mtwzhkyc4a3e"
}
},
"createdAt": "2026-07-19T15:18:00.000Z"
}

And her wrapper, written by the forum when the reply is accepted, is the same shape as mine: her post as the subject, the category, a status. The submission is purely the moderation and acceptance proof for a post in a category, and it carries nothing about threading:

{
"$type": "community.lexicon.forum.submission",
"subject": {
"uri": "at://did:plc:nysigjs52ta6f2l7fdnevadv/community.lexicon.forum.post/3m2m6ycqwak2d",
"cid": "bafyreib5t3kfyqz2wp6vmxaoh4dnl7e2scjgu3qrmy4kbewnzhx6cvd5t4"
},
"category": "at://did:web:forum.thegem.city/community.lexicon.forum.category/general",
"status": "community.lexicon.forum.submission#active",
"createdAt": "2026-07-19T15:18:01.000Z"
}

There's one rule underneath all of this: forum content references other forum content, never the submission records. Replies point at posts. The reason is that submissions may or may not be visible to a given reader, because some of them live in permissioned spaces, and a thread graph that hung off wrappers would fall apart at every visibility boundary. Content references content, the app view derives thread structure entirely from the reply refs on the posts, and the wrappers are the forum's overlay on top of a graph that stands on its own. One edit-related note for implementers: a member can update their post, which changes its CID, so index submissions by matching subject.uri, and treat subject.cid as provenance for what was originally admitted rather than a liveness check.

Private Categories

Neighborhood categories are the private ones. I live in Oakwood and so does Mattie, so we're both members of the Oakwood perimeter, which backs two categories: the neighborhood itself and its restaurant reviews. That's a deliberate feature of the layout. Spaces map to membership lists, not one-to-one to categories, so when two categories share exactly the same audience they can share a space:

{
"lexicon": 1,
"id": "city.thegem.forum",
"defs": {
"main": {
"type": "space",
"description": "Private category spaces for The Gem City Forum",
"key": "any",
"name": "The Gem City Forum",
"collections": [
"community.lexicon.forum.category",
"community.lexicon.forum.post",
"community.lexicon.forum.submission"
]
}
}
}

The Oakwood space sits at at://did:web:forum.thegem.city/space/city.thegem.forum/oakwood, configured with policy: member-list and appAccess: open. Inside it, the same three record shapes as the public side, at permissioned addresses:

at://did:web:forum.thegem.city/space/city.thegem.forum/oakwood/did:web:forum.thegem.city/community.lexicon.forum.category/oakwood
at://did:web:forum.thegem.city/space/city.thegem.forum/oakwood/did:web:forum.thegem.city/community.lexicon.forum.category/oakwood-restaurant-reviews
at://did:web:forum.thegem.city/space/city.thegem.forum/oakwood/did:plc:nysigjs52ta6f2l7fdnevadv/community.lexicon.forum.post/3m2m7cnk5zs2b
at://did:web:forum.thegem.city/space/city.thegem.forum/oakwood/did:web:forum.thegem.city/community.lexicon.forum.submission/3m2m7cq2hbc2n

The forum authors the private category records and the wrappers inside the space; members author their posts into their own permissioned repos on their own PDSes. Functionally there is no difference in record syntax or structure between the public and private halves of this forum, for posts, submissions, and category details alike. Only the addresses differ, a public repository for some and permissioned spaces for others, and that's what lets a forum start public and grow private wings, or the reverse, without changing a single schema.

The Control Space

The app view needs to know which permissioned spaces the forum spans, and that inventory is itself sensitive, since a private category's existence can be private. So the forum keeps a control space, membership limited to the forum entity and its admins and moderators:

{
"lexicon": 1,
"id": "city.thegem.control",
"defs": {
"main": {
"type": "space",
"description": "Operational control records for The Gem City Forum",
"key": "literal:main",
"name": "The Gem City Forum Control",
"collections": ["community.lexicon.forum.spaceRef"]
}
}
}

Inside it, one record per applicable space:

{
"$type": "community.lexicon.forum.spaceRef",
"space": "at://did:web:forum.thegem.city/space/city.thegem.forum/oakwood",
"createdAt": "2026-07-19T14:00:00.000Z"
}

The app view reads the control space, learns the forum's full extent, and augments the public view with every space it's told about. Adding a private category to the forum is writing a category record into a space and a spaceRef into the control space. The control space will accumulate more operational metadata over time, configuration that describes the private aspects of the forum, and I'm leaving that for later on purpose.

Moderation

Every category gets its own moderation space key, public categories included, under a moderation space type:

{
"lexicon": 1,
"id": "city.thegem.moderation",
"defs": {
"main": {
"type": "space",
"description": "Per-category moderation spaces for The Gem City Forum",
"key": "any",
"name": "The Gem City Forum Moderation",
"collections": ["community.lexicon.forum.moderationNote"]
}
}
}

So city.thegem.moderation/general, city.thegem.moderation/oregon-district, city.thegem.moderation/oakwood, and so on, each with its own member list, because not every moderator moderates every category. The note record is the same shape the Pokémon club used, now part of the shared forum vocabulary as community.lexicon.forum.moderationNote: a subject that's an AT-URI naming either an identity or a record, and text.

{
"lexicon": 1,
"id": "community.lexicon.forum.moderationNote",
"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"
},
"text": { "type": "string", "maxLength": 3000 },
"createdAt": { "type": "string", "format": "datetime" }
}
}
}
}
}

A note in the general category's moderation space:

{
"$type": "community.lexicon.forum.moderationNote",
"subject": "at://did:plc:cbkjy5n7bk3ax2wplmtjofq2/community.lexicon.forum.post/3m2m6kwv3ns2h",
"text": "Reported twice for temperature complaints. Dayton in July checks out. No action.",
"createdAt": "2026-07-19T16:40:00.000Z"
}

Two access properties fall straight out of the architecture instead of needing rules. Moderators read their own notes and each other's, because every member of a moderation space reads the whole space. And moderators can't delete each other's notes, because each moderator's notes live in that moderator's own permissioned repo, and nobody writes to a repo they don't own. A requirement that would be an ACL entry anywhere else is just how the storage works here.

Acting on content is a status change, not a deletion. A moderator deactivates a submission, the app view flips status to #deactivated with a putRecord on the forum's wrapper, and the post stops being indexed while a tombstone renders in its place, keeping the thread's shape intact. The member still owns the post, is still responsible for it, and can edit it to satisfy whatever the moderators asked for, after which the submission can go active again. Moderators hide and restore listings; they never hold or delete a member's content. Who gets to be a moderator, and how roles and membership are managed at all, is the bigger post I keep deferring, and the Atmosphere Groups working group is thinking through those ideas in the open, so I'm deliberately not being prescriptive about what that will look like.

The Permission Surface

Public reading needs no grant, and posting to public categories needs only the ordinary repo scopes any client asks for. The permissioned side:

# a member's forum client: the private categories they belong to
space:city.thegem.forum?authority=did:web:forum.thegem.city

# moderator tooling: notes in the categories they moderate, plus the content
space:city.thegem.moderation?authority=did:web:forum.thegem.city
space:city.thegem.forum?authority=did:web:forum.thegem.city

# forum admin tooling: the control space
space:city.thegem.control?authority=did:web:forum.thegem.city

# a member's export tool: their own posts, nothing else
space:city.thegem.forum?authority=did:web:forum.thegem.city&action=read_self&collection=*

Each type declares its own collections, so nothing needs an override, and the per-space member lists decide which credential requests succeed: a member's client can hold the forum grant and still see only the neighborhood spaces they belong to. The forum entity's own session, the one writing categories, wrappers, and spaceRefs, is the same deferred management question as the last two posts.

Trade-offs

Deactivation is listing control, not content control. A tombstoned post still exists in its author's repo, and for public categories it remains publicly addressable; the forum should be honest that moderation governs what the forum presents, not what the network retains.

Public submissions make the public participation graph public, wrapper by wrapper, which is what a public forum is.

On the private side, the open appAccess posture means the member list is the entire perimeter, and any client a member trusts can sync their categories. That's the trade this forum makes for letting members read their forum anywhere; the last post showed the opposite choice, and the same primitive expresses both.

The control space concentrates operational knowledge in one place, which makes it both convenient and a thing to protect.

A wrapper write on the forum's identity for every post and reply in the instance is real overhead, an operational dependency that lands squarely on whatever runs that identity.