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_labelin 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.
Überblick
Abschnitt betitelt „Überblick“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 ──→ 6Etappe 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.
Befunde aus der Prüfung
Abschnitt betitelt „Befunde aus der Prüfung“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.
Vom Plan erfasst
Abschnitt betitelt „Vom Plan erfasst“| # | 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.
Entscheide
Abschnitt betitelt „Entscheide“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 |
Etappe 0: Vorbereitung
Abschnitt betitelt „Etappe 0: Vorbereitung“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 |
Ergebnisse (2026-11-06)
Abschnitt betitelt „Ergebnisse (2026-11-06)“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.directorynimmt sie an. Die Antwort ist 200, aber kein JSON. - Die DID enthält danach
#atproto_label.#atproto,rotationKeys,alsoKnownAsundservicesbleiben 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.
Etappe 1: Store-Grundlage
Abschnitt betitelt „Etappe 1: Store-Grundlage“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.sqlundparticipation_check.sqlgrün.- Labeler
moderation.test.smartvote.social(did:plc:jio5mns5doxvshgurja2nn5l),#atproto_labelin der DID.- Signaturschlüssel in
registrar-secrets, Moderations-Key inregistrar-moderation-smartinfo.Nach dem Deploy noch offen:
deploy/apply-test.shfürLABELER_DIDin 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.
1.1 Migration db/migrations/016_moderation.sql
Abschnitt betitelt „1.1 Migration db/migrations/016_moderation.sql“Umfang wie in MODERATION.md, «Store: Datenbank», mit diesen Ergänzungen:
ALTER TABLE app_arguments ADD COLUMN IF NOT EXISTS content_purged_at timestamptz; -- E4ALTER 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 ozoneentfernen.db/tests/grants.sql: Soll-Liste nachführen;ozoneaus den beiden Rollenlisten entfernen.- DB_ROLES.md: Übersicht, Begründung, Zeile
ozoneund Abschnitt «Geplant» entfernen. - Neu
db/tests/hidden_rule.sql, indb/tests/run.sheingebunden.
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.
1.2 Moderations-Key
Abschnitt betitelt „1.2 Moderations-Key“services/writer/src/registrar/auth.py:require_moderationwierequire_client, gegenmoderation_key_hash.scripts/create_client.sh: zweiten Schlüssel erzeugen, im eigenen Secretregistrar-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.
1.3 Labeler-Konto und Signatur
Abschnitt betitelt „1.3 Labeler-Konto und Signatur“services/writer/requirements.in:cryptography,libipld;requirements.txtnachCOMMANDS.mderzeugen. Prüfen, dass das Writer-Image die Wheels installiert._RESERVED_PREFIXESinregistrar/service.py:moderationergänzen.- Neu
services/writer/src/registrar/labels.py:sign_label(label, key)nach dem Rezept im Konzept.ctsals UTC mitZund festen Millisekunden erzeugen; dieser Text wird signiert und gespeichert.write_label(conn, …)in einer Transaktion mitpg_advisory_xact_lock,ON CONFLICT (decision_id, uri, val, neg) DO NOTHING, liefert auch bei einer Wiederholung das bestehendeseq.
- Neu
scripts/labeler_plc_update.pyaus Schritt 0.2. Es zeigt die Änderung vor dem Einreichen an und fragt nach. Dasselbe Skript dient später dem Schlüsseltausch. - Variablen
LABELER_DIDindeploy/overlays/test/{registrar,indexer,appview}-config.env,LABELER_SIGNING_KEY_HEXinregistrar-secrets; Kommentar indeploy/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.
1.4 Registrar-Routen, Teil 1
Abschnitt betitelt „1.4 Registrar-Routen, Teil 1“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.
1.5 Tests und Abnahme
Abschnitt betitelt „1.5 Tests und Abnahme“| 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.
Etappe 2: Lesemodell
Abschnitt betitelt „Etappe 2: Lesemodell“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.
2.1 Indexer
Abschnitt betitelt „2.1 Indexer“| 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), fremdesrc.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.
2.2 AppView
Abschnitt betitelt „2.2 AppView“| 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.
2.3 Writer und Poller
Abschnitt betitelt „2.3 Writer und Poller“| 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) |
2.4 Peer Review pausieren (E1)
Abschnitt betitelt „2.4 Peer Review pausieren (E1)“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.
2.5 Darstellung im Client
Abschnitt betitelt „2.5 Darstellung im Client“| 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.
2.6 Abnahme
Abschnitt betitelt „2.6 Abnahme“Am Testcluster mit dem Moderations-Key:
- Label auf einen Kommentar mit Antworten setzen: Platzhalter erscheint, Antworten bleiben, Zähler sinkt.
- Label auf ein Konto setzen: Beiträge verschwinden, Gutachten und angenommene Argumente bleiben; ein neuer Kommentar des Kontos erscheint nicht.
- Beides zurücknehmen: Der alte Zustand samt Zählern ist wieder da.
queryLabelszeigt Labels und Rücknahmen, ohne interne Felder.- Lasttest: Antwortzeiten der grössten Liste vor und nach dem Filter (offener Punkt «Last»); die Teilindizes nach Bedarf anpassen.
Etappe 3: Payload und Meldungen
Abschnitt betitelt „Etappe 3: Payload und Meldungen“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: smartinfodoc/RUNBOOK.md§7.Abweichungen vom Plan unten:
cases.subjectKey(URI oder DID) ist der Schlüssel des Teilindex, stattCOALESCE(uri, did). Auch Suche undconcurrencynutzen ihn.decisions.targetsist ein Array-Feld, die Nebentabelle heisstdecisions_targets.- Die Fall-Ansicht bringt ihre Formulare selbst mit und speichert über REST auf
decisions. Sie lädt alles über einen EndpunktGET /api/moderation/cases/:id.- «Erneut versuchen» startet einen neuen Job, statt den alten zurückzusetzen.
- Die Meldestelle ist eine CMS-Seite
meldestellemit Link im Fuss, stattMODERATION_CONTACT_EMAIL.casesanlegen darf nurmoderator, 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, nurmoderator) mitmoderation.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 folgtcreatedAtdes 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
urinimmt 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 lieferngetSubject,getThreadundlistRecentauthorName: den Anzeigenamen ausapp_profiles, bei Bluesky-Antworten den Namen oder Handle der Antwort. Migration 018 erlaubt dem Registrar das Lesen vonapp_profiles.tests/e2e/moderation.e2e.spec.tsfehlt: 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.
3.1 Benutzer und Zugriff
Abschnitt betitelt „3.1 Benutzer und Zugriff“| 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ändertrole,case,active,isAdmin(Feld-Zugriff), und nurisAdminsieht die Liste. Alle anderen sehen sich selbst. beforeLogin: weistactive = falseab.beforeChange,beforeDelete: Eine Änderung, die aktivemoderatorunter 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.hiddenfür Logins ohne Rolle ausgeblendet. Die Sicherheit kommt ausaccess, das ist nur Anzeige.
3.2 Collections
Abschnitt betitelt „3.2 Collections“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).
3.3 Endpunkte
Abschnitt betitelt „3.3 Endpunkte“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 |
3.4 Regeln
Abschnitt betitelt „3.4 Regeln“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'aufcases. - Trigger gegen
UPDATEundDELETEaufdecisions,reportsund ihren Nebentabellen, mit dem Schaltermoderation.purge(B15). Vorher prüfen, ob Payload beim Anlegen nachträglichUPDATEabsetzt; falls ja, Trigger auf die betroffenen Spalten begrenzen.
3.5 Jobs
Abschnitt betitelt „3.5 Jobs“In payload.config.ts, nach den Ergebnissen aus Schritt 0.3, dazu eine Migration für die Job-Tabellen:
- Queue
moderation,autoRunalle 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. onInitsetztprocessingin der Queuemoderationauffalse, 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 |
concurrencypro Gegenstand (urioderdid),exclusive. Zusammen mit dem Verzicht auf Backoff und der Prüfung aus 3.4 überholt eine Rücknahme nie das Label (B11).registrar()inlib/store-publish.tserhält die Option,STORE_MODERATION_KEYstatt 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. notifyschreibtdid,decision,kind; Felder ausreportskennt der Task gar nicht.- Scheitert ein Task endgültig, zeigt die Fall-Ansicht das aus der Job-Tabelle, mit dem Knopf «Erneut versuchen», der
hasErrorund die Zähler zurücksetzt.
3.6 Admin-Oberfläche
Abschnitt betitelt „3.6 Admin-Oberfläche“cases: Standardlistestatus = 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
actionein. Grund mit deutschen Namen, Werte aus einer Liste inlib/labelValues.ts. - «Fall ohne Meldung anlegen» für das Handeln ohne Meldung; «Meldung erfassen» mit
source = manual.
3.7 client-api, Frontend, Meldestelle
Abschnitt betitelt „3.7 client-api, Frontend, Meldestelle“| 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 |
3.8 Tests
Abschnitt betitelt „3.8 Tests“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.
Etappe 4: Betroffene und Einspruch
Abschnitt betitelt „Etappe 4: Betroffene und Einspruch“Ziel: Betroffene erfahren Entscheid und Grund und können Einspruch erheben.
4.1 Benachrichtigungen
Abschnitt betitelt „4.1 Benachrichtigungen“| 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» |
4.2 Einspruch
Abschnitt betitelt „4.2 Einspruch“- Collection
appealswie im Konzept. Team legt an;servicenur über den Endpunkt;statussetzt nur der Hook. POST /api/moderation/appeals: prüft, dass diedidvon der Massnahme betroffen ist, und dass es für diesen Entscheid keinen offenen Einspruch gibt (B14). Antwort ohne interne Felder.- Die Liste
appealszeigt standardmässigstatus = openund ist neben den Fällen die zweite Arbeitsliste (B12). Die Fall-Ansicht weist auf einen offenen Einspruch hin; einesubstitutesieht nur Einsprüche zu ihrem Fall. - Regeln aus 3.4 für
appeal:relatesTopasst zuappeals.decision,outcomeist Pflicht,decidedByist nicht die Person des angefochtenen Entscheids, Befangene sind ausgeschlossen. afterChange: setztappeals.status.- client-api:
POST /moderation/appeals,didaus der Sitzung. - Frontend: Dialog aus der Benachrichtigung.
4.3 Tests und Abnahme
Abschnitt betitelt „4.3 Tests und Abnahme“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.
Etappe 5: Stärkere Massnahmen
Abschnitt betitelt „Etappe 5: Stärkere Massnahmen“Ziel: Widerruf, Sperre, Löschen und Sperre beim Login. Danach ist Prio 1 vollständig.
5.1 Widerruf
Abschnitt betitelt „5.1 Widerruf“POST revokeParticipantaufrequire_moderationumstellen, wiederholbar machen: Ein Mitglied, das bereits widerrufen ist, gibt 200;not_foundbleibt für Unbekannte.POST reinstateParticipantneu.- 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.jsläuft auch fürupdate, Löschungen werden weiter übernommen, das Los für das Peer Review schliesst widerrufene Konten aus.
5.2 PDS-Sperre
Abschnitt betitelt „5.2 PDS-Sperre“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.cidaus dem Index. Bei Argumenten vorerst nur die kanonische Kopie (T2).- Bestehende Sitzungen sind in 5.3 geregelt. Kein neuer Code im Indexer;
#accountbleibt unbeachtet, wie im Konzept begründet.
5.3 Sperre beim Login
Abschnitt betitelt „5.3 Sperre beim Login“| 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.
5.4 Spiegelposts
Abschnitt betitelt „5.4 Spiegelposts“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.
5.5 Löschen
Abschnitt betitelt „5.5 Löschen“Registrar: moderation.deleteCommunityRecord, moderation.purgeContent, moderation.purgeLabels.
purgeContent leert, wiederholbar, in dieser Reihenfolge:
content_purged_atsetzen, zuerst, damit kein Upsert mehr dazwischenfunkt.- Argument:
title,bodyleer,translations = '[]',bsky_post_removed = NULL. - Kommentar:
title,textaufNULL.
Ü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:
setLabel, falls keines besteht.deleteRecord: Kommentar über die client-api, kanonische Kopie eines Arguments über den Registrar.purgeContent, danachsnapshotim Fall löschen (casesliegt nicht unter dem Trigger).notify.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.
5.6 Payload: übrige Tasks
Abschnitt betitelt „5.6 Payload: übrige Tasks“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 |
5.7 Abnahme
Abschnitt betitelt „5.7 Abnahme“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 imsnapshot; 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.
Etappe 6: Prio 2
Abschnitt betitelt „Etappe 6: Prio 2“Ziel: Das Team kommt auch bei mehr Fällen zurecht, und Missbrauch des Meldewegs lässt sich eindämmen. Alles in smartinfo.
6.1 Warteschlange
Abschnitt betitelt „6.1 Warteschlange“| 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 |
6.2 Arbeit am Fall
Abschnitt betitelt „6.2 Arbeit am Fall“- Sammelaktion in der Liste:
components/moderation/BulkDecision.tsxplus Endpunkt, der genau einen Entscheid mit mehrerentargetsanlegt. Regeln aus 3.4 laufen pro Ziel; scheitert eines, wird nichts gespeichert.planJobslegt die Tasks pro Ziel an; der Schlüssel(decision_id, uri, val, neg)trägt das bereits.
6.3 Missbrauch
Abschnitt betitelt „6.3 Missbrauch“- Collection
reportersmit Versionen. - Melde-Endpunkt setzt
mutedausreporters.mutedUntil. Die Liste blendet Fälle aus, die nur stumme Meldungen tragen (Feldcases.visibleReportCount, vom Endpunkt nachgeführt). - Stummschalten und Aufheben: Knöpfe in der Fall-Ansicht, Feld leeren genügt.
- E-Mail: Adapter
@payloadcms/email-nodemailerund SMTP-Secrets incms-secrets. Ohne Konfiguration protokolliert er nur, wie heute bei der client-api in der Entwicklung. - Alarm: Hook auf
reportsab 10 Meldungen zum selben Fall, zählt unterschiedlichereporterDid, damit eine Person ihn nicht allein auslöst. Neues Feldcases.alarmSentAtsorgt 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, Queuemoderation-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.
6.4 Rückmeldungen
Abschnitt betitelt „6.4 Rückmeldungen“notifications.kindumreportReceived,reportClosederweitern,decisionwird dafür optional. Der Text ist allgemein und nennt weder Massnahme noch betroffene Person.- Neue Tasks beim Eingang einer Meldung und bei
dismissoder dem ersten Entscheid auf dem Fall, einmal pro meldender Person. Stumme Meldungen undmanualohnereporterDidbekommen nichts. - Stand des Einspruchs:
GET /moderation/appealsin der client-api, Anzeige in den Benachrichtigungen.
6.5 Abnahme
Abschnitt betitelt „6.5 Abnahme“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.
Abdeckung
Abschnitt betitelt „Abdeckung“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 |
Ausrollen
Abschnitt betitelt „Ausrollen“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.
Dokumentation
Abschnitt betitelt „Dokumentation“| 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 |
Vor prod, ausserhalb dieses Plans
Abschnitt betitelt „Vor prod, ausserhalb dieses Plans“- 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.