Chapter 24 — Ozone-shaped moderation, bundled
By the end of chapter 19 we had a working operator surface: /admin
for handle-gated dashboard work, com.atproto.admin.* XRPC for
scripted operator actions, and a takedown column on records, blobs,
and accounts. That's everything this PDS needs to moderate its
own content.
It's not what the rest of the AT Protocol world expects, though. In
Bluesky's deployment, moderation lives in a separate service —
Ozone, deployed independently from the PDS. Ozone
holds the event log (every takedown, every label, every operator
comment), the moderator roster, the report queue, and a sophisticated
team workflow on top. It exposes a different lexicon namespace —
tools.ozone.* — that moderation clients (the Bluesky web app's
"Ozone" interface, custom moderation tools) drive directly. Ozone is
also what advertises labels to the network: it signs each label
with its operator account's key and serves them via
com.atproto.label.queryLabels so AppViews can decide whether to
hide / blur / annotate a piece of content.
Our learning port bundles both into one Node process. The /mod web
UI, the tools.ozone.moderation.* XRPC handlers, the labels table,
and the labeler DID-document service entry all ship in this repo
and run alongside the PDS itself. That's the central structural
divergence from upstream — and the rest of this chapter is about why
that's a reasonable choice for a self-hosted PDS and how the moving
parts fit together.
Why bundle?
Upstream's split makes sense at Bluesky's scale: the moderation team operates independently of the PDS operator team, and Ozone needs to scale on a different axis (lots of moderators, lots of reports) than the PDS. Two services let those scale separately.
For a self-hosted PDS, the split is mostly cost. You'd run a second service that's idle most of the time, with a second database, second deploy pipeline, second TLS cert. The moderation surface doesn't need its own scaling envelope — a one-operator, ten-moderator deployment fits comfortably in the same process as the PDS itself.
So we bundle. Same Node process, same Postgres database, same deployment story. The two surfaces stay logically distinct (separate schema, separate UI, separate auth gate) but share the runtime.
What the team lead is
The moderation surface is "owned" by one atproto account on this PDS,
configurable via PDS_MOD_TEAM_HANDLE (default mod.<hostname>).
The operator creates that account through the normal signup flow.
Detection is a single string comparison inside createAccount:
input.handle === cfg.modTeamHandle. Every other handle takes the
plain account path; only this single configured handle gets the
labeler treatment.
Two phases of bootstrap exist, optimised for the common case:
Eager (signup-time) — when createAccount itself sees the lead
handle, the genesis PLC op is built with #atproto_labeler already
included and the app.bsky.labeler.service record is written
inside the signup transaction, before the firehose #identity /
#account events fire. By the time createAccount returns, the
network's first view of the DID is "labeler + PDS" — no follow-up
operations, no second plc.directory call.
Lazy (post-signup) — for the rarer cases where an account
becomes the lead after signup (operator renames an existing
account into the configured handle, or changes
PDS_MOD_TEAM_HANDLE to point at an existing account), the same
checks run lazily inside getModTeamLead(). Each routine
(ensureLeadRow, ensureLeadLabelerService, ensureLeadLabelerRecord)
is idempotent, so they self-heal on first mod-surface read. Once it exists, five things happen on the
next getModTeamLead() call (lazy, cached for the process lifetime):
- A row in
mod_teamwithrole='lead'is auto-seeded. - The account's PLC op is rotated to add an
#atproto_labelerservice entry pointing at the PDS's public URL. The genesis op only includes#atproto_pds; the rotation appends toplc_operationslocally, publishes to plc.directory viapublishPlcOp, and emits#identityon the firehose so AppViews re-resolve the DID document. Idempotent —ensureLabelerServiceinsrc/pds/did/plc.tsshort-circuits when the entry is already present. - An
app.bsky.labeler.serviceself-record is created in the lead's repo, declaring the labeler exists. The DID-doc service entry alone is necessary but not sufficient — bsky.app's AppView surfaces an account as a labeler in its UI by indexing this record. We ship the minimum valid declaration (policies: { labelValues: [] }); the operator can later edit it viaputRecordto declare custom label values, definitions, and a self-applied profile label. - The account's local DID document grows the same entry.
buildDidDocument'sisLabelerflag is set when the DID matches the team lead, so our ownresolveLocalDid/describeReporesponses include#atproto_labeleralongside#atproto_pds. - The labels table is signed with that account's key. Every
label emitted via
tools.ozone.moderation.emitEvent#modEventLabelis signed with the team-lead's repo signing key — the same key that signs the account's own MST commits. Downstream consumers fetch the DID document, find the#atprotoverificationMethod, and verify labels against that public key without further coordination.
The bootstrap happens lazily and self-heals: if you change
PDS_MOD_TEAM_HANDLE to point at a different account later, the
next read clears the cache, the new lead's mod_team row gets
seeded, and the new lead's PLC op gets rotated. The old lead's
labeler entry stays in plc.directory's history — operators wanting
to retract it must rotate the old account's op manually (none of
the canonical Ozone clients require this, so we don't ship a knob).
The lead account's other facts — its handle, its records, its DID document — work like any other atproto account. The moderation surface piggybacks on the account; it doesn't replace it. Conceptually the team lead is the labeler.
Additional moderators are atproto accounts added to mod_team
(role='moderator'). The v1 UI lists them read-only; add and remove
via direct SQL until a follow-up wires a roster page. There's no
"team admin" distinct from the team lead — the lead can act
unilaterally, and admin Basic always unlocks everything regardless of
team membership.
The data model
Four tables (migration 0016_moderation_service.sql):
mod_team roster — DIDs and their roles
mod_events append-only event log (every action ever taken)
mod_subject_status denormalised current-state cache per subject
labels signed labels (the public labeler payload)
mod_events is the source of truth. Every other read can be derived
from it. We keep mod_subject_status as a cache because
queryStatuses is on the hot path of any moderation dashboard and
replaying the event log per request would scale poorly.
A subject is identified by a discriminator:
com.atproto.admin.defs#repoRef—{ did }— an account-level subject.com.atproto.repo.strongRef—{ uri, cid }— a record-level subject.
The shape comes straight from the upstream tools.ozone.moderation.defs
lexicon. mod_events and mod_subject_status store the discriminator
type in subject_type and the typed columns (subject_did,
subject_uri, subject_cid) reflect whichever subject shape was
involved.
emitEvent — applying an action
The hot path. The lexicon defines 25+ event types
(tools.ozone.moderation.defs#mod*Event); we
implement sixteen of them:
| Event type | Side effect |
|---|---|
modEventTakedown |
sets records.takedown_ref / blobs.takedown_ref / accounts.status='takendown'; resolves open reports |
modEventReverseTakedown |
clears the above |
modEventComment |
record-only; no state change |
modEventAcknowledge |
flips mod_subject_status.review_state to acknowledged; resolves open reports |
modEventEscalate |
flips review_state to escalated |
modEventLabel |
signs + appends to the labels table |
modEventMute |
flips review_state to muted |
modEventUnmute |
flips review_state back to open |
modEventDivert |
flips review_state to diverted; resolves open reports |
modEventEmail |
sends an email to the subject account via the existing backend; pulls body from a tools.ozone.communication.* template when templateName is supplied |
modEventTag |
merges event.add/event.remove into mod_subject_status.tags |
modEventMuteReporter |
inserts the subject DID into mod_muted_reporters (consumers join to filter) |
modEventUnmuteReporter |
deletes the matching row |
modEventPriorityScore |
writes event.score to mod_subject_status.priority_score |
modEventResolveAppeal |
flips mod_subject_status.appeal_state to resolved |
revokeAccountCredentialsEvent |
deletes every refresh_tokens row for the subject account, forcing logout on every device |
Unsupported event types return EventTypeNotSupported with a clear
message — a future Bluesky-defined type doesn't silently no-op, so
when an upstream event type starts mattering you get explicit
feedback to wire it.
All emit-time logic lives in
src/pds/mod/events.ts — one
applyEmitEvent() function the XRPC handler and the /mod web UI
call. Both write the same row, run the same side effects, update the
same cache. There's exactly one path from "operator picks an action"
to "state changes."
Auth
requireModerator() in
src/pds/mod/auth.ts accepts two modes:
- Admin Basic — the operator with the admin password is always allowed. Matches the "admin can do anything" invariant from chapter 19. The audit-trail attribution falls back to the team-lead DID since the action wasn't taken under a moderator identity.
- Moderator bearer — a normal atproto access JWT whose subject
DID is in
mod_team.createdByon the event input must equal that DID (otherwise a moderator could impersonate the lead in the audit log).
Scheduled actions
tools.ozone.moderation.scheduleAction accepts a takedown wrapped in
a schedulingConfig (executeAt ISO timestamp, or executeAfter
ISO-8601 duration). The handler writes one mod_scheduled_actions
row per subject with state='pending'.
A background sweep (src/pds/mod/scheduled_actions.ts) polls every
30 seconds for rows whose fires_at has passed. For each due row it
reconstructs the emitEvent input from the stored DAG-CBOR payload
and calls applyEmitEvent() — same code path the live emitEvent
handler uses, so side effects + cache update + auto-resolution all
flow identically. State flips to 'completed' on success or
'failed' (with a reason) on apply error.
tools.ozone.moderation.cancelScheduledActions flips state from
'pending' to 'cancelled'. Already-fired rows are untouched.
tools.ozone.moderation.listScheduledActions paginates by id with
optional filters on states[] and subjects[].
Sweep startup + shutdown is wired through the prod server.ts
entry; in dev the sweep is dormant (we'd otherwise compete with the
operator's manual triggers).
queryEvents, queryStatuses, getEvent — the read surface
Three XRPC handlers cover the read side:
tools.ozone.moderation.queryEvents— paginated history with filters (subject, types, createdBy, time range).tools.ozone.moderation.queryStatuses— paginated current-state view, reading frommod_subject_status.tools.ozone.moderation.getEvent— single event by id.tools.ozone.moderation.getRepo— moderation-context view of an account: profile + currentmod_subject_status+ recent events + applied labels, in one round-trip. HonoursrequireModeratorand does serve takendown accounts because moderators need to see what they're moderating.tools.ozone.moderation.getRecord— same shape for a single record, keyed by AT-URI. Also serves takendown records.tools.ozone.moderation.getRepos/getRecords/getSubjects— batched variants of the above, up to 50–100 entries per call. Mirror input order in the output; missing entries surface asrepoViewNotFound/recordViewNotFound.tools.ozone.moderation.getAccountTimeline— merged stream of reports + events + labels for one account, sorted by createdAt desc. Each entry is taggedkind: 'report' | 'event' | 'label'.tools.ozone.moderation.searchRepos— substring search overaccounts.handleandaccounts.email, plus exact match by DID.tools.ozone.moderation.getReporterStats— per-DID summary (reportedCount,resolvedCount,isMuted) for up to 100 DIDs in one call.
The event view is reconstructed from the DAG-CBOR snapshot
emitEvent persisted, so the response shape matches exactly what the
caller submitted — full fidelity round-trip.
Queues and report management
The reference Ozone has two surfaces sitting on top of
moderation_reports:
tools.ozone.queue.*(8 endpoints) — operator-defined buckets of (subject-type, report-type, collection) routing rules. Created viacreateQueue, listed/edited/deleted via the obvious CRUD verbs, and populated byrouteReports({startReportId, endReportId})which matches each unrouted report's(subjectType, reasonType, collection)against every enabled queue and writes the queue id.tools.ozone.report.*(12 endpoints) — operator-side view of reports as first-class objects.queryReports/getReport/getLatestReportread,assignModerator/unassignModerator/reassignQueuemutate the row,listActivities/createActivitydrive the append-onlymod_report_activitieslog, and the stats trio (getLiveStats/getHistoricalStats/refreshStats) computes counters from SQL on every read (we don't cache; the upstream Redis-backed counter rebuild has no analogue here).
Backing schema:
mod_queues(id, name UNIQUE, subject_types[], report_types[], collection?, enabled, created_by, created_at, updated_at, deleted_at)— operator-defined queues.deleteQueueis a soft-delete (setsdeleted_at+enabled=false); the lexicon's optionalmigrateToQueueIdrewiresmoderation_reports.queue_idin bulk before the queue is hidden.mod_queue_assignments(id, queue_id, did, start_at, end_at)— per-(queue, moderator) attachments. Open assignments haveend_at IS NULL; the queue UI shows the current roster of moderators handling each queue.moderation_reportsgainsqueue_id,assigned_to_did,assigned_at— populated byrouteReportsand the report-level assignModerator handlers.mod_report_activities(id, report_id FK, activity_type ∈ {queue, assignment, escalation, close, reopen, note}, previous_status, internal_note, public_note, meta, is_automated, created_by, created_at)— append-only audit log per report;previous_statuscaptures the report's state at activity time so the UI can render transitions.
Status derivation for a report (open | queued | assigned |
closed | escalated) comes from joining the row against
mod_report_resolution and mod_subject_status:
- assigned_to_did set →
assigned - resolution row exists →
closed - subject's
mod_subject_status.review_stateisreviewEscalated→escalated - queue_id set →
queued - otherwise →
open
No state column on moderation_reports itself — the derived value
is canonical, the activities log is the audit trail.
tools.ozone.server.getConfig
A single endpoint Ozone clients (the moderation UI) call on load to discover which features are available. We populate:
pds.url—cfg.publicUrlappview.url— the canonical bsky.app AppView (https://api.bsky.app)viewer.role—roleAdminfor admin Basic,roleModeratorotherwiseverifierDid— the team-lead DID (the labeler this PDS hosts)
blobDivert and chat are omitted: we don't run a blob-divert
quarantine bucket and chat moderation isn't self-hostable.
The labeler surface
com.atproto.label.queryLabels is the public read endpoint.
Anyone can call it without auth and ask "what labels has the labeler
applied to this URI?" That's how AppViews discover content
moderation decisions: they fetch labels from every labeler their
users have subscribed to and apply them to feeds.
Each label is signed with the team-lead's repo signing key. The
canonical signed form is DAG-CBOR of { src, uri, val, cts, neg, cid? }
— same fields atproto's @atproto/api signs. The sig blob travels
on the wire alongside the label; consumers verify against the
labeler DID's #atproto verificationMethod.
Subscribe — deferred. The full Ozone surface also exposes
com.atproto.label.subscribeLabels over WebSocket so consumers can
tail new labels in real time. We don't ship that yet; v1 polls via
queryLabels. The implementation shape would mirror our firehose:
hand the request off to a WebSocket attached to the same Node http
server (the pattern from chapter 16), tail by labels.seq desc,
re-emit on insert.
The /mod web UI
src/routes/mod/ — server-rendered HTML
mirroring /admin's aesthetic.
| Route | What it does |
|---|---|
/mod |
Dashboard: counts, subject-lookup form, recent reports, recent events. |
/mod/login |
Handle + password form; resulting DID must be in mod_team. |
/mod/logout |
Clear the session cookie. |
/mod/subject?q=… |
Single-subject view: state pills, action form, reports + events history. POST applies an action via applyEmitEvent(). |
/mod/events |
Paginated event history with filters. |
/mod/labels |
Manage the labeler's value catalog (edit the app.bsky.labeler.service record) + paginated emission history. |
/mod/safelink |
URL safety rules — block / warn / whitelist — plus the audit-event log. |
/mod/templates |
Communication templates consumed by modEventEmail. |
/mod/verifications |
Issued verification grants + single-grant form. |
/mod/sets |
Subject-set roster + per-set value editor. |
/mod/settings |
Instance-scope operator config (JSON values + descriptions). |
/mod/signatures |
Per-account fingerprint tagging + related-account research view. |
/mod/team |
Roster + add/remove forms (lead-only mutations). |
The session is a cookie-backed JWT scoped to /mod, separate from
the /admin cookie scope so the two surfaces don't bleed. Admin
Basic in the Authorization header always works — the "signed in
as" pill flips to read admin (Basic) and no cookie is required.
How this maps onto a real Ozone
If you wanted to run this PDS as a federation member of a real Ozone-driven moderation network, two things are true:
- A real Ozone client can drive our XRPC surface. The
tools.ozone.moderation.*endpoints match the canonical lexicon shapes —emitEvent,queryEvents,queryStatuses,getEvent. A client that speaks "talk to Ozone" sees this PDS as an Ozone instance. - AppViews can subscribe to our labels. The labeler DID-document
service entry tells the network where to fetch our labels; the
queryLabelsendpoint serves them; the per-label signatures verify against our team-lead account's public key. No extra handshake.
The structural difference — one process vs. two — is invisible to network consumers.
Try it
# 1. Create the team-lead account.
curl -i -X POST http://localhost:3000/xrpc/com.atproto.server.createAccount \
-H 'content-type: application/json' \
-d '{
"handle": "mod.localhost",
"email": "mod@example.com",
"password": "correcthorsebatterystaple",
"inviteCode": "..."
}'
# 2. Capture the access JWT.
TOKEN=$(curl -s -X POST http://localhost:3000/xrpc/com.atproto.server.createSession \
-H 'content-type: application/json' \
-d '{"identifier":"mod.localhost","password":"correcthorsebatterystaple"}' \
| jq -r .accessJwt)
# 3. Emit a takedown on some account.
curl -i -X POST http://localhost:3000/xrpc/tools.ozone.moderation.emitEvent \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{
"event": { "$type": "tools.ozone.moderation.defs#modEventTakedown", "comment": "spam" },
"subject": { "$type": "com.atproto.admin.defs#repoRef", "did": "did:plc:<target>" },
"createdBy": "did:plc:<the mod.localhost DID>"
}'
# 4. Apply a label.
curl -i -X POST http://localhost:3000/xrpc/tools.ozone.moderation.emitEvent \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{
"event": {
"$type": "tools.ozone.moderation.defs#modEventLabel",
"createLabelVals": ["spam"]
},
"subject": { "$type": "com.atproto.admin.defs#repoRef", "did": "did:plc:<target>" },
"createdBy": "did:plc:<mod did>"
}'
# 5. Read the public labels.
curl 'http://localhost:3000/xrpc/com.atproto.label.queryLabels?uriPatterns=did:plc:<target>'
# 6. See the team-lead DID document advertise the labeler.
curl 'http://localhost:3000/xrpc/com.atproto.repo.describeRepo?repo=mod.localhost' | jq .didDoc.service
# Then visit http://localhost:3000/mod (log in as mod.localhost).
Takedown enforcement on reads
A moderation decision that doesn't bite isn't a decision. After
modEventTakedown flips records.takedown_ref (or blobs.takedown_ref,
or accounts.status), every relevant read endpoint short-circuits
before serving:
| Endpoint | What it checks | Error on hit |
|---|---|---|
com.atproto.repo.getRecord |
records.takedown_ref |
RecordNotFound |
com.atproto.repo.listRecords |
WHERE takedown_ref IS NULL |
row omitted from listing |
com.atproto.sync.getBlob |
blobs.takedown_ref |
BlobNotFound |
com.atproto.sync.getRecord |
accounts.status + records.takedown_ref |
RepoTakendown / RecordNotFound |
com.atproto.sync.getRepo |
accounts.status |
RepoTakendown / RepoDeactivated |
com.atproto.sync.getBlocks |
accounts.status |
RepoTakendown / RepoDeactivated |
The bytes stay in repo_blocks / on disk so a modEventReverseTakedown
can restore them; we only stop serving them. From the caller's
perspective the moderation decision is opaque — a takendown record
looks indistinguishable from a deleted one. That's the property
chapter 17 leans on when it says "the network honors signals or it
doesn't; we just emit."
Two read endpoints intentionally do serve takendown content because they exist for moderation work:
tools.ozone.moderation.getRecord— moderators need to see what they're moderatingtools.ozone.moderation.getRepo— same logic, account scope
Both are gated by requireModerator.
Known gaps
- Age-assurance event types. The
ageAssuranceEvent/ageAssuranceOverrideEvent/ageAssurancePurgeEventfamily needs an age-attestation store we don't ship — the surface lives in the upstream Ozone primarily for KOSA-style compliance flows that the reference operator (Bluesky) implements via a third-party vendor. - Passive
accountEvent/identityEvent/recordEventtypes. These mirror what the firehose already emits as#account/#identity/#commit— we don't double-record into mod_events because the firehose log is already the canonical history. - Chat moderation (
tools.ozone.chat.*). Four endpoints —getActorMetadata,getConvo,getConvoMembers,getMessageContext. We don't self-host chat, so the surface is empty by design. The lexicons reach the AppView via proxy when a caller targetschat.bsky.team. - (Closed — every operator-facing
tools.ozone.*surface in the upstream reference now has a paired/modpage or XRPC handler:labels,safelink,templates,verifications,sets,settings,signatures,team,events,queues,reports,server.getConfig.)
Exercises
- Add
modEventMute/modEventUnmuteto the supported set. The side effect would be an entry in a newmod_muted_actorstable; the/modUI gains a "muted" pill. - Wire
subscribeLabelsas a second WebSocket route alongsidesubscribeRepos. Reuse thesrvx-attached-http server pattern from chapter 16; tail bylabels.seqascending. - Add a
mod_report_resolutiontable linking eachmoderation_reportsrow to themod_events.idthat closed it, then expose aresolved/openfilter on the dashboard. This is the missing piece on the per-report resolution-state gap.