Community Forums, Events, and the Moderation Model Behind Them
Community Forums, Events, and the Moderation Model Behind Them
The Problem: Identity Isn't a Community
Attestations, roles, and contact graphs answer "who is this person and how do they relate to the club." None of it answers where the club actually talks to itself, or how it tells members that Saturday's match moved to 3pm. A community platform without a forum and a calendar is just an identity system with nowhere to hang out.
v1.2.0 (2026-06-25) added both — plus, over the following two weeks, the moderation permissions and a security fix that came from actually running it.
Threads, Posts, Events, RSVPs
Forum content lives in the author's own repo, following the same pattern as the contact graph — not in the community's repo:
net.openfederation.forum.thread—{community: did, title, tags?, createdAt}.net.openfederation.forum.post—{community, root: strongRef, parent?: strongRef, text, embeds?, createdAt}.parentis absent for a top-level reply to the thread itself.
Calendar events, by contrast, live in the community's own repo — they're the community speaking, not a member:
community.lexicon.calendar.event—{name, description?, startsAt, endsAt, mode: inperson|virtual|hybrid, status: scheduled|cancelled|postponed, location?, createdAt}.community.lexicon.calendar.rsvp— written to the attendee's own repo, linked back viasubject: strongRefto the event.
POST /xrpc/net.openfederation.forum.createThread
{ "community": "did:plc:club123", "title": "Saturday lineup discussion" }
POST /xrpc/net.openfederation.calendar.createEvent
{ "community": "did:plc:club123", "name": "U-16 vs Riverside", "startsAt": "2026-07-18T14:00:00Z",
"endsAt": "2026-07-18T16:00:00Z", "mode": "inperson", "status": "scheduled", "location": "Hackney Marshes, Pitch 4" }
POST /xrpc/net.openfederation.calendar.rsvp
{ "community": "did:plc:club123", "event": { "uri": "at://did:plc:club123/community.lexicon.calendar.event/3jui...", "cid": "..." }, "status": "going" }
Every one of these is a signed repo record, aggregated into read-optimized index tables (forum_threads, forum_posts, event_rsvps) so listThreads/listEvents/listRsvps don't need to walk MST trees on every read. A backfill script can rebuild those indexes from the underlying repos at any time — the index is a cache, not a source of truth. The generic ATProto repo-write path (com.atproto.repo.createRecord and friends) explicitly rejects writes to forum and calendar collections, forcing every write through the dedicated endpoints so membership and permission checks can't be bypassed by writing raw records directly.
Getting Moderation Right Took Two Passes
The first ship used a single permission, community.forum.write, for both posting and hiding content. On 2026-07-03, that turned out to be wrong: members could hide or unhide content they were only supposed to be able to post. The fix split it into two permissions:
community.forum.write— post threads and replies.community.forum.moderate— hide/unhide threads and posts, see hidden content in listings.
POST /xrpc/net.openfederation.forum.hideThread
{ "uri": "at://did:plc:member456/net.openfederation.forum.thread/3jui...", "hidden": true }
Hiding is a soft, index-only flag — forum_threads.hidden / forum_posts.hidden in the cache — not a deletion of the underlying repo record. That's a deliberate contrast with the delete-as-revoke model used for attestations and contacts: moderation needs to be reversible without needing the original author's cooperation, and the author's signed record staying intact means unhiding doesn't require them to re-post anything.
listThreads and getThread both check the caller's permissions through one shared helper, callerCanModerateForum, extracted specifically so the visibility rule couldn't drift between the two endpoints again. Non-moderators never see a hidden flag on hidden content — they simply don't get it back at all; a hidden thread 404s for them exactly as a nonexistent one would. Moderators get the flag and can toggle it.
A new community.myCapabilities endpoint lets a client ask "what can I actually do here" in one call — {isMember, isOwner, isAdmin, role, permissions[]} — rather than inferring moderation UI from role names that might not map to permissions cleanly. A migration backfilled community.forum.moderate onto every existing signed moderator role record, since roles are signed repo records themselves, not just database rows — retrofitting a permission means re-signing, not just an UPDATE.
The Lesson: Membership Checks Have to Cover Every Read Path
On 2026-07-10, a security fix landed: private communities were leaking their contents to anyone who asked. The bug wasn't in the new forum/calendar endpoints' logic — it was that they, and the generic ATProto repo endpoints (repo.listRecords, repo.getRecord, repo.describeRepo, sync.getRepo), never called the visibility check that community.get and listMembers already enforced. A non-member — even an anonymous caller — could read threads and posts, list events, pull RSVP lists (exposing member DIDs), enumerate membership via repo.listRecords, or export the entire private community as a CAR file via sync.getRepo.
The fix added two guards, applied at every read path that touches community-scoped data:
requireCommunityReadable(req, res, communityDid) // forum + calendar reads
requireRepoReadable(req, res, did) // generic ATProto repo endpoints
Both return 404, not 403, when the caller isn't the owner, an admin, or a member of a private community — so a private community's mere existence isn't revealed to someone probing for it. requireRepoReadable only blocks private community repos; user repos and public-community repos stay ATProto-public, consistent with the "extend, never replace" principle — federation compatibility isn't something privacy features get to break.
Tests now lock this in explicitly: an owner can read everything, anonymous and non-member callers get 404 across all four forum/calendar reads and all three generic repo reads, and — critically — public communities and user repos stay exactly as readable as before, so the fix couldn't have silently over-corrected into breaking federation for everyone else.
One caveat the fix is explicit about: privacy here is enforced by this PDS. Once federation to external relays and AppViews is enabled, private community data will need to be excluded from that federation, or only sent to relays that honor the same gating — that's tracked as separate, future work.
Architecture Principles
- Authorship location signals ownership. Forum content lives in the author's repo; community-level content (events) lives in the community's repo. The write location matches who's speaking.
- Moderation is reversible without the author. Hiding is an index flag, not a deletion — unhide doesn't need the original poster.
- One permission per capability, checked in one place. Posting and moderating are different permissions; visibility logic lives in a single shared helper so it can't fork across endpoints.
- Every read path needs the same gate. A membership check enforced on one endpoint and forgotten on a sibling endpoint is a leak waiting to be found — the fix here was to make the guard reusable and apply it everywhere community-scoped data is read, not just where it was first noticed missing.
What This Enables
- A club runs match-day discussion and fixture scheduling on the same platform that already handles rosters and attestations, with content that federates like any other ATProto record.
- Moderators can hide a problematic thread without deleting the author's record or needing them to cooperate.
- A private community — say, a club's internal disciplinary discussions — stays genuinely private across every read path, not just the ones someone remembered to gate.
- Clients can build moderation UI directly from
myCapabilitiesinstead of hardcoding assumptions about what a "moderator" role can do.
Follow the work
Occasional, meaningful updates only.