Zum Inhalt springen

Moderation

Status: Prio 1 und 2 umgesetzt (2026-11-07), Prio 3 offen. Regeln: VERFASSUNG Titel X und XI und VO Moderation. Minimum vor prod: PRODUCTION_DEPLOYMENT.md. Umsetzung, Entscheide und verschobene Punkte: MODERATION_UMSETZUNG.md.

Payload entscheidet, der Registrar setzt um und signiert die Labels, und die übrigen Dienste folgen den Labels. Ozone kommt nicht zum Einsatz.

Dieses Dokument ist die Referenz für den Funktionsumfang der Moderation, gegliedert in Prio 1 bis 3. Die erste Umsetzung umfasst Prio 1 und 2; Prio 3 folgt später. Mit ✱ markierte Punkte stehen nicht in der Verordnung und sind noch nicht besprochen.

Grundsätze:

  • Eine Stelle entscheidet. Moderationsentscheide fallen nur in Payload; alle anderen Komponenten führen aus. In den Store gelangt Payload nur über den Registrar.
  • Sichtbarkeit nur über Labels. Ob etwas ausgeblendet ist, ergibt sich allein aus den Labels des eigenen Labelers. Das Einklappen externer Antworten ist keine Moderation und braucht kein Label.
  • Wirkungen als Jobs. Jede Wirkung eines Entscheids ist ein Payload-Job, der sich wiederholen lässt, ohne doppelt zu wirken.
  • Das Repo bleibt unangetastet. Ausblenden und Sperren wirken im Index und beim Zugang zum PDS, nie durch Löschen im Repo der Person. Einzige Ausnahme ist die rechtlich verlangte Löschung (VO § 2 Abs. 7).
  • Eine Regel für hidden. Sie steht nur im Indexer; die AppView liest das Ergebnis.
CLIENT smartinfo
Frontend → client-api ──Meldungen, Einsprüche──→ Payload (Fälle, Entscheide, Regeln, Jobs)
│ │ │ │
│ └←──── Record löschen (Job) ───────┘ │ Moderations-Key
│ Kontostatus beim Login │
────────────────┼────────────────────────────────────────┼─────────────
STORE atvoto ▼ ▼
PDS ←──────── Takedown, Restore ──────── Registrar (signiert Labels)
│ schreibt
▼
Tabelle labels
lesen │ │ lesen
▼ ▼
Indexer AppView → andere Apps
setzt hidden, (queryLabels)
rechnet Zähler
│ Flag hidden
┌──────────┴──────────┐
▼ ▼
AppView Writer
(filtert) (Spiegelposts, Übersetzung,
Embeddings, Peer Review)

Es kommt kein neuer Dienst dazu. Neu sind nur das Labeler-Konto und die Meldestelle; die übrigen Komponenten werden erweitert, der PDS bleibt unverändert.

Komponente Ort Aufgabe in der Moderation
Payload Client Fälle, Meldungen, Entscheide, Einsprüche, Benachrichtigungen und Team. Setzt alle Regeln durch und startet die Wirkungen als Jobs.
client-api Client Nimmt Meldungen und Einsprüche aus der App an und reicht sie an Payload weiter. Prüft beim Login und nach einem 401 des PDS den Kontostatus. Löscht bei einer rechtlich verlangten Entfernung den Record im Repo der Person.
Frontend Client Melde-Knopf, Platzhalter, Benachrichtigungen, Sperrseite.
Meldestelle Client Neu. Formular oder E-Mail für Personen ohne Melde-Knopf (VO § 4 Abs. 2). Das Team erfasst diese Meldungen in Payload.
Registrar Store Einziger Weg von Payload in den Store. Liefert die Daten zum Fall, signiert und speichert Labels, führt Widerruf, Takedown, Restore und Löschen aus. Hält den Signaturschlüssel und das PDS-Admin-Passwort.
Indexer Store Liest neue Labels und führt das Flag hidden nach. Rechnet Zähler neu. Lehnt Beiträge von Konten mit widerrufener Teilnahme über participation_check ab.
Writer Store Folgt dem Flag hidden: entfernt Spiegelposts oder stellt sie wieder her, überspringt Ausgeblendetes bei Übersetzung, Embeddings und Peer Review. Lehnt Beiträge von Konten mit widerrufener Teilnahme über participation_check ab.
AppView Store Filtert nach dem Flag und zeigt Platzhalter. Liefert die gespeicherten, signierten Labels öffentlich aus. Hält keinen Schlüssel und nimmt keine Schreibaufrufe für die Moderation an.
PDS Store Unverändert. Takedown und Restore über die Admin-API, aufgerufen nur vom Registrar. Der Kontostatus ist öffentlich abfragbar.

Der Labeler ist kein eigener Dienst. Der Registrar signiert beim Schreiben, danach liegt die Signatur in der Tabelle, und die AppView liefert sie nur noch aus. Ein eigener Labeler-Dienst brächte mehr Betrieb ohne klaren Gewinn; die öffentliche Lese-API hält so weiterhin keine Schreib-Secrets (ARCHITECTURE_SPLIT E19).

Client und Store teilen kein internes Netz: Der Client nutzt nur öffentliche URLs, die Admin-Routen des PDS sind von aussen gesperrt, und prod bekommt eigene Cluster.

Von Nach Zweck Absicherung
Frontend client-api Meldung, Einspruch, Benachrichtigungen Sitzung der Person
client-api Payload Meldung oder Einspruch anlegen, Benachrichtigungen und Sperrgrund lesen Client-intern, API-Key eines Dienst-Kontos in Payload
client-api PDS Kontostatus (com.atproto.sync.getRepoStatus) öffentlich, ohne Anmeldung
Payload-Jobs client-api Record im Repo der Person löschen Client-intern, API-Key
Payload Registrar Daten zum Fall, Label, Widerruf, Takedown, Restore, Löschen öffentliche URL, Moderations-Key
Registrar PDS Takedown, Restore Store-intern, Admin-Passwort
Indexer, AppView Tabelle labels neue Labels lesen DB-Rolle, nur Lesen
Writer, AppView Flag hidden Ausgeblendetes erkennen DB-Rolle, nur Lesen

Sieben Collections tragen die Moderation; decisions ist zugleich das Protokoll und lässt sich nur anlegen. Alle Bezeichner sind englisch: Payload-Felder in camelCase, Spalten im Store in snake_case.

Payloads Standard-Collection users mit persönlichem Login, ergänzt um die Felder role, case und active.

Feld Typ Inhalt
name Text Anzeigename im Protokoll
email E-Mail Login
role Auswahl moderator, substitute
case Beziehung zu cases nur bei substitute: der eine Fall
active Ja/Nein Nein sperrt den Login

moderator darf in der Moderation alles, alle mit denselben Rechten. substitute sieht und entscheidet nur ihren Fall. Zugänge zur Moderation verwaltet der Payload-Admin. Die Stellvertretung bestimmt der Betreiber, der Payload-Admin richtet sie ein; alles Weitere organisiert das Team selbst.

Pflegt jemand im CMS andere Inhalte, bekommt diese Person keine dieser Rollen. Die Zugriffsregeln der Moderation prüfen deshalb immer die Rolle, nie nur das Login.

Ein Fall pro Beitrag oder Konto, von Anfang an.

Feld Typ Inhalt
subjectType Auswahl post oder account
uri Text AT-URI, bei Beiträgen
did Text das Konto, bei Beiträgen die verfassende Person
subjectKey Text URI oder DID: Schlüssel für «höchstens ein offener Fall»
contentType Auswahl etwa argument, comment, expertOpinion; beim Anlegen aus dem Index
status Auswahl open, decided, dismissed
recused Beziehung zu users, mehrere wer nicht mitentscheidet
snapshot JSON Inhalt zum Zeitpunkt der ersten Meldung ✱; wird mit der Frist für Meldungen und Protokoll gelöscht (VO § 9 Abs. 2), bei einer rechtlich verlangten Entfernung sofort

Pro Gegenstand gibt es höchstens einen offenen Fall; neue Meldungen hängen sich an ihn. Für die Schutzregeln zählt der Stand im Index beim Entscheid, nicht das Feld contentType.

Feld Typ Inhalt
case Beziehung zu cases der Fall, an dem die Meldung hängt
source Auswahl app, manual, external (Prio 3)
reporterDid Text bei app und external
reporterName Text Pseudonym bei der Meldung, von der client-api; nur zum Wiedererkennen im Team
reporterContact Text bei manual, etwa die E-Mail einer externen Person
reason Auswahl einer der sechs Label-Werte aus VO § 3 oder other
comment Text optional
muted Ja/Nein gespeichert, aber nicht in der Warteschlange (Prio 2)

Gemeldet werden nur Beiträge (VO § 4 Abs. 5), ein Fall zu einem Konto hat keine Meldungen. Anlegen nur durch client-api (mit API-Key) und Team; ändern und löschen niemand; ausgenommen ist das Löschen nach Ablauf der Frist (VO § 9 Abs. 2).

Das Protokoll: wer was wann und warum entschieden hat.

Feld Typ Inhalt
case Beziehung zu cases der Fall, zu dem entschieden wird
action Auswahl dismiss, hide, revokeParticipation, suspend, reverse, lift, delete, confirmDelete, appeal
targets Liste von Texten URIs oder DID; mehrere bei einer Sammel-Massnahme (Prio 2)
reason Auswahl Label-Wert aus VO § 3; die Auswahl zeigt die deutschen Namen. Pflicht bei hide, revokeParticipation, suspend und delete
severeAbuse Ja/Nein bei suspend: Bestätigung, dass schwerer Missbrauch vorliegt ✱
justification Text Pflicht, nur intern
relatesTo Beziehung zu decisions bei reverse, lift, confirmDelete und appeal: der betroffene Entscheid
appeal Beziehung zu appeals bei appeal
outcome Auswahl bei appeal: granted oder denied
decidedBy Beziehung zu users automatisch, wer angemeldet ist
decidedAt Datum automatisch

Anlegen dürfen moderator und substitute; ändern und löschen niemand, zusätzlich gesichert durch einen Trigger in der Datenbank ✱. Der Trigger lässt nur das Löschen oder Anonymisieren nach Ablauf der Frist zu (VO § 9 Abs. 2). Wie weit die Jobs eines Entscheids sind, zeigt Payloads Job-Tabelle, damit decisions unverändert bleibt.

Feld Typ Inhalt
decision Beziehung zu decisions der angefochtene Entscheid
did Text wer Einspruch erhebt
statement Text Begründung der Person
status Auswahl open, granted, denied

Anlegen durch client-api und Team, etwa bei einem Einspruch per E-Mail. Den Status setzt nur ein Entscheid mit action = appeal.

Für das Stummschalten aus Prio 2.

Feld Typ Inhalt
did Text die meldende Person
mutedUntil Datum leer heisst nicht stumm, ein weit entferntes Datum unbefristet
note Text intern

Versionen sind eingeschaltet, damit nachvollziehbar bleibt, wer wann stummgeschaltet hat.

Benachrichtigungen bleiben im Client. Die client-api liefert sie an die Person aus; der Store kennt sie nicht.

Feld Typ Inhalt
did Text wer benachrichtigt wird
decision Beziehung zu decisions der Entscheid, mit Grund, ohne Begründung und ohne meldende Person
kind Auswahl decision, appealResult
readAt Datum leer heisst ungelesen

Jede Regel sitzt an genau einer Stelle in Payload: als Pflichtfeld, als Prüfung vor dem Speichern oder als Zugriffsregel.

Regel Umsetzung Collection
Grund aus VO § 3 bei jeder Massnahme Pflichtfeld reason, abhängig von action decisions
Begründung fürs Protokoll Pflichtfeld justification decisions
Gutachten nie ausblenden, sperren oder löschen Prüfung vor dem Speichern, Inhaltstyp live aus dem Index decisions
Angenommenes Argument nur bei «rechtswidrig» Prüfung vor dem Speichern: nur reason = illegal, Status live aus dem Index decisions
Befangene entscheiden nicht Zugriffsregel: Wer in recused steht, kann zum Fall keinen Entscheid anlegen decisions
Einspruch durch eine andere Person Prüfung: decidedBy ist nicht die Person des angefochtenen Entscheids decisions
Löschung nur rechtswidriger Inhalte, mit zwei Personen, ohne Einspruch Prüfung: delete nur mit reason = illegal und nur auf dem eigenen PDS; confirmDelete durch eine andere Person; appeal nicht gegen delete oder confirmDelete (VO § 2 Abs. 7) decisions
Sperre nur bei Rechtswidrigkeit oder schwerem Missbrauch Prüfung: suspend nur mit reason = illegal oder severeAbuse = Ja (Art. 45 Abs. 2) decisions
Konto-Label nur bei Mitgliedern Prüfung: Bei Personen ohne Mitgliedschaft nur die einzelne Antwort labeln, nie die DID (VO § 2 Abs. 5) decisions
Stellvertretung nur für ihren Fall Zugriffsregel auf case. Nach ihrem Entscheid setzt ein Hook active auf Nein, ein beforeLogin-Hook weist sie danach ab users
Mindestens zwei Personen im Team Prüfung: nie weniger als zwei aktive Konten mit Rolle moderator users
Meldung bewirkt nie etwas Kein Hook auf reports legt einen Entscheid oder ein Label an reports
Meldende Person bleibt verborgen Jobs übergeben nie Felder der Meldung an eine Benachrichtigung Jobs

Sind alle im Team befangen, bestimmt der Betreiber eine Stellvertretung für diesen Fall, und der Payload-Admin legt sie an.

Nach dem Speichern eines Entscheids legt ein Hook die nötigen Jobs an. Jeder Job nutzt die ID des Entscheids als Schlüssel und wirkt deshalb nur einmal, auch wenn Payload ihn nach einem Fehler wiederholt.

Entscheid (action) Jobs
dismiss keine; der Fall wird dismissed, ab Prio 2 mit Rückmeldung an die meldenden Personen
hide, Beitrag setLabel auf die URI · notify
hide, Konto setLabel auf die DID · notify
revokeParticipation setParticipationRevoked · notify
suspend, Beitrag wie hide, dazu bei gehosteten Konten pdsTakedown für den Record
suspend, Konto setLabel auf die DID · setParticipationRevoked · bei gehosteten Konten pdsTakedown für das Konto · notify
reverse negateLabel · notify
lift negateLabel · clearParticipationRevoked · pdsRestore · notify
delete keine; der Vorschlag wartet auf die Bestätigung durch eine zweite Person
confirmDelete setLabel, falls noch keines besteht · deleteRecord · purgeContent · notify · danach purgeLabels
appeal granted: dieselben Jobs wie reverse oder lift; denied: notify

Bei der Kontosperre laufen Label und Widerruf der Teilnahme vor dem Takedown.

Job Ruft Wirkung
setLabel Registrar signiert das Label und speichert es in labels: auf URI oder DID, bei Argumenten auf die URI der kanonischen Kopie im Community-Konto (VO § 2 Abs. 3), Wert = Label-Wert zum Grund
negateLabel Registrar dasselbe Label mit neg: true
setParticipationRevoked, clearParticipationRevoked Registrar revokeParticipant setzt registry.participants.revoked_at; die neue Route reinstateParticipant ✱ löscht es wieder
pdsTakedown, pdsRestore Registrar ruft com.atproto.admin.updateSubjectStatus für Konto, Record oder Blob auf; nur bei gehosteten Konten
deleteRecord client-api, Registrar löscht den Record wirklich: im Repo der Person über die client-api mit ihrem App-Passwort, im Community-Konto über den Registrar. Die Admin-API des PDS kann das nicht. Ist das Konto gesperrt, braucht es zuerst ein Restore
notify Payload legt eine Benachrichtigung in notifications an
purgeContent Registrar, Payload löscht den Inhalt aus Index, Übersetzungen, Embeddings, Caches, dem aufbewahrten Spiegel-Record und dem snapshot im Fall
purgeLabels Registrar entfernt die Labels auf einem gelöschten Inhalt; bei einer Kontolöschung auch die Labels auf dem Konto und auf seinen gelöschten Beiträgen (VO § 9 Abs. 1)
purgeExpired Payload löscht oder anonymisiert Meldungen, Protokoll und snapshot nach Ablauf der Frist (VO § 9 Abs. 2); Prio 4, die Frist ist noch offen

Nur der Indexer leitet aus Labels die Sichtbarkeit ab. Die AppView liest sein Ergebnis: ein Flag hidden pro Beitrag. Der Indexer liest neue Einträge direkt aus der Tabelle labels und speichert seinen Stand (seq) in indexer_cursors. Er wertet nur Labels der eigenen Labeler-DID aus.

hidden(post) =
activeLabel(post.uri)
OR ( activeLabel(post.authorDid)
AND post.contentType != 'expertOpinion'
AND NOT post.accepted )

activeLabel heisst: Das neueste Label mit diesem Wert ist nicht negiert. Payload verhindert, dass ein Gutachten ein Beitrags-Label bekommt, und lässt bei angenommenen Argumenten nur illegal zu.

  • Listen, Suche, Aktivität, Feed: Die AppView filtert nach dem Flag, für alle Personen.
  • Zähler: Betroffene Zähler werden aus den Daten neu berechnet, nicht hoch- und runtergezählt. Der Indexer ist ihr Eigentümer (refreshLikeCount und die Neuzählung der Kommentare in services/indexer/src/db.js).
  • Übersetzung und Ähnlichkeitssuche: Die Jobs im Writer überspringen ausgeblendete Beiträge, vorhandene Embeddings werden entfernt. Nach einer Rücknahme werden sie neu erzeugt.
  • Peer Review: Ein ausgeblendetes Argument verhält sich nach aussen wie ein geschlossenes Peer Review; state bleibt unverändert, nach einer Rücknahme läuft es weiter. Eine schon abgeschickte Stimme wartet und wird nicht abgelehnt.
  • Verdichtung: berücksichtigt beim nächsten Lauf nur Beiträge ohne Flag.
  • Kommentare: Antworten bleiben sichtbar; anstelle des ausgeblendeten Kommentars zeigt die AppView einen Platzhalter.
  • Gutachten: hidden gilt nicht für sie, auch nicht bei einem Label auf dem Konto. Sie zählen weiter (Art. 29, VO § 2 Abs. 6).
  • Externe Antworten: Die AppView zeigt Antworten von Personen ohne Mitgliedschaft eingeklappt mit dem Hinweis «extern, nicht bei smartinfo registriert». Das ist keine Massnahme und braucht kein Label (VO Interop. § 5).

Spiegelposts folgen dem Flag, einen eigenen Auftrag gibt es nicht. Der Writer löscht den Spiegelpost eines ausgeblendeten Arguments und bewahrt den Record auf. Bei einer Rücknahme legt er ihn ✱ mit gleichem rkey und identischem Record wieder an, also unter derselben Adresse. Angenommene Argumente erfasst ein Konto-Label nicht, ihre Spiegelposts bleiben.

Widerrufene Teilnahme: Das bestehende participation_check setzt sie in Writer und Indexer durch und schliesst die Person auch vom Los fürs Peer Review aus. Massgeblich ist der Zeitpunkt, zu dem der Dienst den Record sieht, nicht createdAt. Bestehende Beiträge bleiben, solange sie kein Label tragen; Änderungen an ihnen übernimmt der Indexer nicht ✱. Löschungen durch die Person übernimmt er weiterhin (Art. 3, Art. 16a).

PDS-Takedown: Er erscheint im Firehose als #account mit status: takendown. Der Indexer verarbeitet dieses Ereignis heute nicht und braucht es hier auch nicht: Gutachten und angenommene Argumente bleiben im Index, der Rest ist über das Label ausgeblendet. Weil der PDS die Records danach nicht mehr ausliefert, fände ein Neuaufbau des Index aus dem Netz die Gutachten nicht mehr. Im Notfall holt der Betreiber sie dann von Hand direkt aus dem eigenen PDS.

Der Labeler ist ein eigenes Konto auf dem atvoto-PDS, etwa «smartinfo Moderation», aber kein eigener Dienst. Der Registrar signiert und speichert die Labels ab dem Start, die AppView veröffentlicht die gültigen Labels und ihre Rücknahmen.

  • Ab dem Start steht der öffentliche Schlüssel als #atproto_label in der DID, sonst lässt sich die Signatur nicht prüfen.
  • Prio 3: Die Dienst-Adresse #atproto_labeler und die Deklaration der Label-Werte kommen dazu.

Die Label-Werte stehen in VO § 3. Sie sind englisch, wie in ATProto üblich.

Endpunkt Ort Zugang Zweck
com.atproto.label.queryLabels AppView öffentlich öffentliche Schnittstelle; ohne Begründung, ohne meldende Person, ohne decision_id
ch.atvoto.registrar.moderation.* Registrar Moderations-Key Payload-Jobs: Daten zum Fall, Labels, Takedown, Restore, Löschen
com.atproto.label.subscribeLabels AppView öffentlich Prio 3: Live-Abo für andere Apps
com.atproto.moderation.createReport AppView Service-Auth Prio 3: Meldungen aus fremden Apps

Die Tabelle labels wird nur ergänzt und nicht geändert, ausser wenn der Inhalt oder das Konto gelöscht wird (VO § 9 Abs. 1). Eine Rücknahme ist ein neuer Eintrag mit neg.

Spalte Inhalt
seq fortlaufende Nummer, zugleich Stand für Indexer und Writer
ver Version des Label-Formats, immer 1; wird mitsigniert
src DID des Labelers
uri AT-URI des Beitrags oder DID des Kontos
cid bleibt leer: Labels zum Ausblenden gelten für jede Version, sonst umgeht eine neue Version des Records das Label
val Label-Wert
neg Rücknahme
cts Zeitpunkt
sig Signatur
decision_id ID des Entscheids in Payload, nur intern ✱; bildet mit uri und val den Schlüssel gegen doppelte Labels

Signiert wird nach der Label-Spezifikation: das Label mit ver, aber ohne sig, in DAG-CBOR kodieren, mit SHA-256 hashen und mit dem #atproto_label-Schlüssel signieren, im kompakten 64-Byte-Format und mit Low-S. Signiert und ausgeliefert werden genau dieselben Felder. Der Registrar braucht dazu zwei Bibliotheken im Writer-Image: cryptography und libipld.

  • labels läuft in der normalen Sicherung von Postgres mit.
  • Der Signaturschlüssel liegt nur beim Registrar, in registrar-secrets.
  • Der Moderations-Key ist ein zweiter Schlüssel pro Client (registry.clients) und liegt nur im CMS. Mit dem normalen STORE_CLIENT_KEY, den auch die client-api hält, lassen sich keine Moderationsrouten aufrufen; das gilt auch für revokeParticipant.
  • Der Registrar prüft, ob das Konto zum aufrufenden Client gehört. Takedown und Restore führt er nur bei gehosteten Konten aus.
  • Prio 3: createReport legt die Meldung im Store ab, Payload holt sie über den Registrar ab und setzt source = external. Das setzt eine Anpassung von VO Moderation § 4 Abs. 2 und VO Interop. § 7 Abs. 2 voraus. subscribeLabels liefert negierte Labels nach der Spezifikation nicht mehr aus, nur noch die Rücknahme.

Beim Login prüft smartinfo kurz, ob das PDS-Konto gesperrt ist, und sperrt dann auch das smartinfo-Konto. Der PDS bleibt die massgebende Quelle; das smartinfo-Konto bekommt in auth_creds zusätzlich die Spalte suspended_at.

  1. Nach dem Magic Link oder Short Code fragt die client-api com.atproto.sync.getRepoStatus ab, mit kurzem Timeout.
  2. Status takendown oder suspended: Sie setzt suspended_at, löscht bestehende Sitzungen, erstellt keine neue und antwortet mit account_suspended. Die App zeigt Entscheid, Grund (aus decisions) und die Adresse für den Einspruch. Fehlt ein Entscheid, etwa nach einem Takedown von Hand, erscheint nur der allgemeine Hinweis mit der Adresse.
  3. Konto wieder aktiv: Sie löscht suspended_at, und der Login läuft normal.
  4. PDS nicht erreichbar: Massgebend ist suspended_at. Ein Ausfall des PDS sperrt niemanden neu aus.

Während einer laufenden Sitzung wirkt derselbe Ablauf, sobald ein Aufruf am PDS mit 401 scheitert: Die client-api fragt dann den Kontostatus ab. Der Fehlercode allein genügt nicht, denn createSession meldet bei einer Sperre nur AuthenticationRequired, und AccountTakedown erhält nur ein noch gültiges Token. Lesen über ein noch gültiges Token funktioniert bis zu seinem Ablauf weiter, höchstens eine Stunde; Schreiben nicht. Der Grund erscheint erst nach dem Magic Link, also nur bei der Person mit Zugriff auf die E-Mail-Adresse.

Meldung bis Ausblenden

  1. Ein Mitglied meldet einen Kommentar. Die client-api prüft die Sitzung und reicht die Meldung an Payload weiter.
  2. Payload hängt die Meldung an den offenen Fall oder legt einen an und sichert einen snapshot.
  3. Eine Person aus dem Team öffnet den Fall und sieht Inhalt, Kontext, Grund und meldende Person.
  4. Sie wählt hide mit Grund «Beschimpfung» (insult) und schreibt die Begründung. Payload prüft die Regeln und speichert den Entscheid.
  5. Die Jobs setLabel und notify setzen das Label und benachrichtigen die betroffene Person.
  6. Der Registrar signiert das Label und speichert es. Der Indexer liest es, setzt hidden, rechnet Zähler neu und nimmt den Kommentar aus Übersetzung und Ähnlichkeitssuche.
  7. Die AppView zeigt einen Platzhalter; Antworten bleiben sichtbar.

Kontosperre mit Einspruch

  1. Das Team stellt schweren Missbrauch fest und sperrt ein gehostetes Konto mit suspend, Grund «Umgehung» (circumvention).
  2. Die Jobs setzen das Label auf die DID, widerrufen die Teilnahme und setzen danach den PDS-Takedown. Sie benachrichtigen die Person, und der Writer entfernt die Spiegelposts nicht angenommener Argumente.
  3. Der Indexer blendet die Beiträge aus, ausser Gutachten und angenommenen Argumenten, und übernimmt keine neuen Records.
  4. Beim nächsten Login erkennt die client-api die Sperre auf dem PDS und sperrt auch das smartinfo-Konto. Die App zeigt Entscheid, Grund und die Adresse für den Einspruch.
  5. Die Person erhebt Einspruch per E-Mail; das Team erfasst ihn in Payload. Eine andere Person aus dem Team entscheidet.
  6. granted: Label negiert, Teilnahme wieder erlaubt, Takedown aufgehoben, smartinfo-Konto beim nächsten Login wieder frei, die Beiträge zählen wieder. denied: nur eine Benachrichtigung.
Bereich Funktion Umsetzung
Melden Einzelnen Beitrag mit Grund melden; Konten meldet niemand, sie nimmt sich das Team selbst vor client-api nimmt an, Payload reports
Melden Meldungen von Hand erfassen Payload: Formular, source = manual
Melden Meldung bewirkt nie automatisch etwas Payload: kein Hook mit Wirkung
Sichten Warteschlange offener Meldungen Payload: Liste cases, status = open
Sichten Inhalt, Konto, Grund, meldende Person Payload: Fall-Ansicht, Daten über den Registrar, auch für Ausgeblendetes
Sichten Beiträge von sich aus durchsehen, Fall öffnen ✱ Payload: Ansicht «Beiträge selbst prüfen» (/admin/moderation/recent), nur moderator; Daten über den Registrar, auch Ausgeblendetes
Sichten Bisherige Meldungen und Massnahmen eines Kontos Payload: Abschnitt in der Fall-Ansicht, gefiltert nach did; nur moderator
Sichten Zusammenhang, etwa Kommentarverlauf ✱ Payload: eigene Komponente lädt ihn über den Registrar
Sichten Status offen, entschieden, abgewiesen ✱ Payload: Feld status am Fall
Entscheiden Abweisen ohne Massnahme Payload: action = dismiss
Entscheiden Beitrag oder Konto samt Beiträgen ausblenden Payload, Registrar, Indexer: hide, Label auf URI oder DID
Entscheiden Wert aus VO § 3 bei jeder Massnahme Payload: Pflichtfeld reason
Entscheiden Externe Antworten ohne Nutzungsbedingungen AppView: eingeklappt mit Hinweis, kein Label
Entscheiden Begründung fürs Protokoll Payload: Pflichtfeld justification
Entscheiden Handeln ohne Meldung ✱ Payload: Fall ohne Meldung anlegen, aus AT-URI, DID oder smartinfo-Link
Entscheiden Massnahme zurücknehmen, Beitrag zählt wieder Payload, Registrar, Indexer: reverse, Negations-Label, Zähler neu
Schutzregeln Gutachten geschützt Payload: Prüfung; Indexer: Regel für hidden
Schutzregeln Angenommenes Argument nur bei «rechtswidrig» Payload: Prüfung; Indexer: Regel für hidden
Schutzregeln Nichts im Repo löschen alle: Labels und Takedown wirken ausserhalb des Repos; Ausnahme ist die rechtlich verlangte Löschung
Schutzregeln Befangenheit, Stellvertretung Payload: Feld recused, Rolle substitute
Stärkere Massnahmen Teilnahme widerrufen Registrar setzt registry.participants.revoked_at; Writer und Indexer lehnen über participation_check ab
Stärkere Massnahmen Eine Person sperrt allein Payload: keine zweite Freigabe
Stärkere Massnahmen Kontosperre mit Label, Widerruf der Teilnahme, PDS Payload-Jobs über den Registrar: setLabel, setParticipationRevoked, pdsTakedown
Stärkere Massnahmen Externe Konten nur ausblenden und Teilnahme widerrufen Registrar: pdsTakedown nur bei gehosteten Konten
Stärkere Massnahmen Sperre aufheben ✱ Payload: action = lift
Stärkere Massnahmen Rechtswidrigen Inhalt löschen, endgültig, mit zwei Personen Payload: delete, confirmDelete; Jobs: deleteRecord, purgeContent
Wirkung Weg aus Listen, Suche, Aktivität, Feed, Verdichtung Indexer setzt hidden, AppView filtert
Wirkung Zählt in keinem Zähler, auch Bewertungen Indexer: hidden auch auf app_likes, Zähler neu berechnen
Wirkung Keine Übersetzung, keine Ähnlichkeitssuche Writer: Jobs überspringen, Embeddings entfernen
Wirkung Antworten sichtbar, Platzhalter AppView: Darstellung
Wirkung Spiegelpost entfernen Writer: folgt dem Flag hidden
Betroffene Entscheid und Grund als Benachrichtigung Payload: Job notify, Collection notifications; client-api liefert aus
Betroffene Kontosperre beim nächsten Login client-api: prüft beim Login den PDS-Status und sperrt auch das smartinfo-Konto; Frontend: Sperrseite
Betroffene Nie, wer gemeldet hat Jobs übergeben keine Felder der Meldung
Einspruch Ohne Frist gegen jede Massnahme; die Löschung ist keine client-api nimmt an, Payload appeals; nach einer Kontosperre per E-Mail
Einspruch Andere Person entscheidet Payload: Prüfung auf decidedBy
Einspruch Ergebnis mitteilen ✱ Payload: Job notify
Protokoll Wer, was, wann, warum Payload: decisions
Protokoll Nicht änderbar ✱ Payload: nur anlegen; Trigger in der Datenbank
Protokoll Öffentliche Schnittstelle ohne Begründung und Melder AppView: queryLabels liefert die gespeicherten Labels aus
Protokoll Standardformat für andere Apps Registrar: Labels nach com.atproto.label
Protokoll Labels signiert, ab dem Start Registrar: signiert beim Anlegen
Zugang Mindestens zwei Personen, persönliches Login Payload: Collection users, Rolle moderator
Zugang Stellvertretung nur für ihren Fall Payload: Rolle substitute, beforeLogin-Hook
Bereich Funktion Umsetzung
Warteschlange Mehrere Meldungen als ein Fall Payload: ein offener Fall pro Gegenstand, ab Start
Warteschlange Filter nach Grund, Status, Inhaltstyp, Alter ✱ Payload: Listenfilter
Warteschlange Suche nach Konto oder Beitrag ✱ Payload: Suche über did und uri
Warteschlange Älteste offene Meldung zuerst ✱ Payload: Sortierung nach createdAt des Falls
Arbeit am Fall Massnahme auf mehrere Beiträge ✱ Payload: Sammelaktion in der Liste, ein Entscheid mit mehreren targets
Missbrauch Stummschalten Payload: reporters.mutedUntil; Meldungen bleiben gespeichert
Missbrauch Stummschaltung aufheben ✱ Payload: Feld leeren
Missbrauch Alarm bei vielen Meldungen Payload: Hook auf reports, ab 10 Meldungen zum selben Fall einmal eine E-Mail an alle Moderatoren
Missbrauch Team über neue Meldungen und Einsprüche informieren ✱ Payload: Sammelmail über die E-Mail-Anbindung
Missbrauch Hinweis auf lange offene Fälle ✱ Payload-Job mit Zeitplan
Rückmeldungen Eingang und Abschluss an die meldende Person ✱ Payload: Job notify
Rückmeldungen Stand des Einspruchs für Betroffene ✱ client-api zeigt status aus appeals

Gehört nicht zur ersten Umsetzung.

Funktion Umsetzung
Live-Abo für andere Apps AppView: subscribeLabels liefert die gespeicherten Labels aus, ohne Schlüssel
smartinfo als vollwertiger ATProto-Labeler Dienst-Adresse in der DID, deklarierte Label-Werte
Meldungen aus externen ATProto-Apps ✱ AppView: createReport; Payload holt die Meldungen über den Registrar ab. Erst nach Anpassung von VO § 4 Abs. 2 und VO Interop. § 7 Abs. 2

Fälle zuweisen, interne Notizen, Vorlagen für Begründungen, befristete Massnahmen, Kennzahlen, Export für juristische Prüfungen, Hinweise auf Spam-Muster, die nur vorschlagen. purgeExpired gehört dazu, sobald die Frist in VO § 9 Abs. 2 feststeht; es muss vor dem Ablauf der ersten Frist laufen.

  • Warnung ohne Ausblenden (VO § 3).
  • Eigene Übersichtsseite oder Zahlen zur Moderation (VO § 8 Abs. 4).
  • Tageslimit für Meldungen.
  • Ozone: ein zweites Werkzeug neben Payload, das die eigenen Record-Typen nicht kennt; die Regeln müssten trotzdem anderswo durchgesetzt werden (Diskussion #3317).
  • @skyware/labeler als Abhängigkeit: seit dem 18. Februar 2026 archiviert, nur noch Vorlage.
  • Osprey: eine Regel-Engine für automatische Massnahmen. Die VO schliesst automatische Folgen aus (§ 4 Abs. 3).
  • Eigener Event-Bus: Payload-Jobs und die Tabelle labels tragen alle Wirkungen.

Bezeichner und Formate für die Umsetzung. Mit ✱ markierte Festlegungen sind Vorschläge.

Migration db/migrations/016_moderation.sql, zusätzlich in db-setup.sql, db/tests/grants.sql und DB_ROLES.md (Konvention: db/migrations/README.md).

CREATE TABLE labels (
seq bigserial PRIMARY KEY,
ver smallint NOT NULL DEFAULT 1,
src text NOT NULL,
uri text NOT NULL,
cid text,
val text NOT NULL,
neg boolean NOT NULL DEFAULT false,
cts text NOT NULL, -- Text: muss Byte für Byte dem signierten Wert entsprechen
sig bytea NOT NULL,
decision_id text NOT NULL,
UNIQUE (decision_id, uri, val, neg)
);
CREATE INDEX labels_uri_seq_idx ON labels (uri, seq DESC);
-- Gültig: Der neueste Eintrag pro (src, uri, val) ist keine Rücknahme.
CREATE VIEW labels_active AS
SELECT * FROM (
SELECT DISTINCT ON (src, uri, val) * FROM labels ORDER BY src, uri, val, seq DESC
) latest WHERE NOT neg;
ALTER TABLE app_arguments ADD COLUMN hidden boolean NOT NULL DEFAULT false,
ADD COLUMN bsky_post_removed jsonb; -- aufbewahrter Spiegel-Record
ALTER TABLE app_comments ADD COLUMN hidden boolean NOT NULL DEFAULT false;
ALTER TABLE app_likes ADD COLUMN hidden boolean NOT NULL DEFAULT false;
ALTER TABLE app_arguments ADD COLUMN content_purged_at timestamptz; -- Upserts behalten geleerte Texte
ALTER TABLE app_comments ADD COLUMN content_purged_at timestamptz;
ALTER TABLE registry.clients ADD COLUMN moderation_key_hash text UNIQUE;
-- dazu Teilindizes WHERE hidden; Rolle ozone entfernen
Rolle Neue Rechte
registrar labels R I D, Sequenz; labels_active R; app_arguments, app_comments, app_likes, app_peerreviews, app_peerreview_responses R; app_arguments U(title, body, translations, bsky_post_removed, content_purged_at); app_comments U(title, text, content_purged_at)
indexer labels, labels_active R; app_label_active, app_refresh_hidden EXECUTE; hidden liegt in seinen Tabellen
appview labels, labels_active R
writer app_arguments U(bsky_post_removed)

Der Registrar schreibt Labels innerhalb einer Transaktion mit pg_advisory_xact_lock. So bleibt die Reihenfolge von seq auch bei einem zweiten Pod lückenlos sichtbar. purgeContent leert die Texte, statt Zeilen zu löschen, weil andere Tabellen darauf verweisen und der Indexer bei einem gelöschten Record nur deleted setzt.

Alle Routen unter /xrpc/ch.atvoto.registrar.*, neu in services/writer/src/registrar/moderation.py und labels.py. Eine neue Abhängigkeit require_moderation in auth.py prüft moderation_key_hash und liefert die client_id. Fehler im bestehenden Format {"error", "message"}.

Route Eingabe Antwort, Verhalten
GET moderation.getSubject uri oder did Autor, contentType, accepted, deleted, Text, origin (hosted, external, none), gültige Labels, Status auf dem PDS; auch für Ausgeblendetes
GET moderation.getThread uri Kommentarverlauf zum Fall, auch Ausgeblendetes
GET moderation.listRecent limit, before Argumente von Personen und Kommentare aller Vorlagen des Clients zum Durchsehen, neueste zuerst, auch Ausgeblendetes; ohne Filter nach Person oder Haltung
POST moderation.setLabel decisionId, uri, val, neg {seq}; bei gleicher decisionId kein neuer Eintrag
POST moderation.setSubjectStatus decisionId, did oder uri oder did + blobCid, takedown ruft die Admin-API auf; den cid eines Records holt der Registrar aus dem Index; not_hosted (409) bei externen Konten
POST moderation.deleteCommunityRecord decisionId, uri wie delete_community_record; wiederholbar
POST moderation.purgeContent decisionId, uri leert die Texte im Index
POST moderation.purgeLabels uri oder did löscht die Zeilen in labels
POST revokeParticipant did bestehend, künftig mit require_moderation; ein zweiter Aufruf darf nicht mehr not_found melden
POST reinstateParticipant did neu: revoked_at = NULL; wiederholbar

Zugehörigkeit: Das Konto muss in registry.participants zum Client gehören, die kanonische Kopie zu einem Dossier des Clients. Antworten von Personen ohne Mitgliedschaft dürfen nur ein Label auf die URI erhalten. Ein Label auf der DID wird mit not_a_member (409) abgelehnt.

subject für com.atproto.admin.updateSubjectStatus, am Test-PDS geprüft:

Gegenstand subject
Konto {"$type": "com.atproto.admin.defs#repoRef", "did": …}
Record {"$type": "com.atproto.repo.strongRef", "uri": …, "cid": …}
Blob {"$type": "com.atproto.admin.defs#repoBlobRef", "did": …, "cid": …}

Aufheben mit "takedown": {"applied": false}, Sperren mit {"applied": true, "ref": <decisionId>}.

Neue Abhängigkeiten in services/writer/requirements.in: cryptography, libipld (uv pip compile, siehe COMMANDS.md). In einem Probelauf von @atproto/crypto bestätigt:

cbor = libipld.encode_dag_cbor(label) # ver, src, uri, val, cts; neg und cid nur, wenn gesetzt
digest = hashlib.sha256(cbor).digest()
r, s = decode_dss_signature(key.sign(digest, ec.ECDSA(Prehashed(hashes.SHA256()))))
if s > N // 2: # Low-S; N = Ordnung von secp256k1
s = N - s
sig = r.to_bytes(32, "big") + s.to_bytes(32, "big")
Variable Dienst Inhalt
LABELER_DID Registrar, Indexer, AppView DID des Labeler-Kontos, zugleich src
LABELER_SIGNING_KEY_HEX Registrar, registrar-secrets privater secp256k1-Schlüssel

Das Labeler-Konto wird von Hand angelegt wie die Test-Konten und ist kein Mitglied. Der Handle moderation kommt in _RESERVED_PREFIXES (registrar/service.py). scripts/create_client.sh erzeugt zusätzlich den Moderations-Key und legt ihn als eigenes Secret ab. Wird der Signaturschlüssel ersetzt, muss die DID aktualisiert werden.

  • Eine neue Schleife in services/indexer/src/main.js wie replayHeld, alle paar Sekunden: neue Zeilen aus labels ab dem Stand in indexer_cursors (id = labels).
  • Für jede Zeile hidden neu berechnen: bei einer URI den Beitrag, bei einer DID alle Beiträge des Kontos in app_arguments (author_did), app_comments (did) und app_likes (did). Danach die betroffenen Zähler neu berechnen.
  • accepted heisst: peerreview_status = 'approved', oder das Argument hat kein Peer Review.
  • Gutachten erhalten kein Flag, app_peerreview_responses bleibt unverändert.
  • Die Upserts in db.js berechnen hidden auch für neue Records, sonst erschiene ein neuer Beitrag eines gelabelten Kontos. Ein erneutes Indexieren darf das Flag nicht zurücksetzen.
  • refreshLikeCount zählt nur NOT hidden. refreshCommentCount zählt nur NOT hidden AND origin = 'intern'; heute zählt er externe Antworten mit, was VO Interop. § 5 Abs. 1 widerspricht.
  • not_hidden(alias) in core/visibility.py, nach dem Muster von not_withdrawn, in allen Lesepfaden: core/similarity.py, routes/atproto/feed.py und in routes/participation/ die Dateien activity.py, arguments.py, comments.py, dossier_stats.py, reviews.py, search.py, taxonomy.py. Für Bewertungen auch in der Verdichtung von dossier_stats.py.
  • Kommentare: Ein ausgeblendeter Kommentar mit sichtbaren Antworten kommt als {"uri", "hidden": true, "parentUri", "argumentUri"} ohne Text und ohne Autor, einer ohne solche Antworten gar nicht.
  • Externe Antworten (origin = 'extern'): Feld collapsed: true, ausser participation_check lässt die Person zu. So erscheinen sie nach einer Registrierung von selbst normal.
  • routes/atproto/labels.py: com.atproto.label.queryLabels nach dem Lexicon, nur Labels mit src = LABELER_DID, sig im JSON als {"$bytes": …}, ohne seq und decision_id.
  • Entfernen: routes/atproto/ozone.py, den Ozone-Aufruf in actor.py, OZONE_URL in config.py, /xrpc/tools.ozone.* im README.

Eine neue Schleife run_mirror_sync_forever in src/atproto/crosspost.py, gestartet in src/main.py. Sie braucht keinen Stand, weil sie den Zustand abgleicht:

Bedingung in app_arguments Aktion
content_purged_at IS NOT NULL AND bsky_post_uri IS NOT NULL Spiegelpost löschen, nie sichern; bsky_post_uri und bsky_post_removed leeren
hidden AND bsky_post_uri IS NOT NULL AND bsky_post_removed IS NULL Record lesen und in bsky_post_removed sichern, dann löschen
NOT hidden AND NOT deleted AND bsky_post_removed IS NOT NULL mit putRecord unter gleichem rkey anlegen, bsky_post_removed leeren

Dazu AND NOT hidden in _crosspost_arguments (bsky_post_uri IS NULL), translation/translator.py, embedding/backfill.py und arguments/peer_review_assign.py. Vorhandene Embeddings ausgeblendeter Argumente löscht backfill.py. Ein laufendes Peer Review eines ausgeblendeten Arguments pausiert (MODERATION_UMSETZUNG.md, Etappe 2.4).

Ablage in services/cms/src/plugins/moderation/ mit den Unterordnern collections/, endpoints/, lib/ und jobs/ nach den Konventionen in services/cms/CLAUDE.md; eingebunden über plugins in payload.config.ts. Schema mit pnpm payload migrate:create.

Zugriff. users hat heute weder Rollen noch Regeln, jedes Login dürfte sich also selbst eine Rolle geben.

Änderung an users Inhalt
role moderator, substitute, service
isAdmin Ja/Nein; ist der Payload-Admin. Nur wer das Feld hat, ändert role, case, active und isAdmin. Bestehende Logins erhalten es bei der Migration, weil sie heute schon alles dürfen
auth.useAPIKey nur für das Dienst-Konto mit Rolle service

Das CMS ist öffentlich erreichbar (cms.*, Pfad /), die REST-API also auch. Zugriffsregeln gelten nur für REST; die Local API umgeht sie (overrideAccess). Regeln wie Befangenheit und Schutz der Gutachten gehören deshalb in beforeChange-Hooks.

Jobs. jobs in payload.config.ts mit einer Queue moderation, autoRun, enableConcurrencyControl und deleteJobOnComplete: false. Pro Entscheid ein Workflow, dessen Tasks der Reihe nach laufen, damit das Label vor dem Takedown steht. Der Workflow startet in afterChange von decisions, mit req, also in derselben Transaktion. Tasks verwenden die decisions-ID als decisionId. Scheitert ein Task nach allen Versuchen, zeigt die Fall-Ansicht das an.

Tasks wiederholen sich ohne Backoff: Mit Backoff überholt ein zweiter Job desselben Gegenstands den ersten. reverse, lift und appeal sind erst möglich, wenn der Workflow des betroffenen Entscheids fertig ist. Ein Absturz mitten im Job lässt ihn auf processing = true stehen, deshalb setzt onInit das in dieser Queue zurück; das setzt eine einzige Replik voraus (MODERATION_UMSETZUNG.md, Schritt 0.3).

Task Aufruf
setLabel, negateLabel, pdsTakedown, pdsRestore, purgeContent, purgeLabels Registrar, registrar() aus lib/store-publish.ts, mit STORE_MODERATION_KEY statt STORE_CLIENT_KEY
setParticipationRevoked, clearParticipationRevoked Registrar
deleteRecord gehostet: client-api; kanonische Kopie: Registrar; extern: keine Aktion
notify Local API auf notifications

Datenbank. In der Payload-Migration von Hand ergänzen: ein Teilindex UNIQUE für höchstens einen offenen Fall pro Gegenstand und der Trigger auf decisions und reports. Der Trigger lässt UPDATE und DELETE nur zu, wenn purgeExpired in seiner Transaktion SET LOCAL moderation.purge = 'on' gesetzt hat.

E-Mail (Prio 2). Payload hat heute keine Mail-Anbindung; Alarm und Sammelmail brauchen einen email-Adapter und SMTP-Zugang.

Route Zugang Zweck
POST /moderation/reports Sitzung Meldung an Payload; reporterDid aus der Sitzung, nie aus dem Body
POST /moderation/appeals Sitzung Einspruch an Payload
GET /moderation/notifications, POST /moderation/notifications/{id}/read Sitzung nur eigene
POST /internal/moderation/delete-record Header mit CLIENT_API_MODERATION_SECRET delete_record für did und uri
  • Neu src/routes/moderation.py, eingebunden in main.py.
  • Der Frontend-Proxy /api/app/[...path] leitet jeden Pfad weiter. Er muss internal/ ablehnen; das Secret bleibt trotzdem Pflicht.
  • Neu src/auth/account_status.py: fragt getRepoStatus mit 3 s Timeout ab, setzt oder löscht suspended_at, löscht Sitzungen und holt Entscheid und Grund aus Payload.
  • login_account in auth/login.py ruft das vor create_session auf. Damit gilt es für Magic Link und Short Code. Antwort: 403 account_suspended mit reason, decidedAt, contact.
  • _send und _login in atproto/pds.py: Nach dem zweiten 401 und bei einem 401 von createSession prüft die client-api den Status. Neue Kategorie ACCOUNT_SUSPENDED in atproto/errors.py (403).
  • Migration db/migrations/002_suspended_at.sql im smartinfo-Repo: auth.auth_creds.suspended_at timestamptz.
Variable Dienst Inhalt
STORE_MODERATION_KEY cms, cms-secrets Moderations-Key
CLIENT_API_MODERATION_SECRET client-api, cms Aufruf von Payload an die client-api
CMS_SERVICE_API_KEY client-api API-Key des Dienst-Kontos in Payload
CLIENT_API_INTERNAL_URL cms clusterinterne URL der client-api
MODERATION_CONTACT_EMAIL client-api Adresse für Einsprüche und Meldestelle
  • Melde-Knopf an Argument und Kommentar, Grund aus den sechs Werten oder other.
  • Platzhalter bei hidden: true, Einklappen bei collapsed: true mit dem Hinweis aus VO Interop. § 5.
  • Benachrichtigungen mit Einspruch.
  • account_suspended in auth/verify/verify-client.tsx und auth/magic-link-sent/page.tsx abfangen, neue Sperrseite unter auth/. Texte in messages/ für de, en, fr, it, rm.
  1. Labeler-Konto, Signaturschlüssel, Moderations-Key.
  2. Store: Migration, dann Registrar, Indexer, AppView, Writer. Solange es keine Labels gibt, ändert sich für Nutzende nichts.
  3. Client: Migrationen, CMS, client-api, Frontend.
Ort Prüft
db/tests/grants.sql, db/tests/run.sh Rechte, Migration wiederholbar
services/writer/tests Signatur gegen festen Testvektor, Low-S, Idempotenz von setLabel, Zugehörigkeit, require_moderation, Abgleich der Spiegelposts
services/indexer/test Regel für hidden, Gutachten und angenommene Argumente, Zähler, neue Records gelabelter Konten, Rücknahme
services/appview/tests not_hidden in jedem Lesepfad, Platzhalter, collapsed, queryLabels ohne interne Felder
services/cms/tests/int jede Zeile aus «Regeln», Reihenfolge der Tasks, Trigger
services/client-api Sperre beim Login, PDS nicht erreichbar, 401 in laufender Sitzung, reporterDid aus der Sitzung, Secret
  • Verschobene Punkte: Rechte im CMS, vollständiges Löschen, Jobs aus dem betroffenen Entscheid und die Neuberechnung von hidden beim Abschluss des Peer Reviews stehen in MODERATION_UMSETZUNG.md.
  • Beitragssperre: Ein Record-Takedown lässt den Inhalt über getRepo und sync.getRecord abrufbar. Muss er wirklich weg, gilt delete (VO § 1 Abs. 4, Diskussion in § 2).
  • Deaktivierte Konten: ACCOUNT_DELETION.md verlangt, sie auszublenden und wieder zu zeigen. Dafür gibt es kein Label, und der Indexer verarbeitet #account heute nicht.
  • Alarm: Ab 10 Meldungen «zum selben Fall» erkennt keine Flut über viele Fälle.
  • Last: Trägt der Filter nach hidden die Lesepfade?

Vor dem Start zu prüfen, weil das Verhalten fremder Dienste nicht dokumentiert ist.

  • Spiegelpost wiederherstellen. Einen Spiegelpost mit Antworten auf Bluesky löschen und mit gleichem rkey und identischem Record wieder anlegen. Prüfen, ob Bluesky die alten Antworten wieder anhängt. Der Test-PDS hängt nicht am Relay, das geht erst mit Anschluss.

Am Test-PDS und lokal geprüft (2026-11-06):

Prüfung Ergebnis
Kontostatus getRepoStatus liefert nach einem Takedown active: false, status: takendown, ohne Anmeldung; nach dem Restore wieder active: true
Token nach Takedown Schreiben mit altem Token: 401 AccountTakedown. Lesen über den AppView-Proxy geht weiter. createSession: 401 AuthenticationRequired. Nach dem Restore funktioniert das alte Token wieder
Record-Takedown weg aus getRecord und listRecords, aber weiter in getRepo und sync.getRecord; idempotent
Blob-Takedown getBlob meldet «Blob not found», Restore funktioniert
Echtes Löschen deleteRecord mit der Sitzung der Person entfernt den Inhalt auch aus getRepo; mit dem Admin-Passwort wird der Aufruf abgelehnt
Firehose Takedown als #account mit active: false, status: takendown; Restore als active: true
Admin-Routen von aussen 403
Signatur in Python mit cryptography und libipld signierte Labels bestätigt @atproto/crypto; ein verändertes Label wird abgelehnt; DAG-CBOR identisch mit @ipld/dag-cbor
Schlüssel in der DID PLC-Operation direkt mit dem Rotation-Key des PDS signiert, ohne Mail-Anbindung: #atproto_label steht in der DID, alle anderen Felder bleiben gleich, ein Label lässt sich gegen die aufgelöste DID prüfen, das Konto schreibt normal weiter
Payload-Jobs mit Payload 3.74 auf SQLite: Ergebnisse in MODERATION_UMSETZUNG.md, Schritt 0.3

Reihenfolge, Abnahmen und Entscheide stehen in MODERATION_UMSETZUNG.md.