Zum Inhalt springen

Moderation: Implementationsplan

Status: umgesetzt (2026-11-07), Prio 1 und 2. Grundlage: MODERATION.md und VO Moderation. Geprüft gegen den Code von atvoto und smartinfo, Stand 2026-11-06.

Dieser Plan beschreibt die Umsetzung von Prio 1 und Prio 2. Bezeichner, Felder und Routen stehen in MODERATION.md und werden hier nur wiederholt, wo der Plan sie ergänzt oder korrigiert. Pfade ohne Zusatz liegen im atvoto-Repo, Pfade mit smartinfo: im Nachbar-Repo.

Nicht enthalten

  • Prio 3: subscribeLabels, createReport, #atproto_labeler, Deklaration der Label-Werte. Der Schlüssel #atproto_label in der DID gehört zu Prio 1 und ist enthalten.
  • Prio 4, darunter purgeExpired, solange die Frist in VO § 9 Abs. 2 offen ist.
  • Klärungen ausserhalb des Codes: juristische Prüfung, Begriff «schwerer Missbrauch» (VO § 7), Kontaktadresse der Meldestelle (VO § 4). Sie blockieren die Umsetzung nicht, wohl aber prod.

Die mit ✱ markierten Punkte aus Prio 1 und 2 sind eingeplant, obwohl das Konzept sie als noch nicht besprochen führt.

Das Konzept ordnet Payload als Etappe 1 ein. Das geht so nicht: Regeln und Fall-Ansicht brauchen getSubject aus dem Registrar. Der Plan beginnt deshalb im Store und baut danach den ersten durchgehenden Ablauf.

Etappe Inhalt Danach möglich Repos
0 Entscheide, zwei technische Vorabklärungen Offene Risiken sind geklärt beide
1 Store-Grundlage: Migration, Moderations-Key, Labeler, Signatur, Lese-Routen Labels von Hand setzen atvoto
2 Lesemodell: Indexer, AppView, Writer, Platzhalter Ein Label blendet überall aus beide
3 Payload und Meldungen: Rollen, Collections, Regeln, Fall-Ansicht, Jobs Meldung bis Ausblenden, Rücknahme smartinfo
4 Betroffene: Benachrichtigungen, Einspruch Einspruch gegen das Ausblenden smartinfo
5 Stärkere Massnahmen: Widerruf, Sperre, Sperre beim Login, Spiegelposts, Löschen Prio 1 vollständig beide
6 Prio 2 Prio 2 vollständig smartinfo
0 ──→ 1 ──→ 2 ──→ 3 ──→ 4 ──→ 5 ──→ 6

Etappe 4 und 5 lassen sich auch parallel bearbeiten, sobald Etappe 3 steht. Etappe 6 braucht nur Etappe 3 und 4; die Teile aus Etappe 5 hängen nur an der Sammelaktion.

Die Prüfung gegen den Code ergab Lücken und Widersprüche im Konzept. Sie sind in die Etappen eingearbeitet oder, wo mit «→ T» markiert, in die Liste für später verschoben. Keiner dieser Punkte stellt die Architektur in Frage. MODERATION.md wird in Etappe 0 nachgeführt, damit Plan und Referenz übereinstimmen.

# Befund Beleg Lösung im Plan
B1 Jedes CMS-Login darf Inhalte ändern. Auch eine Stellvertretung, also eine Drittperson, könnte Dossiers bearbeiten und veröffentlichen smartinfo:services/cms/src/collections/*: update: !!user; lib/access.ts: user ? true → T1
B2 Eine deaktivierte Stellvertretung bleibt angemeldet. Der beforeLogin-Hook greift nur beim nächsten Login Konzept, Regel «Stellvertretung» → T1
B3 Für purgeContent fehlen Rechte und Ziele: Embeddings, aufbewahrter Spiegel-Record, Warteschlangen. Für accepted fehlt das Leserecht auf app_peerreviews Konzept, Tabelle «Neue Rechte» app_peerreviews in Etappe 1.1; der Rest → T2
B4 Übersetzungen von Kommentaren sind eigene Records im Community-Konto. Löscht man nur die Zeilen im Index, bleibt der Text auf dem PDS db-setup.sql, app_comment_translations; translator.py → T2
B5 Argumente bestehen aus zwei Records: Original im Repo der Person und kanonische Kopie. Das Konzept sperrt und löscht pro Gegenstand einen Record app_arguments.origin_uri → T2, Entscheid E3
B6 Nach einem Abschluss des Peer Reviews stimmt hidden nicht mehr: Ein Argument unter Konto-Label wird angenommen und muss wieder erscheinen upsertPeerreviewResultDb, peer_review_finalize.py → T4
B7 Das geleerte title/body kann zurückkommen: Der Upsert überschreibt die Texte und setzt deleted = false. Liegt noch ein älteres Ereignis in der Firehose, ist der Inhalt wieder da upsertArgumentDb, DO UPDATE Etappe 1.1 und 2.1: neue Spalte content_purged_at, Entscheid E4
B8 Wettlauf beim Löschen: Der Spiegel-Abgleich kann den Record nach purgeContent noch in bsky_post_removed sichern Konzept, «Store: Writer» Etappe 5.4: Bei content_purged_at löschen, nie sichern
B9 lift und reverse führen feste Job-Listen. lift einer Beitragssperre würde die Teilnahme freigeben, reverse eines Widerrufs täte gar nichts Konzept, Tabelle «Wirkungen» → T3
B10 delete auf gesperrtem Konto: Das Konzept verlangt zuerst ein Restore, die Job-Liste enthält es nicht, und danach müsste die Sperre wieder gelten Konzept, Tabelle «Job» → T2
B11 Rücknahme kann das Label überholen: Hängt hide in der Wiederholung und läuft reverse zuerst, bleibt das Label am Ende gültig Reihenfolge nur innerhalb eines Workflows festgelegt; concurrency allein genügt nicht (Schritt 0.3) Etappe 3.4 und 3.5: Tasks ohne Backoff und Prüfung vor dem Speichern
B12 Die Warteschlange zeigt nur offene Fälle. Einsprüche hängen an entschiedenen Fällen und erscheinen dort nicht Konzept, cases, appeals Etappe 4.2: eigene Liste für offene Einsprüche
B13 Der API-Key der client-api darf Protokolldaten nicht lesen. Eine Benachrichtigung mit aufgelöster Beziehung gäbe justification und decidedBy preis Konzept, notifications Etappe 3.3 und 4.1: eigene Endpunkte mit fester Feldliste; service hat kein Leserecht auf decisions
B14 Der Einspruch braucht eine Prüfung, ob die Person betroffen ist Konzept, appeals Etappe 4.2
B15 Ein Trigger nur auf decisions deckt die Nebentabellen nicht ab: Mehrfachfelder wie targets legt Payload in eigenen Tabellen ab Payload-Postgres-Adapter; Namen aus der erzeugten Migration ablesen Etappe 3.4: Trigger auch dort
B16 bsky_poller.js fragt weiterhin ausgeblendete Argumente ab getCrosspostedArguments Etappe 2.3
B17 Das Konzept nennt keine Frontend-Arbeiten für Melde-Knopf und Platzhalter in den Etappen Konzept, «Umsetzung» Etappe 2.5 und 3.7
B18 Die CMS-Tests laufen ohne Datenbank, CI hat dort kein Postgres. Trigger und Zugriffsregeln lassen sich so nicht prüfen tests/int, vitest.config.mts Etappe 3.8: zweite Test-Suite mit Postgres
B19 Die Regel «mindestens zwei Personen» würde das erste Einrichten blockieren Konzept, Regel Etappe 3.1: greift nur, wenn eine Änderung den Bestand unter zwei senkt

revokeParticipant lässt sich ohne Bruch auf den Moderations-Key umstellen: In smartinfo gibt es keinen Aufrufer. Anzupassen sind nur Tests und die Beispiele in COMMANDS.md und NEW_CLIENT.md.

Entschieden am 2026-11-06:

# Frage Entscheid Betrifft
E1 Peer Review eines ausgeblendeten Arguments Es pausiert und verhält sich dabei wie ein geschlossenes Peer Review, ohne Fehler bei laufenden Peer Reviews. Nach einer Rücknahme läuft es weiter 2.2, 2.4
E2 Inhaltsrechte für Moderierende Vorerst keine Trennung, Rollen für Inhalte kommen später T1
E3 Original des Arguments bei Sperre und Löschung Später. Vorerst trifft beides nur die kanonische Kopie T2
E4 Schutz gegen Rückkehr gelöschter Texte Neue Spalte content_purged_at; die Upserts behalten die geleerten Texte 1.1, 2.1, 5.4, 5.5
E5 Schlüssel in der DID PLC-Operation per Skript direkt mit dem Rotation-Key signieren, ohne Mail-Anbindung. Wird in 0.2 geprüft 0.2, 1.3
E7 Meldestelle Vorerst nur E-Mail (MODERATION_CONTACT_EMAIL), verlinkt im Impressum und auf der Sperrseite 3.7
E8 Stimme, die beim Pausieren schon abgeschickt ist Sie wartet über KEEP_PENDING, statt abgelehnt zu werden 2.4
E6 Löschung hide ist der Normalfall. Eine Löschung ist endgültig und wird so kommuniziert: Sie gilt erst, wenn eine zweite Person sie bestätigt hat, und danach gibt es keinen Einspruch. Das widerspricht Art. 45 Abs. 5 nicht, denn Art. 44 führt die Löschung nicht als Massnahme auf; sie ist die Ausführung einer Rechtspflicht (Art. 45 Abs. 3). Die juristische Prüfung steht noch aus 4.2, 5.6

Diese Punkte sind bewusst verschoben. Keiner verlangt einen Umbau: Es sind Zusatzschritte in bestehenden Jobs, Routen oder Zugriffsregeln.

# Inhalt Spätester Zeitpunkt Bis dahin gilt
T1 Rechte im CMS (B1, B2, E2): Rollen für Inhalte; canEditContent in lib/access.ts statt !!user in den Inhalts-Collections und eigenen Endpunkten; Zugriffsregeln prüfen active, der Hook entfernt die Sitzungen einer deaktivierten Stellvertretung Vor der ersten Stellvertretung; das ist eine Drittperson Alle Logins dürfen Inhalte ändern, wie heute. Auf die Moderation greifen trotzdem nur die Rollen zu (Etappe 3.1)
T2 Löschen vollständig (B3, B4, B5, B10, E3): Records der Kommentar-Übersetzungen, Embeddings, app_acceptance_queue.record, app_held_contributions, Original des Arguments; Restore und erneuter Takedown bei gesperrten Konten samt Test, ob eine Person unter Record-Takedown selbst löschen kann. Dazu die Rechte aus B3 Vor prod, spätestens vor der ersten echten Löschung: delete dient der rechtlich verlangten Entfernung (VO § 2 Abs. 7) delete entfernt die kanonische Kopie oder den Kommentar samt Index-Texten; Reste bleiben wie links genannt
T3 Jobs aus dem betroffenen Entscheid ableiten (B9): planJobs statt fester Liste Bevor suspend, lift und der Widerruf in Etappe 5 freigegeben werden. In Etappe 3 gibt es nur hide und reverse, dort reicht die feste Zuordnung –
T4 hidden beim Abschluss des Peer Reviews neu berechnen (B6): ein Aufruf in upsertPeerreviewResultDb, dazu einmalig alle Argumente nachrechnen Vor prod Tritt selten auf, weil E1 Peer Reviews ausgeblendeter Argumente pausiert: Nur ein Argument in der Nachfrist kann unter einem Konto-Label noch abgeschlossen werden. Es bleibt dann ausgeblendet, bis zur nächsten Neuberechnung

Ziel: Entscheide stehen, und die zwei Annahmen mit dem grössten Umbaurisiko sind am echten System geprüft.

Schritt Arbeit Ergebnis
0.1 MODERATION.md nachführen (Befunde, E1–E8, neue Aktion confirmDelete, Etappen auf diesen Plan verweisen), Umsetzung dort kürzen Eine widerspruchsfreie Referenz
0.2 Schlüssel in der DID: am Test-PDS ein Probekonto anlegen. Skript-Entwurf: letzte Operation von plc.directory/{did}/log/last holen, verificationMethods.atproto_label ergänzen, alle anderen Felder unverändert lassen, mit PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX signieren und einreichen Ablauf bestätigt; Konto schreibt danach weiter normal; Signatur eines Labels gegen die aufgelöste DID geprüft. Punkt in «Testbedarf» erledigt
0.3 Payload-Jobs: Probelauf mit Payload 3.74 und zwei Workflows mit gleichem concurrency-Key: Fehler, Backoff, endgültiges Scheitern, Absturz Erledigt 2026-11-06, Ergebnis unten; Konfiguration in Etappe 3.5

Schritt 0.2, Schlüssel in der DID: E5 funktioniert. Am Test-PDS mit zwei Probekonten geprüft:

  • Der Rotation-Key des PDS darf die Operation signieren; plc.directory nimmt sie an. Die Antwort ist 200, aber kein JSON.
  • Die DID enthält danach #atproto_label. #atproto, rotationKeys, alsoKnownAs und services bleiben gleich.
  • Ein Label lässt sich gegen den Schlüssel aus der aufgelösten DID prüfen, ein verändertes wird abgelehnt.
  • Das Konto schreibt danach normal weiter, Sitzung und Status sind unverändert.

Die Konten sind wieder gelöscht. Ihre beiden DIDs bleiben als Einträge in plc.directory bestehen (did:plc:bxrm552m7jlodflbuk7llk2x, did:plc:otjmefe5id74mrkmghxndhpy), das lässt sich nicht entfernen.

Schritt 0.3, Payload-Jobs: concurrency allein reicht nicht. Mit SQLite geprüft, die Job-Logik liegt im Payload-Kern und ist vom Adapter unabhängig. Beim Anbinden an Postgres läuft derselbe Ablauf als Test mit (Etappe 3.8).

Fall Ergebnis Folge
Fehler ohne Backoff Reihenfolge hält: Der erste Job wird wiederholt und fertig, dann erst läuft der zweite Tasks ohne Backoff
Fehler mit Backoff Zweiter Job überholt: Während des Wartens steht processing auf false, und concurrency schliesst nur laufende Jobs aus Kein Backoff
Endgültiges Scheitern Zweiter Job läuft danach trotzdem Prüfung vor dem Speichern: reverse, lift, appeal erst, wenn der Workflow des betroffenen Entscheids fertig ist
Absturz mitten im Job Job bleibt für immer auf processing = true und blockiert alle weiteren Jobs dieses Gegenstands, auch nach einem Neustart. Das betrifft jeden Deploy während eines Jobs Beim Start processing zurücksetzen. Geprüft: Beide Jobs laufen danach in der richtigen Reihenfolge. Zulässig, weil das CMS mit einer Replik läuft und Tasks wiederholbar sind
Fertige Jobs Payload löscht sie, sie fehlen also in der Job-Tabelle, auf die sich die Fall-Ansicht stützt deleteJobOnComplete: false

Laut Quelltext starten die Crons nur mit getPayload({ cron: true }), was initReq im Admin-Panel tut. Die Readiness-Probe auf /admin löst das kurz nach dem Pod-Start aus. Am Testcluster mit dem ersten Job bestätigen.

Stand 2026-11-06: Code und Tests sind geschrieben, die Routen sind noch nicht deployt. Am Testcluster eingerichtet und geprüft:

  • Migration 016 auf Postgres 15, wiederholbar; grants.sql, hidden_rule.sql und participation_check.sql grün.
  • Labeler moderation.test.smartvote.social (did:plc:jio5mns5doxvshgurja2nn5l), #atproto_label in der DID.
  • Signaturschlüssel in registrar-secrets, Moderations-Key in registrar-moderation-smartinfo.

Nach dem Deploy noch offen: deploy/apply-test.sh für LABELER_DID in den ConfigMaps, Migration 017, Neustart von Registrar, Indexer und AppView, dann die Abnahmen 1.5 und 2.6. Der Moderations-Key geht in Etappe 3 ans CMS.

Ziel: Der Registrar signiert und speichert Labels und liefert die Daten zum Fall. Für Nutzende ändert sich nichts.

Umfang wie in MODERATION.md, «Store: Datenbank», mit diesen Ergänzungen:

ALTER TABLE app_arguments ADD COLUMN IF NOT EXISTS content_purged_at timestamptz; -- E4
ALTER TABLE app_comments ADD COLUMN IF NOT EXISTS content_purged_at timestamptz;

Die Rechte des registrar wachsen gegenüber dem Konzept (die übrigen aus B3 folgen mit T2):

Objekt Recht Zweck
app_peerreviews R accepted in getSubject
app_arguments U zusätzlich bsky_post_removed, content_purged_at purgeContent
app_comments U zusätzlich content_purged_at purgeContent

Für die Regel hidden kommen zwei SQL-Funktionen dazu, die nur der indexer ausführen darf: app_label_active(subject, labeler) und app_refresh_hidden(uri, did, labeler). So steht die Regel an einer Stelle und lässt sich auf einer echten Datenbank testen. Die Indexer-Tests arbeiten sonst mit einem Pool-Fake und prüfen nur die Form des SQL.

Zu ändern, wie die Konvention in db/migrations/README.md verlangt:

  • db/db-setup.sql: alles falten; CREATE ROLE ozone entfernen.
  • db/tests/grants.sql: Soll-Liste nachführen; ozone aus den beiden Rollenlisten entfernen.
  • DB_ROLES.md: Übersicht, Begründung, Zeile ozone und Abschnitt «Geplant» entfernen.
  • Neu db/tests/hidden_rule.sql, in db/tests/run.sh eingebunden.

Der Teil für ozone folgt dem Muster aus 014_drop_calculator.sql. Vor dem Einspielen am Testcluster prüfen, ob dort eine Datenbank ozone besteht. DROP DATABASE läuft nicht in der Transaktion der Migration und wäre von Hand nötig.

  • services/writer/src/registrar/auth.py: require_moderation wie require_client, gegen moderation_key_hash.
  • scripts/create_client.sh: zweiten Schlüssel erzeugen, im eigenen Secret registrar-moderation-<id> ablegen. Ein neuer Parameter entscheidet, welcher Schlüssel rotiert wird, damit eine Rotation des Client-Keys nicht die Jobs im CMS unterbricht.
  • doc/betrieb/NEW_CLIENT.md, COMMANDS.md: Key und Weitergabe an das CMS.
  • services/writer/requirements.in: cryptography, libipld; requirements.txt nach COMMANDS.md erzeugen. Prüfen, dass das Writer-Image die Wheels installiert.
  • _RESERVED_PREFIXES in registrar/service.py: moderation ergänzen.
  • Neu services/writer/src/registrar/labels.py:
    • sign_label(label, key) nach dem Rezept im Konzept. cts als UTC mit Z und festen Millisekunden erzeugen; dieser Text wird signiert und gespeichert.
    • write_label(conn, …) in einer Transaktion mit pg_advisory_xact_lock, ON CONFLICT (decision_id, uri, val, neg) DO NOTHING, liefert auch bei einer Wiederholung das bestehende seq.
  • Neu scripts/labeler_plc_update.py aus Schritt 0.2. Es zeigt die Änderung vor dem Einreichen an und fragt nach. Dasselbe Skript dient später dem Schlüsseltausch.
  • Variablen LABELER_DID in deploy/overlays/test/{registrar,indexer,appview}-config.env, LABELER_SIGNING_KEY_HEX in registrar-secrets; Kommentar in deploy/base/registrar/deployment.yaml.

Das Konto wird von Hand angelegt, bevor die Dienste ausgerollt werden. Fehlt LABELER_DID, wertet der Indexer keine Labels aus und meldet das im Log; setLabel antwortet mit einem Fehler.

Neu services/writer/src/registrar/moderation.py, eingebunden in registrar/main.py, alle mit require_moderation. Die Ingress-Regel /xrpc/ch.atvoto.registrar. deckt die Pfade bereits ab.

Route Hinweise
GET moderation.getSubject Sucht uri in app_arguments, app_comments, app_peerreview_responses, app_likes. Liefert zusätzlich originUri für Argumente. accepted wie in der Regel hidden. PDS-Status über getRepoStatus mit kurzem Timeout; ist er nicht erreichbar, pdsStatus: null, kein Fehler
GET moderation.getThread Baum über parent_uri und argument_uri, auch Ausgeblendetes
POST moderation.setLabel val gegen die sechs Werte aus VO § 3 prüfen. Zugehörigkeit nach dem Konzept, not_a_member (409) für ein Label auf die DID einer Person ohne Mitgliedschaft. neg: true ohne gültiges Label: kein Fehler, kein Eintrag, damit eine Wiederholung nach purgeLabels durchläuft

Die übrigen Routen folgen in Etappe 5, um sie zusammen mit ihren Aufrufern zu testen.

Ort Prüft
db/tests/run.sh Migration wiederholbar, Rechte, kein Rest von ozone
services/writer/tests/test_registrar_labels.py Signatur gegen festen Testvektor aus @atproto/crypto, Low-S, neg und cid nur wenn gesetzt, Idempotenz von setLabel, seq in Reihenfolge
services/writer/tests/test_registrar_moderation.py Client-Key wird abgelehnt, widerrufener Client auch, fremdes Konto, not_a_member, getSubject für jeden Inhaltstyp

Abnahme am Testcluster: Ein Aufruf von setLabel mit dem Moderations-Key legt eine Zeile an, die sich gegen den Schlüssel aus der aufgelösten DID prüfen lässt. Dieselbe Anfrage mit dem Client-Key gibt 401.

Stand 2026-11-06: Code und Tests sind geschrieben, Rollout steht aus. Die Testcluster-Abnahme (2.6) läuft zusammen mit Etappe 1. Das hidden-Flag gilt auch für Kommentare eines ausgeblendeten Arguments: Sie verschwinden mit ihm, weil sie sonst keinen Ort mehr haben.

Ziel: Ein von Hand gesetztes Label blendet einen Beitrag überall aus, die Rücknahme stellt ihn wieder her.

Datei Änderung
services/indexer/src/labels.js (neu) processLabelsOnce(): Zeilen ab dem Stand in indexer_cursors (id = labels) lesen, in Blöcken. Je Zeile app_refresh_hidden aufrufen, betroffene Zähler neu rechnen, Stand schreiben, alles in einer Transaktion
services/indexer/src/main.js setInterval wie replayHeld, alle 5 s, kein Überlappen zweier Läufe
services/indexer/src/db.js Nach den Upserts von Argument, Kommentar und Bewertung app_refresh_hidden für die URI aufrufen. hidden steht nie im DO UPDATE
ebenda, upsertArgumentDb, upsertCommentDb Bei gesetztem content_purged_at die Textfelder behalten, nicht auf deleted = false zurücksetzen (E4)
ebenda, refreshLikeCount, refreshCommentCount NOT hidden; bei Kommentaren zusätzlich origin = 'intern'. Dabei bleiben count(DISTINCT did) und der Kommentar dazu erhalten

Die Neuberechnung für ein Konto ändert viele Zeilen: Die Funktion liefert die betroffenen subject_uri und argument_uri zurück, damit nur diese Zähler neu laufen. bsky_reply_count bleibt unberührt.

Das Einspielen ändert comment_count einmalig: Externe Antworten zählen nicht mehr. Eine eigene Migration 017 rechnet die Spalte zusammen mit dem neuen Indexer einmal neu. In Migration 016 wäre es zu früh, weil der alte Indexer die externen Antworten beim nächsten Ereignis wieder mitzählt.

Tests:

  • db/tests/hidden_rule.sql, echte Datenbank, seit Etappe 1 vorhanden: URI-Label, Konto-Label, angenommen (approved, ohne Peer Review), preliminary, rejected, Rücknahme, zwei Werte auf demselben Beitrag (eine Rücknahme genügt nicht), fremde src.
  • services/indexer/test/labels.test.js: Stand, Wiederholung, Überlappen, Zähler.
  • services/indexer/test/db.test.js: neuer Record eines gelabelten Kontos, erneutes Indexieren, geleerte Texte bleiben leer.
Datei Änderung
core/visibility.py not_hidden(alias) neben not_withdrawn
Lesepfade Filter in core/similarity.py, routes/atproto/feed.py und in routes/participation/: activity.py, arguments.py, comments.py, dossier_stats.py (auch Verdichtung), reviews.py (E1), search.py, taxonomy.py
routes/participation/comments.py Platzhalter {"uri", "hidden": true, "parentUri", "argumentUri"} (der Client baut den Baum daraus) nur, wenn unter dem Kommentar noch sichtbare Antworten liegen; ein Ast ohne sichtbare Antwort fällt weg
routes/participation/comments.py collapsed: true bei origin = 'extern', ausser registry.participation_check lässt die Person zu
routes/atproto/labels.py (neu) queryLabels: uriPatterns (Pflicht; exakt oder mit * am Ende), sources, limit bis 250, cursor auf seq. Liefert auch Rücknahmen (VO § 8 Abs. 1); nur src = LABELER_DID; neg und cid nur wenn gesetzt; sig als {"$bytes": …}. Rate-Limit wie die übrigen öffentlichen Routen
Ozone entfernen routes/atproto/ozone.py, Router in __init__.py, Label-Teil in actor.py, OZONE_URL in config.py, Kommentar in main.py, /xrpc/tools.ozone.* im README

Tests in services/appview/tests/test_moderation_visibility.py: eine Tabelle aller Lesepfade prüft, dass ein Beitrag mit hidden fehlt, damit ein neuer Pfad ohne Filter auffällt. Dazu Platzhalter, collapsed, queryLabels ohne seq und decision_id und eine Prüfung, die eine Antwort von queryLabels wieder in DAG-CBOR kodiert und die Signatur prüft.

Datei Änderung
src/atproto/crosspost.py, _crosspost_arguments AND NOT a.hidden
src/translation/translator.py NOT hidden in den Abfragen für Argumente und Kommentare
src/embedding/backfill.py Ausgeblendete überspringen, ihre Vektoren löschen. Nach einer Rücknahme erzeugt die bestehende Abfrage sie neu
src/arguments/peer_review_assign.py Keine neuen Einladungen für Ausgeblendete. Label-Abfrage nicht nötig, das Flag genügt
services/indexer/src/bsky_poller.js AND NOT hidden in getCrosspostedArguments (B16)

Ein ausgeblendetes Argument verhält sich nach aussen wie ein geschlossenes Peer Review. app_peerreviews.state bleibt dabei unverändert: finalize_once veröffentlicht für jede Zeile mit state = 'closed' ohne result_uri ein endgültiges Ergebnis, ein echtes Schliessen liesse sich also nicht zurücknehmen. Der Zustand ergibt sich allein aus dem Flag hidden, die Rücknahme braucht deshalb keinen eigenen Schritt.

Stelle Heute bei closed Neu bei hidden
reviews.py, peerreview.pending, peerreview.list offene Aufgabe fehlt Argument fehlt (not_hidden)
reviews.py, peerreview.checkIn, peerreview.activity Antwort {"error": "closed"} gleiche Antwort
reviews.py, peerreview.status state: closed state: closed, ohne Inhalt des Arguments
smartinfo:client-api, _review_precheck review_closed wirkt unverändert über status
smartinfo:frontend, review-form.tsx Hinweis, Entwurf bleibt im localStorage unverändert; nach einer Rücknahme ist das Formular wieder offen
arguments/peer_review_assign.py keine Einladung keine Einladung
atproto/acceptance.py, schon abgesendete Antwort rejected, review_closed KEEP_PENDING, siehe unten
peer_review_finalize.py – unverändert: Ein Peer Review in der Nachfrist schliesst regulär

Einzige bewusste Abweichung: Eine Antwort, die schon unterwegs ist, wird nicht abgelehnt, sondern wartet über das bestehende KEEP_PENDING, wie bei einer pausierten Vorlage. Ein rejected wäre endgültig: stageForAcceptance ignoriert denselben user_uri danach (ON CONFLICT DO NOTHING), die Stimme ginge also auch nach einer Rücknahme verloren. app_response_gate bleibt deshalb unverändert. Beim Umsetzen prüfen, dass solche Zeilen die Abfrage der Warteschlange nicht in eine Dauerschleife bringen; closed und withdrawn sind dort heute ausgefiltert.

Tests: Ausblenden mit offenem Formular, Check-in, Antwort unterwegs, keine neue Einladung, kein Ergebnis-Record, Rücknahme, die wartende Antwort zählt, das Peer Review läuft bis zum Quorum weiter.

Datei (smartinfo) Änderung
services/client-api/src/routes/xrpc.py Anreicherung der Autoren verträgt fehlende author
services/frontend/src/components/comment-content.tsx, comment-detail.tsx, lib/commentThread.ts Platzhalter bei hidden: true; Einklappen mit Hinweis aus VO Interop. § 5 bei collapsed: true
services/frontend/messages/*.json Texte für de, en, fr, it, rm
services/cms/src/jobs/taxonomyAutosort.ts Prüfen und testen: Ein ausgeblendetes Argument darf nicht aus dem Baum fallen und danach neu einsortiert werden

Das Frontend kommt mit dieser Etappe, nicht später: Der erste Test am Cluster liefert sonst Kommentare ohne Autor an einen alten Client.

Am Testcluster mit dem Moderations-Key:

  1. Label auf einen Kommentar mit Antworten setzen: Platzhalter erscheint, Antworten bleiben, Zähler sinkt.
  2. Label auf ein Konto setzen: Beiträge verschwinden, Gutachten und angenommene Argumente bleiben; ein neuer Kommentar des Kontos erscheint nicht.
  3. Beides zurücknehmen: Der alte Zustand samt Zählern ist wieder da.
  4. queryLabels zeigt Labels und Rücknahmen, ohne interne Felder.
  5. Lasttest: Antwortzeiten der grössten Liste vor und nach dem Filter (offener Punkt «Last»); die Teilindizes nach Bedarf anpassen.

Stand 2026-11-06: Code und Tests sind geschrieben, nicht deployt. Geprüft auf echtem Postgres (test:db), dazu ein Durchlauf mit laufendem CMS und Chromium gegen einen Registrar-Stub. Die Abnahme am Testcluster steht aus. Einrichtung: smartinfo doc/RUNBOOK.md §7.

Abweichungen vom Plan unten:

  • cases.subjectKey (URI oder DID) ist der Schlüssel des Teilindex, statt COALESCE(uri, did). Auch Suche und concurrency nutzen ihn.
  • decisions.targets ist ein Array-Feld, die Nebentabelle heisst decisions_targets.
  • Die Fall-Ansicht bringt ihre Formulare selbst mit und speichert über REST auf decisions. Sie lädt alles über einen Endpunkt GET /api/moderation/cases/:id.
  • «Erneut versuchen» startet einen neuen Job, statt den alten zurückzusetzen.
  • Die Meldestelle ist eine CMS-Seite meldestelle mit Link im Fuss, statt MODERATION_CONTACT_EMAIL.
  • cases anlegen darf nur moderator, nicht die Stellvertretung.
  • Die Standardliste sortiert offene Fälle nach vorne, statt zu filtern: Payload kennt keinen abwählbaren Standardfilter.
  • Der Melde-Knopf gilt nur für Beiträge (Entscheid vom 2026-11-06). Konten nimmt sich das Team selbst vor, mit einem Fall ohne Meldung.
  • Aus Prio 2 vorgezogen: Die Fall-Ansicht zeigt bisherige Meldungen und Massnahmen des Kontos, weil Moderierende daraus entscheiden, ob sie gegen ein Konto vorgehen. Nur moderator, eine Stellvertretung bleibt auf ihren Fall begrenzt.
  • Neu: Ansicht «Beiträge selbst prüfen» (/admin/moderation/recent, nur moderator) mit moderation.listRecent. Das Team kann so von sich aus Fälle öffnen. Eine Liste über alle Vorlagen, jeder Beitrag nennt seine. Bewusst ohne Filter nach Person oder Haltung. Die Reihenfolge folgt createdAt des Records, das die verfassende Person setzt: Wer direkt ans PDS schreibt, kann einen Beitrag zurückdatieren.
  • Neu: Ein Fall lässt sich auch aus einem smartinfo-Link öffnen, für Hinweise per E-Mail. Das Feld uri nimmt ihn an, ein Link auf ein Profil öffnet einen Fall zum Konto.
  • Neu: Fälle heissen «Titel · Autor · Datum» statt nach URI oder DID (Feld label, beim Öffnen festgehalten). Dafür liefern getSubject, getThread und listRecent authorName: den Anzeigenamen aus app_profiles, bei Bluesky-Antworten den Namen oder Handle der Antwort. Migration 018 erlaubt dem Registrar das Lesen von app_profiles.
  • tests/e2e/moderation.e2e.spec.ts fehlt: Der Browser-Durchlauf braucht den Registrar-Stub und lief einmalig ausserhalb des Repos.

Ziel: Der ganze Ablauf von der Meldung bis zum Ausblenden und der Rücknahme funktioniert ohne Handarbeit.

Alles in smartinfo. Ablage in services/cms/src/plugins/moderation/, Komponenten in components/moderation/, Konventionen aus services/cms/CLAUDE.md: Logik rein in lib/, pnpm generate:importmap nach neuen Komponenten, Labels mit l(de, en), nach Schemaänderungen pnpm payload migrate:create und generate:types mit vollständiger ATVOTO_LANGUAGES.

Datei Änderung
collections/Users.ts Felder name, role (moderator, substitute, service), case, active, isAdmin. auth.useAPIKey
Migration Bestehende Logins erhalten isAdmin und active. Der erste Login einer leeren Datenbank wird per Hook ebenfalls isAdmin
lib/access.ts isAdmin, isModerator, isTeam für die Moderation. Die Inhalts-Collections bleiben vorerst unverändert (T1)

Das Feld role und die Regeln an users bleiben auch ohne T1 nötig: Ohne sie könnte sich jedes Login selbst zum moderator machen und das Protokoll lesen. Eine Stellvertretung darf erst mit T1 eingerichtet werden; bis dahin ist sie technisch vorbereitet, aber im RUNBOOK als nicht freigegeben vermerkt.

Regeln an users:

  • Nur isAdmin ändert role, case, active, isAdmin (Feld-Zugriff), und nur isAdmin sieht die Liste. Alle anderen sehen sich selbst.
  • beforeLogin: weist active = false ab.
  • beforeChange, beforeDelete: Eine Änderung, die aktive moderator unter zwei senkt, wird abgelehnt. Das Aufbauen von null auf zwei ist erlaubt (B19).
  • Das Dienst-Konto hat access.admin: false, also kein Admin-Panel.
  • Moderations-Collections sind per admin.hidden für Logins ohne Rolle ausgeblendet. Die Sicherheit kommt aus access, das ist nur Anzeige.

Felder wie im Konzept, in plugins/moderation/collections/, angemeldet über plugins in payload.config.ts. In dieser Etappe cases, reports, decisions, notifications; appeals folgt in Etappe 4, reporters in Etappe 6. decisions.targets ist von Anfang an mehrfach, damit Prio 2 ohne Schemawechsel auskommt.

Zugriff:

Collection Lesen Anlegen Ändern, Löschen
cases moderator; substitute nur ihr Fall Team Team, nur recused; status, snapshot nur per Hook. Löschen niemand
reports wie cases, über case Team, manual; service über Endpunkt niemand
decisions wie cases, über case moderator, substitute niemand
notifications Team nur Local API readAt nur Endpunkt

service hat auf keiner dieser Collections ein Leserecht, nur eigene Endpunkte (B13).

In plugins/moderation/endpoints/. Sie prüfen selbst, wer aufruft, und antworten mit einer festen Feldliste.

Endpunkt Aufrufer Zweck
POST /api/moderation/reports service In inTransaction: offenen Fall suchen oder anlegen, Meldung anhängen, beim neuen Fall snapshot aus getSubject. Trifft das parallele Anlegen den Teilindex, den Fall neu lesen und anhängen
GET /api/moderation/cases/:id/subject Team Fall-Ansicht: Daten über den Registrar, da Komponenten store-publish nicht importieren dürfen. Prüft Rolle und case
GET /api/moderation/cases/:id/thread Team ebenso

Reine Funktionen in plugins/moderation/lib/decisionRules.ts, aufgerufen aus einem beforeChange-Hook auf decisions. So greifen sie auch über die Local API, die access umgeht. Im Konzept steht Befangenheit als Zugriffsregel, der Abschnitt zu Payload verlangt zu Recht einen Hook; der Plan folgt dem Hook.

Regel Eingabe
Grund und Begründung je nach action Daten
Gutachten nie hide, suspend, delete getSubject
Angenommenes Argument nur illegal getSubject
Befangene entscheiden nicht; Stellvertretung nur im eigenen Fall req.user, Fall
Einspruch durch andere Person; nur gegen Massnahmen relatesTo
suspend nur mit illegal oder severeAbuse Daten
Konto-Massnahme nur bei Mitgliedern getSubject (origin)
reverse und lift nur gegen eine noch gültige Massnahme des passenden Typs Entscheide zum Fall
reverse, lift, appeal erst, wenn der Workflow des betroffenen Entscheids fertig ist; sonst Meldung «Massnahme läuft noch» (B11) Job-Tabelle

Ist der Registrar nicht erreichbar, schlägt das Speichern fehl: Eine Schutzregel darf nicht wegen eines Ausfalls entfallen. decidedBy, decidedAt setzt der Hook; Eingaben dazu werden ignoriert.

afterChange auf decisions: setzt status des Falls, startet den Workflow, deaktiviert eine Stellvertretung (das Entfernen der Sitzungen folgt mit T1). Alles mit req, also in derselben Transaktion.

Handgeschriebene Migration:

  • Teilindex UNIQUE (subject_type, COALESCE(uri, did)) WHERE status = 'open' auf cases.
  • Trigger gegen UPDATE und DELETE auf decisions, reports und ihren Nebentabellen, mit dem Schalter moderation.purge (B15). Vorher prüfen, ob Payload beim Anlegen nachträglich UPDATE absetzt; falls ja, Trigger auf die betroffenen Spalten begrenzen.

In payload.config.ts, nach den Ergebnissen aus Schritt 0.3, dazu eine Migration für die Job-Tabellen:

  • Queue moderation, autoRun alle 15 s, enableConcurrencyControl, deleteJobOnComplete: false.
  • Tasks mit retries: { attempts: 20 } und ohne Backoff. Jeder Lauf ist ein Versuch, das überbrückt rund fünf Minuten Ausfall des atvoto-Stores.
  • onInit setzt processing in der Queue moderation auf false, bevor Jobs laufen. Das setzt eine einzige Replik voraus; bei mehreren braucht es eine Zeitgrenze.

plugins/moderation/lib/planJobs.ts leitet die Tasks ab, rein und mit Tabellen getestet. In Etappe 3 genügen die Zeilen für dismiss, hide und reverse. Die Zeilen der Etappe 5 hängen vom betroffenen Entscheid ab und kommen mit T3 dazu; die Tabelle zeigt das Ziel.

Entscheid Gegenstand Tasks in dieser Reihenfolge Etappe
dismiss alle keine; Fall dismissed 3
hide Beitrag setLabel(uri) · notify 3
hide Konto setLabel(did) · notify 3
reverse von hide wie dort negateLabel · notify 3
revokeParticipation Konto setParticipationRevoked · notify 5
reverse von revokeParticipation Konto clearParticipationRevoked · notify 5
suspend Beitrag, gehostet setLabel · pdsTakedown(Records) · notify 5
suspend Beitrag, extern setLabel · notify 5
suspend Konto, gehostet setLabel(did) · setParticipationRevoked · pdsTakedown · notify 5
suspend Konto, extern setLabel(did) · setParticipationRevoked · notify 5
lift von suspend Beitrag negateLabel · pdsRestore, falls gehostet · notify 5
lift von suspend Konto negateLabel · clearParticipationRevoked · pdsRestore, falls gehostet · notify 5
delete Beitrag auf dem eigenen PDS keine; wartet auf confirmDelete 5
confirmDelete wie delete siehe Etappe 5.5 5
appeal, granted wie der angefochtene Entscheid wie reverse oder lift · notify mit appealResult 4, 5
appeal, denied alle notify mit appealResult 4
  • concurrency pro Gegenstand (uri oder did), exclusive. Zusammen mit dem Verzicht auf Backoff und der Prüfung aus 3.4 überholt eine Rücknahme nie das Label (B11).
  • registrar() in lib/store-publish.ts erhält die Option, STORE_MODERATION_KEY statt des Client-Keys zu nutzen.
  • Bekommt ein Task eine dauerhafte Antwort (etwa not_hosted), gilt er als erledigt, statt zu wiederholen; nur Netz- und 5xx-Fehler wiederholen sich.
  • notify schreibt did, decision, kind; Felder aus reports kennt der Task gar nicht.
  • Scheitert ein Task endgültig, zeigt die Fall-Ansicht das aus der Job-Tabelle, mit dem Knopf «Erneut versuchen», der hasError und die Zähler zurücksetzt.
  • cases: Standardliste status = open, älteste zuerst. Die Sortierung gehört formal zu Prio 2, kostet aber nichts.
  • components/moderation/CasePanel.tsx: Inhalt, Konto, Zustand im Index und auf dem PDS, gültige Labels, Meldungen samt meldender Person, bisherige Entscheide zum Fall, Stand der Jobs.
  • components/moderation/ThreadView.tsx: Kommentarverlauf.
  • Formular für Entscheide mit vorbelegtem Fall; Felder blenden sich je nach action ein. Grund mit deutschen Namen, Werte aus einer Liste in lib/labelValues.ts.
  • «Fall ohne Meldung anlegen» für das Handeln ohne Meldung; «Meldung erfassen» mit source = manual.
Datei (smartinfo) Änderung
services/client-api/src/routes/moderation.py (neu), main.py POST /moderation/reports mit reporterDid aus der Sitzung, Rate-Limit über limiter gegen Fehlbedienung. Das ist kein Tageslimit im Sinn des Konzepts
services/client-api/src/config.py CMS_SERVICE_API_KEY, MODERATION_CONTACT_EMAIL
services/frontend/src/lib/client-api-proxy.ts oder api/app/[...path]/route.ts internal/ ablehnen
services/frontend/src/lib/api/index.ts, lib/queries/moderation.ts reportSubject
services/frontend/src/components/report-dialog.tsx (neu) Grund aus den sechs Werten oder «anderes», Kommentar; an Argument und Kommentar. Nur für Angemeldete
Impressum, messages/*.json Adresse der Meldestelle (E7); Texte in fünf Sprachen
deploy/base/cms, deploy/base/client-api, deploy/overlays/test neue Variablen; Secrets von Hand, wie im Kopf der Deployments vermerkt
doc/RUNBOOK.md Dienst-Konto, Moderations-Key, Team anlegen

Zwei Suiten, denn tests/int läuft heute ohne Datenbank (B18):

Ort Prüft
tests/int/moderation*.int.spec.ts alle Zeilen aus «Regeln», planJobs als Tabelle, Registrar gemockt, «keine Felder aus reports in notify»
tests/db/moderation.db.spec.ts (neu, eigene vitest-Konfiguration, Postgres-Service im CMS-Job von build-images.yml) Zugriff über REST je Rolle, Trigger, Teilindex beim parallelen Anlegen, Workflow-Reihenfolge, Wiederholung ohne Doppelwirkung, Neustart nach einem Absturz im Job, die Fälle aus Schritt 0.3 auf Postgres, kein Hook auf reports mit Wirkung
services/client-api/tests/test_moderation.py reporterDid aus der Sitzung, ein mitgeschickter Wert wird ignoriert
services/frontend Proxy lehnt internal/ ab, Dialog
tests/e2e/moderation.e2e.spec.ts Fall öffnen, entscheiden, Stand der Jobs sichtbar

Abnahme: Am Testcluster die Abläufe «Meldung bis Ausblenden» und «Rücknahme» aus MODERATION.md mit zwei Logins und einem Mitgliedskonto. Ein Login ohne Rolle sieht und ändert nichts in der Moderation und kann sich keine Rolle geben.

Ziel: Betroffene erfahren Entscheid und Grund und können Einspruch erheben.

Ort (smartinfo) Änderung
plugins/moderation/endpoints/ GET /api/moderation/notifications?did=, POST …/:id/read, nur service. Antwort: id, kind, action, reason, decisionId, createdAt, readAt; bei appealResult zusätzlich outcome. Nie justification, decidedBy, Meldedaten
client-api, routes/moderation.py GET /moderation/notifications, POST /moderation/notifications/{id}/read; did aus der Sitzung
Frontend lib/queries/moderation.ts, Eintrag in app-nav.tsx mit Zähler, Seite app/(app)/notifications/ samt Eintrag im matcher von src/proxy.ts; Knopf «Einspruch erheben»
  • Collection appeals wie im Konzept. Team legt an; service nur über den Endpunkt; status setzt nur der Hook.
  • POST /api/moderation/appeals: prüft, dass die did von der Massnahme betroffen ist, und dass es für diesen Entscheid keinen offenen Einspruch gibt (B14). Antwort ohne interne Felder.
  • Die Liste appeals zeigt standardmässig status = open und ist neben den Fällen die zweite Arbeitsliste (B12). Die Fall-Ansicht weist auf einen offenen Einspruch hin; eine substitute sieht nur Einsprüche zu ihrem Fall.
  • Regeln aus 3.4 für appeal: relatesTo passt zu appeals.decision, outcome ist Pflicht, decidedBy ist nicht die Person des angefochtenen Entscheids, Befangene sind ausgeschlossen.
  • afterChange: setzt appeals.status.
  • client-api: POST /moderation/appeals, did aus der Sitzung.
  • Frontend: Dialog aus der Benachrichtigung.

Rollen- und Fremdzugriff auf Benachrichtigungen, Feldliste der Antwort, Einspruch einer nicht betroffenen Person, derselbe Moderator als zweite Instanz, granted nimmt zurück, denied meldet nur.

Abnahme: Ablauf Ausblenden, Benachrichtigung, Einspruch, Entscheid durch die zweite Person, Ergebnis bei der betroffenen Person, die nirgends die meldende Person sieht.

Ziel: Widerruf, Sperre, Löschen und Sperre beim Login. Danach ist Prio 1 vollständig.

  • POST revokeParticipant auf require_moderation umstellen, wiederholbar machen: Ein Mitglied, das bereits widerrufen ist, gibt 200; not_found bleibt für Unbekannte.
  • POST reinstateParticipant neu.
  • Tests und Doku anpassen: test_registrar.py, COMMANDS.md, NEW_CLIENT.md, ARCHITECTURE_SPLIT.md.
  • Prüfen und testen, was das Konzept voraussetzt: Der Gate in record_handler.js läuft auch für update, Löschungen werden weiter übernommen, das Los für das Peer Review schliesst widerrufene Konten aus.
  • pds_admin.py: update_subject_status(subject, takedown, ref) mit den drei Formen aus dem Konzept.
  • POST moderation.setSubjectStatus: not_hosted (409) bei externen Konten. cid aus dem Index. Bei Argumenten vorerst nur die kanonische Kopie (T2).
  • Bestehende Sitzungen sind in 5.3 geregelt. Kein neuer Code im Indexer; #account bleibt unbeachtet, wie im Konzept begründet.
Datei (smartinfo) Änderung
db/migrations/002_suspended_at.sql, db/db-setup.sql auth.auth_creds.suspended_at
services/client-api/src/auth/account_status.py (neu) check_account(did): getRepoStatus mit 3 s Timeout. takendown, suspended: suspended_at setzen, Sitzungen löschen, Token aus _token_cache entfernen. Aktiv: suspended_at leeren. Nicht erreichbar: suspended_at entscheidet. deactivated und RepoNotFound gelten nicht als Sperre
auth/login.py login_account prüft vor create_session; Antwort 403 account_suspended mit reason, decidedAt, contact. Der Kommentar «Local only» am Dateianfang wird angepasst
plugins/moderation/endpoints/ GET /api/moderation/account-status?did=, nur service: letzter Entscheid suspend ohne lift, nur reason und decidedAt
atproto/errors.py, atproto/pds.py Kategorie ACCOUNT_SUSPENDED (403). In _send nach dem zweiten 401 und in _login bei 401 check_account aufrufen; nur bei bestätigter Sperre die neue Kategorie, sonst wie bisher
services/frontend/src/lib/client-api-proxy.ts Session-Cookie auch bei 403 account_suspended löschen
app/(auth)/auth/verify/verify-client.tsx, magic-link-sent/page.tsx, lib/api/ Code abfangen und zur Sperrseite leiten
app/(auth)/auth/suspended/page.tsx (neu), messages/*.json Entscheid, Grund, Adresse; allgemeiner Hinweis, wenn kein Entscheid vorliegt

Der Grund erscheint nur nach dem Magic Link. /auth/start verrät nichts über eine Sperre.

Tests: tests/test_account_status.py, Ergänzungen in test_auth_login.py, test_auth_unified_flow.py, test_pds.py: gesperrt, aufgehoben, PDS nicht erreichbar mit und ohne suspended_at, 401 in laufender Sitzung, abgelaufenes Token löst keine Sperre aus.

run_mirror_sync_forever in src/atproto/crosspost.py, gestartet in src/main.py. Sie läuft unabhängig von APPVIEW_CROSSPOST_ENABLED: Auch nach dem Abschalten des Crossposts müssen Ausgeblendete verschwinden.

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 (B8)
hidden AND bsky_post_uri IS NOT NULL AND bsky_post_removed IS NULL Record in bsky_post_removed sichern, dann löschen. Fehlt er schon, leer sichern und protokollieren
NOT hidden AND NOT deleted AND bsky_post_removed IS NOT NULL Mit putRecord unter gleichem rkey anlegen, bsky_post_removed leeren

Tests für jede Zeile, Wiederholung, Absturz zwischen Sichern und Löschen. Offen bleibt der Punkt aus «Testbedarf»: Ob Bluesky die alten Antworten wieder anhängt, lässt sich erst mit Anschluss ans Relay prüfen; dieser Punkt blockiert den Start nicht, solange Crosspost aus ist.

Registrar: moderation.deleteCommunityRecord, moderation.purgeContent, moderation.purgeLabels.

purgeContent leert, wiederholbar, in dieser Reihenfolge:

  1. content_purged_at setzen, zuerst, damit kein Upsert mehr dazwischenfunkt.
  2. Argument: title, body leer, translations = '[]', bsky_post_removed = NULL.
  3. Kommentar: title, text auf NULL.

Übersetzungs-Records, Embeddings und Warteschlangen folgen mit T2. Das Konzept nennt auch Caches. AppView und Writer halten heute keinen Inhalt im Cache, nur DID-Schlüssel; dafür ist nichts zu tun.

client-api: POST /internal/moderation/delete-record mit CLIENT_API_MODERATION_SECRET, Vergleich mit hmac.compare_digest; ruft delete_record. Ohne die Prüfung aus 5.3, damit das Löschen nach einem Restore nicht wieder in die Sperre läuft. Fehlendes Secret in prod: Fehler beim Start.

Payload: Reihenfolge nach confirmDelete:

  1. setLabel, falls keines besteht.
  2. deleteRecord: Kommentar über die client-api, kanonische Kopie eines Arguments über den Registrar.
  3. purgeContent, danach snapshot im Fall löschen (cases liegt nicht unter dem Trigger).
  4. notify.
  5. purgeLabels.

Bei einem gesperrten Konto scheitert Schritt 2 für Kommentare, bis T2 umgesetzt ist. Die Fall-Ansicht zeigt den Fehler; das Team hebt die Sperre dann von Hand auf.

Tasks aus der Tabelle in 3.5 ergänzen; suspend mit der Bestätigung severeAbuse; lift. Der Einspruch gegen suspend läuft über lift.

Löschung (E6). Weil decisions nur angelegt wird, besteht die Löschung aus zwei Einträgen:

Regel Umsetzung
Vorschlag action = delete, nur mit reason = illegal (Art. 45 Abs. 3). Keine Wirkung, kein Job, keine Benachrichtigung; der Fall bleibt offen und zeigt «Löschung wartet auf Bestätigung»
Bestätigung Neue Aktion confirmDelete mit relatesTo auf den Vorschlag. decidedBy ist nicht die Person des Vorschlags, Befangene sind ausgeschlossen. Erst dieser Eintrag startet die Jobs
Ablehnung Die zweite Person bestätigt nicht und schliesst den Fall mit dismiss oder hide; damit ist der Vorschlag erledigt
Bei Eile Zuerst hide, die Löschung läuft danach in Ruhe
Nur auf dem eigenen PDS Für Kommentare externer Konten ist delete nicht wählbar, dort bleibt hide mit normalem Einspruch
Hinweis im Team Das Formular sagt bei beiden Schritten, dass die Löschung endgültig ist und es keinen Einspruch gibt
Hinweis an die Person notify mit eigenem Text: Grund, «endgültig», kein Einspruch. Im Frontend fehlt dort der Knopf «Einspruch erheben»
Kein Einspruch Der Endpunkt aus 4.2 und die Regel für appeal lehnen delete und confirmDelete als angefochtenen Entscheid ab

Ablauf «Kontosperre mit Einspruch» aus MODERATION.md vollständig am Testcluster, dazu:

  • Externes Konto: nur Label und Widerruf, keine Fehlermeldung.
  • Gutachten und angenommene Argumente bleiben sichtbar und zählen.
  • Löschen eines Kommentars: Inhalt fehlt in getRepo, im Index und im snapshot; kein Label mehr.
  • Löschen eines Arguments: kanonische Kopie weg, kein Spiegel-Record aufbewahrt.
  • Ein Vorschlag allein bewirkt nichts; dieselbe Person kann ihn nicht bestätigen; gegen eine Löschung ist kein Einspruch möglich.

Damit ist Prio 1 fertig, bis auf die Punkte aus Später. Abgleich mit der Abdeckungstabelle unten.

Ziel: Das Team kommt auch bei mehr Fällen zurecht, und Missbrauch des Meldewegs lässt sich eindämmen. Alles in smartinfo.

Funktion Umsetzung
Mehrere Meldungen als ein Fall steht seit Etappe 3
Filter nach Status, Inhaltstyp, Alter Filter der Payload-Liste auf den Feldern von cases
Filter nach Grund Der Grund liegt an der Meldung. Neues Feld cases.reasons (mehrfach), vom Melde-Endpunkt nachgeführt, damit die Liste filtern kann
Suche nach Konto oder Beitrag listSearchableFields: ['did', 'uri']
Älteste zuerst Standardsortierung; steht seit Etappe 3
  • Sammelaktion in der Liste: components/moderation/BulkDecision.tsx plus Endpunkt, der genau einen Entscheid mit mehreren targets anlegt. Regeln aus 3.4 laufen pro Ziel; scheitert eines, wird nichts gespeichert. planJobs legt die Tasks pro Ziel an; der Schlüssel (decision_id, uri, val, neg) trägt das bereits.
  • Collection reporters mit Versionen.
  • Melde-Endpunkt setzt muted aus reporters.mutedUntil. Die Liste blendet Fälle aus, die nur stumme Meldungen tragen (Feld cases.visibleReportCount, vom Endpunkt nachgeführt).
  • Stummschalten und Aufheben: Knöpfe in der Fall-Ansicht, Feld leeren genügt.
  • E-Mail: Adapter @payloadcms/email-nodemailer und SMTP-Secrets in cms-secrets. Ohne Konfiguration protokolliert er nur, wie heute bei der client-api in der Entwicklung.
  • Alarm: Hook auf reports ab 10 Meldungen zum selben Fall, zählt unterschiedliche reporterDid, damit eine Person ihn nicht allein auslöst. Neues Feld cases.alarmSentAt sorgt dafür, dass er nur einmal kommt. Der Hook bewirkt nur eine E-Mail, nie ein Label.
  • Sammelmail und Hinweis auf lange offene Fälle: Payload-Tasks mit schedule, Queue moderation-digest. Die Mail enthält nur Zahlen und Links, keine Inhalte und keine meldenden Personen. Die Schwelle für «lange offen» wird über eine Variable gesetzt.
  • notifications.kind um reportReceived, reportClosed erweitern, decision wird dafür optional. Der Text ist allgemein und nennt weder Massnahme noch betroffene Person.
  • Neue Tasks beim Eingang einer Meldung und bei dismiss oder dem ersten Entscheid auf dem Fall, einmal pro meldender Person. Stumme Meldungen und manual ohne reporterDid bekommen nichts.
  • Stand des Einspruchs: GET /moderation/appeals in der client-api, Anzeige in den Benachrichtigungen.

Filter und Suche mit Testdaten, Sammelaktion mit einem geschützten Ziel (nichts gespeichert), Stummschalten, Alarm bei der zehnten unterschiedlichen Person, genau einmal, Rückmeldung ohne Angaben zum Ergebnis gegenüber Dritten.

Zum Abgleich mit dem Funktionsumfang in MODERATION.md.

Bereich Funktionen Etappe
Melden Melde-Knopf, von Hand erfassen, keine automatische Wirkung 3
Sichten Warteschlange, Inhalt und Konto, Zusammenhang, Status 3
Entscheiden Abweisen, Ausblenden, Grund, Begründung, Handeln ohne Meldung, Zurücknehmen 3
Entscheiden Externe Antworten eingeklappt 2
Schutzregeln Gutachten, angenommene Argumente, Befangenheit, Stellvertretung 2 (Indexer), 3 (Payload)
Stärkere Massnahmen Widerruf, Sperre allein, Kontosperre, externe Konten, Aufheben, Löschen 5
Wirkung Listen, Suche, Feed, Verdichtung, Zähler, Übersetzung, Ähnlichkeit, Platzhalter 2
Wirkung Spiegelpost 5
Betroffene Benachrichtigung, «nie wer gemeldet hat» 3, 4
Betroffene Sperre beim nächsten Login 5
Einspruch Ohne Frist, andere Person, Ergebnis 4; gegen Sperre und Löschen 5
Protokoll Wer, was, wann, warum; nicht änderbar 3
Protokoll Signiert, Standardformat, öffentliche Schnittstelle 1, 2
Zugang Zwei Personen, persönliches Login, Stellvertretung 3
Prio 2 Warteschlange, Arbeit am Fall, Missbrauch, Rückmeldungen 6

Pro Etappe gilt dieselbe Reihenfolge: zuerst Datenbank, dann Store-Dienste, dann Client.

Etappe Reihenfolge Rückweg
1 Labeler-Konto und Schlüssel, Migration 016, Registrar Migration fügt nur hinzu; DROP ROLE ozone ist die einzige Ausnahme und unbedenklich
2 Client-Darstellung (2.5), dann Indexer, AppView, Writer Vor dem ersten echten Label folgenlos. Filter ohne Labels ändern keine Antwort; Ausnahme ist comment_count
3 CMS-Migration, CMS, client-api, Frontend Knopf ausblenden. Bestehende Logins behalten über isAdmin ihre Rechte
4 CMS, client-api, Frontend wie 3
5 Registrar, Writer, Migration in smartinfo, client-api, Frontend, zuletzt CMS Die Aktionen suspend und delete erscheinen erst mit dem CMS, wenn alle anderen Teile laufen
6 CMS, client-api, Frontend; SMTP-Secret vorher Tasks abschalten

Vor Etappe 3 am Testcluster prüfen, dass alle bestehenden Logins nach der Migration isAdmin tragen. Sonst kann niemand mehr Rollen vergeben.

Datei Änderung Etappe
doc/architektur/MODERATION.md Befunde und Entscheide einarbeiten; Status laufend 0, dann je Etappe
doc/architektur/DB_ROLES.md neue Rechte, ozone entfernen 1
doc/recht/verordnungen/VO_MODERATION.md E6: § 2 Abs. 7 um zweite Person und Endgültigkeit ergänzen, in § 6 festhalten, dass Löschungen keine Massnahme sind und kein Einspruch besteht. E3, falls die Texte daran angepasst werden sollen 0
doc/architektur/ARCHITECTURE_SPLIT.md, doc/betrieb/NEW_CLIENT.md, COMMANDS.md Moderations-Key, neue Routen, revokeParticipant 1, 5
README.md queryLabels statt tools.ozone.* 2
doc/betrieb/PRODUCTION_DEPLOYMENT.md Checkliste um Labeler und Moderations-Key ergänzen 1
smartinfo:doc/RUNBOOK.md, atvoto-Runbook Team, Takedown von Hand, Schlüsseltausch 3, 5
  • T1 bis T4 aus Später, soweit nicht schon umgesetzt.
  • Juristische Prüfung: Begriff der Löschpflicht (VO § 2), öffentliche Labels auf pseudonymen Konten (VO § 8), Frist (VO § 9), schwerer Missbrauch (VO § 7), Löschung ohne Einspruch (E6).
  • purgeExpired, sobald die Frist feststeht. Es muss vor dem Ablauf der ersten Frist laufen.
  • Spiegelposts am Relay prüfen, bevor Crosspost in prod angeht.
  • Probelauf von Meldung bis Einspruch mit dem echten Team.
  • Offene Punkte aus MODERATION.md, die dieser Plan nicht löst: deaktivierte Konten, Alarm bei einer Flut über viele Fälle.