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)Komponenten
Abschnitt betitelt „Komponenten“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).
Verbindungen
Abschnitt betitelt „Verbindungen“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 |
Datenmodell in Payload
Abschnitt betitelt „Datenmodell in Payload“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 |
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.
reports
Abschnitt betitelt „reports“| 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).
decisions
Abschnitt betitelt „decisions“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.
appeals
Abschnitt betitelt „appeals“| 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.
reporters
Abschnitt betitelt „reporters“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.
notifications
Abschnitt betitelt „notifications“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.
Wirkungen
Abschnitt betitelt „Wirkungen“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 |
Lesemodell
Abschnitt betitelt „Lesemodell“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 (
refreshLikeCountund die Neuzählung der Kommentare inservices/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;
statebleibt 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:
hiddengilt 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.
Labeler
Abschnitt betitelt „Labeler“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_labelin der DID, sonst lässt sich die Signatur nicht prüfen. - Prio 3: Die Dienst-Adresse
#atproto_labelerund 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.
labelslä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 normalenSTORE_CLIENT_KEY, den auch die client-api hält, lassen sich keine Moderationsrouten aufrufen; das gilt auch fürrevokeParticipant. - 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:
createReportlegt die Meldung im Store ab, Payload holt sie über den Registrar ab und setztsource=external. Das setzt eine Anpassung von VO Moderation § 4 Abs. 2 und VO Interop. § 7 Abs. 2 voraus.subscribeLabelsliefert negierte Labels nach der Spezifikation nicht mehr aus, nur noch die Rücknahme.
Sperre beim Login
Abschnitt betitelt „Sperre beim Login“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.
- Nach dem Magic Link oder Short Code fragt die client-api
com.atproto.sync.getRepoStatusab, mit kurzem Timeout. - Status
takendownodersuspended: Sie setztsuspended_at, löscht bestehende Sitzungen, erstellt keine neue und antwortet mitaccount_suspended. Die App zeigt Entscheid, Grund (ausdecisions) und die Adresse für den Einspruch. Fehlt ein Entscheid, etwa nach einem Takedown von Hand, erscheint nur der allgemeine Hinweis mit der Adresse. - Konto wieder aktiv: Sie löscht
suspended_at, und der Login läuft normal. - 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.
Abläufe
Abschnitt betitelt „Abläufe“Meldung bis Ausblenden
- Ein Mitglied meldet einen Kommentar. Die client-api prüft die Sitzung und reicht die Meldung an Payload weiter.
- Payload hängt die Meldung an den offenen Fall oder legt einen an und sichert einen
snapshot. - Eine Person aus dem Team öffnet den Fall und sieht Inhalt, Kontext, Grund und meldende Person.
- Sie wählt
hidemit Grund «Beschimpfung» (insult) und schreibt die Begründung. Payload prüft die Regeln und speichert den Entscheid. - Die Jobs
setLabelundnotifysetzen das Label und benachrichtigen die betroffene Person. - 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. - Die AppView zeigt einen Platzhalter; Antworten bleiben sichtbar.
Kontosperre mit Einspruch
- Das Team stellt schweren Missbrauch fest und sperrt ein gehostetes Konto mit
suspend, Grund «Umgehung» (circumvention). - 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.
- Der Indexer blendet die Beiträge aus, ausser Gutachten und angenommenen Argumenten, und übernimmt keine neuen Records.
- 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.
- Die Person erhebt Einspruch per E-Mail; das Team erfasst ihn in Payload. Eine andere Person aus dem Team entscheidet.
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.
Funktionsumfang
Abschnitt betitelt „Funktionsumfang“Prio 1: nötig zum Start
Abschnitt betitelt „Prio 1: nötig zum Start“| 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 |
Prio 2: bald nach dem Start
Abschnitt betitelt „Prio 2: bald nach dem Start“| 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 |
Prio 3: später
Abschnitt betitelt „Prio 3: später“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 |
Prio 4: bei Bedarf
Abschnitt betitelt „Prio 4: bei Bedarf“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.
Bewusst nicht vorgesehen
Abschnitt betitelt „Bewusst nicht vorgesehen“- 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
labelstragen alle Wirkungen.
Technische Spezifikation
Abschnitt betitelt „Technische Spezifikation“Bezeichner und Formate für die Umsetzung. Mit ✱ markierte Festlegungen sind Vorschläge.
Store: Datenbank
Abschnitt betitelt „Store: Datenbank“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-RecordALTER 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 TexteALTER 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.
Store: Registrar
Abschnitt betitelt „Store: Registrar“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>}.
Store: Signatur und Labeler-Konto
Abschnitt betitelt „Store: Signatur und Labeler-Konto“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 gesetztdigest = 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 - ssig = 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.
Store: Indexer
Abschnitt betitelt „Store: Indexer“- Eine neue Schleife in
services/indexer/src/main.jswiereplayHeld, alle paar Sekunden: neue Zeilen auslabelsab dem Stand inindexer_cursors(id=labels). - Für jede Zeile
hiddenneu berechnen: bei einer URI den Beitrag, bei einer DID alle Beiträge des Kontos inapp_arguments(author_did),app_comments(did) undapp_likes(did). Danach die betroffenen Zähler neu berechnen. acceptedheisst:peerreview_status = 'approved', oder das Argument hat kein Peer Review.- Gutachten erhalten kein Flag,
app_peerreview_responsesbleibt unverändert. - Die Upserts in
db.jsberechnenhiddenauch für neue Records, sonst erschiene ein neuer Beitrag eines gelabelten Kontos. Ein erneutes Indexieren darf das Flag nicht zurücksetzen. refreshLikeCountzählt nurNOT hidden.refreshCommentCountzählt nurNOT hidden AND origin = 'intern'; heute zählt er externe Antworten mit, was VO Interop. § 5 Abs. 1 widerspricht.
Store: AppView
Abschnitt betitelt „Store: AppView“not_hidden(alias)incore/visibility.py, nach dem Muster vonnot_withdrawn, in allen Lesepfaden:core/similarity.py,routes/atproto/feed.pyund inroutes/participation/die Dateienactivity.py,arguments.py,comments.py,dossier_stats.py,reviews.py,search.py,taxonomy.py. Für Bewertungen auch in der Verdichtung vondossier_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'): Feldcollapsed: true, ausserparticipation_checklässt die Person zu. So erscheinen sie nach einer Registrierung von selbst normal. routes/atproto/labels.py:com.atproto.label.queryLabelsnach dem Lexicon, nur Labels mitsrc = LABELER_DID,sigim JSON als{"$bytes": …}, ohnesequnddecision_id.- Entfernen:
routes/atproto/ozone.py, den Ozone-Aufruf inactor.py,OZONE_URLinconfig.py,/xrpc/tools.ozone.*im README.
Store: Writer
Abschnitt betitelt „Store: Writer“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).
Client: Payload
Abschnitt betitelt „Client: Payload“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.
Client: client-api
Abschnitt betitelt „Client: client-api“| 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 inmain.py. - Der Frontend-Proxy
/api/app/[...path]leitet jeden Pfad weiter. Er mussinternal/ablehnen; das Secret bleibt trotzdem Pflicht. - Neu
src/auth/account_status.py: fragtgetRepoStatusmit 3 s Timeout ab, setzt oder löschtsuspended_at, löscht Sitzungen und holt Entscheid und Grund aus Payload. login_accountinauth/login.pyruft das vorcreate_sessionauf. Damit gilt es für Magic Link und Short Code. Antwort: 403account_suspendedmitreason,decidedAt,contact._sendund_logininatproto/pds.py: Nach dem zweiten 401 und bei einem 401 voncreateSessionprüft die client-api den Status. Neue KategorieACCOUNT_SUSPENDEDinatproto/errors.py(403).- Migration
db/migrations/002_suspended_at.sqlim 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 |
Client: Frontend
Abschnitt betitelt „Client: Frontend“- Melde-Knopf an Argument und Kommentar, Grund aus den sechs Werten oder
other. - Platzhalter bei
hidden: true, Einklappen beicollapsed: truemit dem Hinweis aus VO Interop. § 5. - Benachrichtigungen mit Einspruch.
account_suspendedinauth/verify/verify-client.tsxundauth/magic-link-sent/page.tsxabfangen, neue Sperrseite unterauth/. Texte inmessages/für de, en, fr, it, rm.
Reihenfolge beim Ausrollen
Abschnitt betitelt „Reihenfolge beim Ausrollen“- Labeler-Konto, Signaturschlüssel, Moderations-Key.
- Store: Migration, dann Registrar, Indexer, AppView, Writer. Solange es keine Labels gibt, ändert sich für Nutzende nichts.
- 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 |
Offene Punkte
Abschnitt betitelt „Offene Punkte“- Verschobene Punkte: Rechte im CMS, vollständiges Löschen, Jobs aus dem betroffenen Entscheid
und die Neuberechnung von
hiddenbeim Abschluss des Peer Reviews stehen in MODERATION_UMSETZUNG.md. - Beitragssperre: Ein Record-Takedown lässt den Inhalt über
getRepoundsync.getRecordabrufbar. Muss er wirklich weg, giltdelete(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
#accountheute nicht. - Alarm: Ab 10 Meldungen «zum selben Fall» erkennt keine Flut über viele Fälle.
- Last: Trägt der Filter nach
hiddendie Lesepfade?
Testbedarf
Abschnitt betitelt „Testbedarf“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 |
Umsetzung
Abschnitt betitelt „Umsetzung“Reihenfolge, Abnahmen und Entscheide stehen in MODERATION_UMSETZUNG.md.