dev: Doku aufgeräumt, neue Roadmap als einzige Liste offener Punkte

- dev/roadmap.md neu: alle offenen Punkte aus Plänen, Audits und Protokollen,
  gegen Code und Git abgeglichen (Stand v1.7.0)
- Abgeschlossenes ins Archiv: 2026-09-01 → archive/2026-09-nutzergewinnung,
  2026-09-11-audit → archive/2026-09-audit, lagacy-rechnungen →
  archive/legacy-rechnungen, Deploy-Protokoll v1.1.0, Umami-Snippets,
  Go-Live-Roadmap vom 20.07.
- Aktiv bleiben eigene Ordner bildrechte/ (Plan Fassung 2, Decision-Update)
  und changelog/ (Decision-Update Changelog)
- dev/README.md als Ordnerkarte neu, Verweise und zwei Code-Kommentare auf
  die neuen Pfade

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-09 11:25:09 +00:00
parent 35fd2f6e55
commit 8e3554bf91
32 changed files with 312 additions and 116 deletions

View file

@ -0,0 +1,878 @@
# Code-, Projekt- und Entwicklungsaudit
**Projekt:** pressekonto / presseecho / businessportal24
**Auditdatum:** 11.09.2026
**Geprüfter Commit:** `5d0570bee0d94275465b19d6ecb9950cf4d1c1b1`
**Arbeitsverzeichnis:** `/var/www/html`
**Auftrag:** Umfangreicher Audit mit Fehleranalyse, Verbesserungsvorschlägen und priorisierten Optimierungen. Keine Änderungen am Anwendungscode.
**Ergebnis:** 28 priorisierte Befunde, ergänzt um Architekturbeurteilung, Prüfgrenzen, Optimierungsplan und konkrete Regressionstests.
## 1. Gesamtbeurteilung
Die Anwendung ist ein gewachsenes, fachlich anspruchsvolles Publishing-System. Sie verbindet öffentliche Nachrichtenportale mit Kunden- und Redaktionsverwaltung, Firmen- und Kontaktzuordnungen, mehreren Anmeldeverfahren, KI-Prüfung, Abonnements, Einmalkäufen, Credits, Legacy-Abrechnung, Newsletter und rechtlich motivierten Änderungsprozessen.
Die Grundlage ist deutlich weiter als ein unverändertes Starter-Kit: Es gibt zentralisierte Veröffentlichungs- und Abrechnungsservices, gezielte Datenbankindizes, eine ausgelagerte Volltextsuche, Transaktionen und Zeilensperren an wichtigen Geld- und Kontingentoperationen sowie einen großen Bestand an fachlichen Tests. Die jüngere Entwicklung enthält nachvollziehbare Verbesserungen an Sicherheit, Performance und Betrieb.
**Trotzdem bestehen wesentliche Fehler an den Übergängen zwischen diesen Teilbereichen.** Besonders relevant sind eine unerlaubte Vergabe von Firmenrechten über eine unbestätigte E-Mail-Adresse, unterschiedliche 2FA-Regeln je Anmeldeweg, übergangene Soft Deletes, fehlerhaftes Newsletter-Chunking, eine Abrechnung über ein explizites Vertragsende hinaus und die fehlende Bindung von KI-Ergebnissen an die analysierte Inhaltsversion.
**Empfehlung:** Die Befunde F01–F07 vor einer weiteren funktionalen Ausweitung bearbeiten. F01 ist innerhalb dieser Gruppe ein Sicherheits-Release-Blocker. Ein umfassender Neuaufbau ist nicht begründet; erforderlich sind gezielte Korrekturen an den bestehenden Sicherheits- und Zustandsgrenzen.
### 1.1 Wesentliche Ergebnisse auf einen Blick
| Priorität | Anzahl | Einordnung |
|---|---:|---|
| P1 | 7 | Hohe Sicherheits- oder Geschäftsauswirkung; vorrangig beheben |
| P2 | 19 | Relevante Funktions-, Betriebs-, Performance- oder Qualitätssicherungsfehler |
| P3 | 2 | Entwicklungs- und Dokumentationsverbesserungen |
Es wird kein P0 vergeben: Es wurde kein laufender Angriff oder eingetretener Produktionsschaden nachgewiesen. Die Vergabe P1 bedeutet bei F01 dennoch, dass die beschriebene Rechtevergabe vor dem nächsten regulären Release korrigiert werden sollte. Priorität bewertet die erforderliche Reaktion, nicht die Wahrscheinlichkeit eines bereits erfolgten Vorfalls.
### 1.2 Belastbarkeit des Ergebnisses
Der Audit kombiniert Quellcodeanalyse, Gegenprüfung des installierten Framework-Codes, Prüfung bestehender Tests, Git-Historie, Datenbankschema und ausschließlich lesende Datenbankabfragen. Der besonders relevante Authentifizierungsbefund wurde zusätzlich unabhängig anhand der vollständigen Aufrufkette gegengeprüft.
**Die Anwendungstests, PHPStan und Dependency-Audits konnten nicht ausgeführt werden:** Alle entsprechenden Sail-Aufrufe scheiterten mit `Docker or Podman is not running.` Auch `vendor/bin/sail up -d` scheiterte. Es wurde kein direkter PHP-/Node-Aufruf als Umgehung verwendet.
Damit sind die Fehler überwiegend **statisch belegt**, jedoch nicht als in dieser Sitzung dynamisch reproduzierte Angriffe oder Integrationstests zu verstehen. F22 ist zusätzlich durch tatsächliche MySQL-Query-Pläne gestützt. Die Datenbankverbindung des Boost-Servers funktionierte unabhängig von der nicht verfügbaren Sail-CLI. Ihre Betriebsrolle wurde nicht als Produktion unterstellt.
## 2. Prüfauftrag, Teilaufgaben und Vorgehen
Der Auftrag wurde in die folgenden kleinen Prüfpakete zerlegt und schrittweise bearbeitet. Für den Backend-Teil wurde entsprechend dem Laravel-Skill eine zusätzliche lesende Gegenprüfung eingesetzt.
| Schritt | Prüfpaket | Bearbeitet | Nachweis / Einschränkung |
|---|---|---|---|
| 1 | Bestandsaufnahme, Versionen, Umfang, Git-Zustand | Ja | Dateien, Composer-/npm-Konfiguration, Boost application-info, Git |
| 2 | Architektur, Domains, Portale, Routen und Zuständigkeiten | Ja | Routing, Provider, Context-Klassen, Services, Dokumentation |
| 3 | Anmeldung, Verifizierung, 2FA, Berechtigungen | Ja | Fortify, Socialite, Magic Links, Policies, Middleware, Login-Listener |
| 4 | Kunden-/Adminaktionen und Livewire-Zustand | Ja | Kritische öffentliche Actions, Locked-Properties, erneute Autorisierung |
| 5 | API-Verträge, Validierung, Eigentums- und Löschgrenzen | Ja | v1, Legacy-Aliasse, Requests, Resources, Scope-Verhalten |
| 6 | Veröffentlichung, KI-Prüfung, Quoten, Zeitsteuerung | Ja | Jobs, Veröffentlichungsservice, Zustandsübergänge und Scheduler |
| 7 | Geldflüsse und Legacy-Abrechnung | Ja | Wallet, Topups, Rechnungsnummern, MAN-Rechnungen, Käufe, Webhook-nahe Abläufe |
| 8 | Newsletter, Hintergrundverarbeitung, Wiederanlauf | Ja | Empfängerauswahl, Start, Versandjob und Fehlergrenzen |
| 9 | Datenbank, Suche, Performance und Migration | Ja | Migrations-/Indexprüfung, Query-Pläne, Importpfade |
| 10 | Medien, öffentliche Seiten, Cache, SEO und UI | Ja, statisch | Sichtbarkeitsregeln, Uploads, Rendering, Templates; kein visueller Browserlauf |
| 11 | Tests, CI, Entwicklungsworkflow und Betriebsdokumentation | Ja | Bestehende Testfälle gelesen, Gates und Runbook geprüft; Laufzeitchecks blockiert |
| 12 | Befunde zusammenführen, priorisieren, Maßnahmen und Regressionen | Ja | Dieser Bericht |
### 2.1 Was nicht verändert oder ausgeführt wurde
- Kein Anwendungscode, keine Tests, keine Migrationen, keine Konfiguration und keine Abhängigkeiten verändert.
- Keine Datenbankänderungen, keine Datenbereinigung, keine Rechnungen erzeugt, keine Newsletter gestartet und keine E-Mails versendet.
- Keine Authentifizierungs- oder Berechtigungsangriffe gegen reale Konten ausgeführt.
- Keine Deployments, keine Commits und kein Push.
- Keine produktiven Nutzdaten, Zugangsdaten oder Tokens in den Bericht übernommen.
- Ausschließlich diese angeforderte Markdown-Datei neu angelegt.
### 2.2 Geltungsbereich und Grenzen
Die Prüfung deckt alle wesentlichen technischen und fachlichen Bereiche ab. Bei über 116.000 Zeilen in den betrachteten Anwendungs-, View-, Test- und Migrationsdateien ist sie keine Zusicherung einer lückenlosen Zeilenprüfung jeder Datei. Kritische Pfade wurden vertieft untersucht; übrige Bereiche wurden strukturell und anhand von Querschnittssuchen bzw. repräsentativen Implementierungen geprüft.
Nicht nachgewiesen wurden: vollständige Testgrünheit, reale Browser-/Mobilansichten, tatsächliche Produktionskonfiguration, Infrastruktur-Firewallregeln, Backup-Wiederherstellbarkeit, Produktions-Queueparameter, Lastgrenzen, erfolgreiche echte Stripe-/Google-/SMTP-/KI-Interaktionen oder ein aktueller vollständiger CVE-Status. Ebenso stellt der Bericht keine juristische oder steuerrechtliche Freigabe dar; rechtlich benannte Funktionen werden technisch auf Konsistenz geprüft.
## 3. Projektbestand und Architektur
### 3.1 Tatsächlicher Technologiebestand
Versionen aus Boost `application-info` am Auditdatum, nicht lediglich die Mindestanforderungen aus `composer.json`:
| Baustein | Installierter Stand |
|---|---|
| PHP | 8.4 |
| Laravel | 12.63.0 |
| Fortify | 1.36.2 |
| Sanctum | 4.3.1 |
| Socialite | 5.27.0 |
| Cashier | 16.5.3 |
| Livewire / Volt | 4.2.4 / 1.10.5 |
| Flux / Flux Pro | jeweils 2.14.0 |
| Pest / PHPUnit | 3.8.6 / 11.5.50 |
| Larastan / Pint | 3.9.6 / 1.29.1 |
| Sail / Boost | 1.57.0 / 2.4.5 |
| Alpine / Tailwind CSS | 3.15.0 / 4.1.14 |
| Datenbank der verfügbaren Boost-Verbindung | MySQL |
Ergänzend sind unter anderem Spatie Permission und HTMLPurifier eingebunden. `composer.json` beschreibt das Projekt weiterhin als offizielles Livewire-Starter-Kit. Das ist kosmetisch, bildet die fachliche Eigenständigkeit aber nicht mehr ab.
### 3.2 Umfang
| Metrik | Ergebnis | Bedeutung |
|---|---:|---|
| PHP-Dateien unter `app/` | 363 | Einschließlich Concerns, Services, Commands etc. |
| Blade-Dateien unter `resources/views/` | 209 | UI und erhebliche Teile der Volt-Anwendungslogik |
| `*Test.php` unter `tests/` | 203 | Dateianzahl, keine Aussage über ausgeführte Tests |
| Statisch gezählte `test()`-/`it()`-Deklarationen | 1.481 | Datasets können die tatsächliche Zahl der Ausführungen erhöhen |
| Migrationsdateien | 112 | Hohe Schema- und Legacy-Komplexität |
| Zeilen in `app`, Views, Tests und Migrationen zusammen | 116.414 | Rohumfang einschließlich Kommentaren/Leerzeilen |
| PHPStan-Baseline | 166 Einträge / 188 unterdrückte Vorkommen | Keine neu gemessenen PHPStan-Fehler |
Besonders große Dateien:
| Datei | Zeilen | Konsequenz |
|---|---:|---|
| `resources/views/livewire/customer/press-releases/show.blade.php` | 1.997 | Viele unterschiedliche Käufe, Korrekturen und Anfragen in einem UI-Lebenszyklus |
| `app/Services/PressRelease/ProofPdfRenderer.php` | 1.476 | Eigenständige komplexe PDF-Erzeugung; hoher Spezialwissenbedarf |
| `resources/views/livewire/admin/press-releases/index.blade.php` | 1.401 | Tabellen-, Filter-, Status- und Verwaltungslogik gebündelt |
| `resources/views/livewire/admin/users/edit.blade.php` | 1.186 | Hohe Kopplung von Rollen, Firmen, Kontakten und Profilen |
| `resources/views/livewire/customer/bookings.blade.php` | 1.185 | Umfangreicher Bestellzustand und UI in einer Datei |
Große Dateien sind nicht automatisch fehlerhaft. Hier erschweren sie jedoch die Prüfung von Berechtigungswechseln und Zustandsübergängen. Die Zusammenführung vieler Funktionen sollte nicht mit einer Zusammenführung aller Verantwortlichkeiten verwechselt werden.
### 3.3 Fachliche Struktur
```mermaid
flowchart TD
Public[Öffentliche Portale: presseecho / businessportal24]
Hub[pressekonto: Anmeldung, Kundenbereich, Redaktion]
Api[v1-API und Legacy-Service-Aliasse]
Context[Portal- und Firmenkontext, Middleware, Policies]
Publishing[Veröffentlichungsservice, KI-Prüfung, Medien]
Billing[Cashier / Stripe, MAN-Rechnungen, Credits, Einzelkäufe]
CRM[Firmen, Kontakte, Mitgliedschaften]
Async[Queue, Newsletter, Scheduler, Importe]
Data[MySQL, private Dateien, Cache, Suchindex]
Public --> Context
Hub --> Context
Api --> Context
Context --> Publishing
Context --> Billing
Context --> CRM
Publishing --> Async
Billing --> Async
CRM --> Data
Publishing --> Data
Billing --> Data
Async --> Data
```
Es bestehen zwei voneinander unabhängige Zugriffsachsen: **Portal/Sprache** für die Veröffentlichung und **Benutzer/Firmenzuordnung** für die Verwaltung. Dazu kommt der Lebenszyklus eines Datensatzes: aktiv, archiviert, mit 410 entfernt oder soft-gelöscht. Mehrere Befunde entstehen dadurch, dass eine Abfrage nur eine dieser Achsen berücksichtigt.
### 3.4 Bereits gut gelöst
- Veröffentlichungen laufen zentral über `PressReleaseService`; Publikation prüft unter Sperren erneut Zustand, Quoten und verfügbare Käufe.
- Walletsaldo und Ledger werden transaktional geschrieben. Topup-Gutschriften prüfen `credited_at` unter Row Lock.
- Rechnungsnummern beruhen auf einer eindeutigen Sequenz und Zeilensperren.
- Boost-Inventar verwendet stabile Sperrzeilen und eine definierte Sperrreihenfolge.
- Die API besitzt Tokenfähigkeiten, aktive Benutzerprüfung und ein tokenbezogenes Rate Limit. Fremde Autorschaft wird in den untersuchten PM-Pfaden geprüft.
- Magic-Login-Tokens werden gehasht gespeichert und beim Verbrauch atomar beansprucht. Der Magic-Login respektiert bereits die lokale 2FA.
- Eigene Admin-/Customer-Middleware ist für Livewire-Folgeanfragen registriert. Viele Actions autorisieren zusätzlich explizit.
- Presse-HTML wird über einen zentralen Sanitizer ausgegeben; strukturierte Daten verwenden `JSON_HEX_TAG`. Unescaped-Ausgabe ist deshalb nicht pauschal als XSS eingestuft worden.
- Medien liegen konzeptionell auf privaten Disks und werden über Controller ausgeliefert. Das ist die richtige Grundlage, auch wenn F04 eine Lücke in der Sichtbarkeitsprüfung zeigt.
- Zahlreiche Scheduler-Tasks besitzen `withoutOverlapping()` und Failure-Hooks; es existiert ein Betriebs-Runbook.
- Die Suchindex-Auslagerung und zahlreiche zusammengesetzte Indizes zeigen eine bewusste Auseinandersetzung mit großen Beständen.
- Tests decken viele sensible Themen ausdrücklich ab: Rechteeskalation, Verifizierung, Billing, Scheduling, Compliance, Legacy-Kompatibilität und Performanceverhalten.
## 4. Befundregister
**Nachweisarten:** **S** = statisch anhand konkreter Aufrufkette belegt; **D** = durch lesende Datenbankprüfung ergänzt; **B** = Auswirkung hängt von einer ausdrücklich genannten Betriebs-/Parallelitätsbedingung ab. Ein B-Befund ist kein nachgewiesener Produktionsvorfall.
| ID | Priorität | Nachweis | Befund |
|---|---|---|---|
| F01 | P1 | S | Unbestätigte E-Mail kann dauerhafte fremde Firmenrechte auslösen |
| F02 | P1 | S | Google-Login umgeht lokale Zwei-Faktor-Authentifizierung |
| F03 | P1 | S | Sicherheits-UI umgeht erneute Identitätsprüfung; Recovery-Anzeige manipulierbar |
| F04 | P1 | S | Soft Deletes werden in API, Vorschau, Kundenlisten und Medien übergangen |
| F05 | P1 | S/D | Newsletter-Chunking dupliziert und überspringt Empfänger |
| F06 | P1 | S | MAN-Abrechnung ignoriert explizites Legacy-Vertragsende |
| F07 | P1 | S/B | KI-Ergebnisse sind nicht an die analysierte Inhaltsversion gebunden |
| F08 | P2 | S/B | Admin-Gutschriften autorisieren nur beim Öffnen der Komponente |
| F09 | P2 | S/B | Globaler Superadmin-Kurzschluss umgeht Kontodeaktivierung |
| F10 | P2 | S | Automatisch vergebene Firmenrechte werden bei Wegfall der Grundlage nicht entzogen |
| F11 | P2 | S | API-Pagination und Eingabeverträge unvollständig validiert |
| F12 | P2 | S/D | Meldeverfahren ordnen URLs nur anhand des Slugs zu |
| F13 | P2 | S/B | Firmenlöschung/-deaktivierung invalidiert öffentliche Seitencaches nicht zuverlässig |
| F14 | P2 | S/B | Newsletter-Start besitzt keinen sicheren Wiederanlauf |
| F15 | P2 | S/B | Newsletter-Job kann doppelt senden und Zähler inkonsistent hinterlassen |
| F16 | P2 | S | Nicht abrechenbare Altverträge können den Rechnungslauf blockieren |
| F17 | P2 | S/B | KI-Retrybudget und Worker-Timeout passen nicht zusammen |
| F18 | P2 | S/B | Monatskontingent-Reset ist nicht periodengebunden und nicht idempotent |
| F19 | P2 | S/B | Gleichzeitige Aufrufaggregatoren zählen Ereignisse mehrfach |
| F20 | P2 | S | Scheduler-Publikationsfehler liefern trotzdem Exit-Code Erfolg |
| F21 | P2 | S/B | Legacy-Import lädt große Gesamttabellen und hinterlässt Teilimporte |
| F22 | P2 | S/D | `LOWER(email)` verhindert den vorhandenen E-Mail-Index |
| F23 | P2 | S/B | Dateifehler können scheinbar erfolgreiche Uploads und verlorene Logos verursachen |
| F24 | P2 | S | Lint-CI formatiert, erzwingt aber keine saubere Formatierung im Commit |
| F25 | P2 | S | Test-/Analysekonfiguration lässt wesentliche Produktionspfade unbewiesen |
| F26 | P2 | S | Eigenes Anmeldemodal besitzt keine explizite Fokusführung |
| F27 | P3 | S | Standard-Entwicklungsstart startet keinen Vite-Server |
| F28 | P3 | S | Einstiegspunkte der Dokumentation zeigen widersprüchliche Entwicklungsstände |
## 5. Sicherheits- und Zugriffsprüfung
### F01 — Fremde Firmenrechte über eine unbestätigte E-Mail-Adresse
**Priorität:** P1, innerhalb dieses Audits Sicherheits-Release-Blocker.
**Belastbarkeit:** Vollständige statische Aufrufkette, unabhängig gegengeprüft; nicht gegen reale Konten ausgeführt.
**Fundstellen:**
- `config/fortify.php:150`: Profiländerung ist als Fortify-Feature aktiv.
- `app/Providers/FortifyServiceProvider.php:44`: eigene Profil-Action ist gebunden.
- `vendor/laravel/fortify/routes/routes.php:100–103`: `PUT /user/profile-information` benötigt Authentifizierung, keine verifizierte E-Mail.
- `app/Actions/Fortify/UpdateUserProfileInformation.php:48–56`: E-Mail wird sofort ersetzt, `email_verified_at` auf `null` gesetzt.
- `resources/views/livewire/auth/login.blade.php:62–69`: Login erfolgt vor der Verifizierungsprüfung.
- `app/Listeners/SyncCompanyMembershipsOnLogin.php:22–28` und `app/Services/Auth/ContactAccessService.php:104–119`: E-Mail-basierte Zuordnung ohne Verifizierungsprüfung.
- `app/Services/Auth/ContactAccessService.php:237–246`: Vergabe von `responsible` und additive Kontaktzuordnung.
- `app/Policies/CompanyPolicy.php:34–40,64–74`: diese Rolle gewährt Schreibzugriff.
**Fehlerszenario:** Ein Benutzer kontrolliert ein Passwortkonto und eine eigene E-Mail-Adresse. Eine fremde E-Mail steht in `companies.email` oder an einem zugeordneten Pressekontakt, gehört aber noch keinem anderen `users.email`-Datensatz. Der Benutzer ändert über den weiterhin aktiven Fortify-Endpunkt seine eigene Kontoadresse auf diese fremde Adresse. Nach Ab- und Anmeldung mit seinem unveränderten Passwort feuert der Login-Listener und trägt ihn bereits als Firmenverantwortlichen ein. Anschließend kann er über denselben Endpunkt auf seine eigene Adresse zurückwechseln und diese bestätigen. Die zuvor vergebenen Firmenmitgliedschaften bleiben erhalten.
**Auswirkung:** Dauerhafter Zugriff auf fremde Firmenstammdaten, Kontakte und entsprechend berechtigte Pressemitteilungen, ohne Besitznachweis des fremden Postfachs. Die `verified`-Middleware verhindert nur den unmittelbaren Zugang während des unverifizierten Zwischenzustands; sie verhindert nicht die Vergabe der Berechtigung.
**Abgrenzung:** Bestehende `users.email`-Einträge begrenzen durch Unique-Validierung die angreifbaren Zieladressen. CSRF ist keine Abwehr gegen einen Benutzer, der reguläre Änderungen am eigenen Konto ausführt. Ein fremdes Benutzerkonto wird durch diese Kette nicht unmittelbar übernommen; betroffen sind Firmenrechte.
**Empfehlung:** E-Mail-basierte Berechtigungen ausschließlich aufgrund einer aktuell nachgewiesenen Kontoadresse vergeben. Den Fortify-Endpunkt auf denselben `pending_email`-Prozess wie die reguläre Sicherheits-UI führen oder deaktivieren, wenn er nicht benötigt wird. Die Kontrolle muss im Zuordnungsservice liegen und alle Aufrufer erfassen. Bestehende automatisch entstandene Zuordnungen anschließend gezielt auf ihre Verifikationsgrundlage untersuchen; nicht pauschal alle Mitgliedschaften löschen.
**Erforderlicher Regressionstest:** Eigene bestätigte Adresse → fremde, nur am Kontakt gespeicherte Adresse → Passwortlogin → Rückwechsel und Bestätigung eigener Adresse. Während der gesamten Sequenz dürfen weder `company_user` noch `contact_user` unberechtigte Zuordnungen erhalten. Zusätzlich den direkten Serviceaufruf für unverifizierte und deaktivierte Benutzer prüfen.
### F02 — Google-Login überspringt lokale 2FA
**Priorität:** P1.
**Fundstellen:** `app/Http/Controllers/Auth/GoogleController.php:37–57`; als Gegenbeispiel `app/Http/Controllers/Auth/MagicLinkConsumeController.php:68–77` und `resources/views/livewire/auth/login.blade.php:49–59`.
Nach erfolgreicher Google-Auflösung ruft der Callback unmittelbar `Auth::login($user, remember: true)` auf. Eine Prüfung auf `hasEnabledTwoFactorAuthentication()` und die Übergabe in Fortifys Challenge fehlen. Passwort- und Magic-Login prüfen dies bereits.
**Trigger:** Ein aktives Bestandskonto mit bestätigter lokaler 2FA meldet sich über Google mit der passenden Adresse an. Die Anwendung erstellt sofort eine Sitzung. Dass der Google-Account eventuell selbst MFA verwendet, beweist nicht die Erfüllung der lokal konfigurierten TOTP-Anforderung.
**Auswirkung:** Die Sicherheitszusage „2FA aktiviert“ hängt vom gewählten Anmeldeweg ab. Das betrifft auch privilegierte Konten, sofern Google-Login für ihre Adresse funktioniert.
**Empfehlung:** Gemeinsame Login-Abschlusslogik für Passwort, Magic Link und Google: aktive Identität prüfen, gegebenenfalls Challengezustand setzen und erst nach erfolgreicher Challenge authentifizieren. Die Entscheidung über dauerhafte Anmeldung ebenfalls zentralisieren.
**Regression:** Google-Callback mit aktiviertem lokalen TOTP muss als Gast enden und zur Challenge führen. Ohne bestätigte 2FA bleibt das bisherige Verhalten. Deaktivierte Konten müssen gesperrt bleiben. `tests/Feature/Auth/GoogleLoginTest.php` deckt derzeit Neuanlage, Zuordnung, Deaktivierung und Fehler ab, jedoch diesen 2FA-Fall nicht.
### F03 — Sicherheitsaktionen ohne erneute Identitätsprüfung; Recovery-Anzeige clientseitig freischaltbar
**Priorität:** P1.
**Fundstellen:** `resources/views/livewire/customer/security.blade.php:39,74–109,120–164,179–186`; `routes/customer.php:28–31,73`; `config/fortify.php:152–155`; `vendor/laravel/fortify/routes/routes.php:142–172`.
Die Konfiguration verlangt `confirmPassword => true`. Fortifys eigene 2FA-Routen setzen dafür `password.confirm` ein. Die Volt-Komponente ruft die Fortify-Actions direkt auf, ohne diesen Schutz gleichwertig zu prüfen. Deaktivierung der 2FA, Erzeugung neuer Recovery-Codes und Start einer E-Mail-Änderung sind allein mit einer bestehenden Sitzung möglich.
Zusätzlich hängt die Anzeige entschlüsselter Recovery-Codes von der ungesperrten öffentlichen Property `showRecoveryCodes` ab. Diese Property ist vom Client veränderbar. „Nur einmal anzeigen“ ist dadurch lediglich das normale UI-Verhalten, keine serverseitige Sicherheitsgrenze.
**Trigger:** Eine bereits bestehende oder entwendete Sitzung ruft `disableTwoFactorAuthentication()` bzw. `regenerateRecoveryCodes()` auf oder setzt `showRecoveryCodes` auf `true`. Dafür ist keine erneute Kenntnis des Passworts oder eines anderen starken Nachweises erforderlich.
**Auswirkung:** Temporärer Sitzungszugriff kann zu dauerhaft geschwächter Kontosicherheit führen. Der Angreifer braucht eine Sitzung; es handelt sich nicht um einen anonymen Zugriff auf fremde Recovery-Codes.
**Empfehlung:** Einen zeitlich begrenzten, serverseitig geprüften erneuten Identitätsnachweis vor sicherheitskritischen Aktionen und vor Recovery-Ausgabe verlangen. Für passwortlose Konten einen ausdrücklich definierten sicheren Ersatz vorsehen. `showRecoveryCodes` mindestens gegen Clientmutation schützen; das ersetzt die erneute Identitätsprüfung nicht. Sicherheitsänderungen nachvollziehbar protokollieren und den Kontoinhaber benachrichtigen.
**Regression:** Ohne aktuellen Nachweis müssen direkte Action-Aufrufe und `set('showRecoveryCodes', true)` scheitern. Die Tests in `tests/Feature/CustomerProfileSecurityTest.php:209–270` prüfen zwar Einrichtung und normale Sichtbarkeit, erlauben die Regeneration bisher ohne erneuten Nachweis.
### F04 — Soft Deletes werden als Nebenwirkung der Portal-Entkopplung entfernt
**Priorität:** P1.
**Fundstellen:**
- `app/Http/Controllers/Api/V1/PressReleaseController.php:36,238,257`.
- `app/Http/Controllers/Api/V1/CompanyController.php:17,34`.
- `app/Http/Controllers/PressReleasePreviewController.php:39–49`.
- `app/Http/Controllers/Web/PressReleaseImageMediaController.php:23–26` und entsprechender Attachment-Controller.
- `app/Http/Controllers/Web/Concerns/AuthorizesPressReleaseMediaAccess.php:39–46`.
- `app/Models/PressRelease.php:537–542`.
- `resources/views/livewire/customer/press-kits/show.blade.php:233–260`.
`withoutGlobalScopes()` deaktiviert nicht nur den Portalfilter, sondern auch den Soft-Delete-Filter. Nachfolgende `ownedByApiUser()`-/`accessibleBy()`-Scopes stellen den Soft-Delete-Schutz nicht wieder her. Die betroffenen Modelle verwenden tatsächlich `SoftDeletes`.
**Konkrete Folgen:**
1. Eine über die API gelöschte eigene Entwurfs-PM kann weiterhin über Index/Show gefunden werden; auch das Update sucht ohne Soft-Delete-Scope.
2. Ein bestehender Vorschau-Token kann eine soft-gelöschte PM weiter laden.
3. Gelöschte Kontakte erscheinen weiterhin in der Firmenansicht, weil die Kontaktliste und der Einzelkontaktlookup alle Scopes entfernen.
4. Medien laden die zugehörige Firma ohne Scopes. Eine soft-gelöschte, zuvor aktive Firma bleibt auf dem geladenen Model `is_active=true`; eine veröffentlichte PM kann dadurch weiterhin als öffentlich gelten. Die Instanzprüfung berücksichtigt `deleted_at` nicht. Die normale Firmenlöschung in `resources/views/livewire/admin/companies/edit.blade.php:262` ändert diesen Aktivwert nicht.
**Wichtiger Testbefund:** `tests/Feature/PressReleasePreviewTest.php:237–247` heißt „preview link for soft deleted press release returns 404“, führt tatsächlich aber `$pr->forceDelete()` aus. Der Test beweist nur den Fall eines physisch entfernten Datensatzes und verdeckt genau diese Lücke.
**Empfehlung:** Portalübergreifende Abfragen mit `withoutGlobalScope(PortalScope::class)` formulieren. Gelöschte Datensätze nur in ausdrücklich dafür vorgesehenen Verwaltungsfunktionen über `withTrashed()` zulassen. Sichtbarkeitsregeln für PM und Firma einschließlich Soft Delete zentralisieren und sowohl in Query- als auch Instanzprüfungen gleich umsetzen.
**Regression:** Soft Delete separat für PM, Firma, Kontakt, Bild und Anhang testen. Bestehende Vorschau- und Medienlinks sowie API-GET/PATCH nach Löschung prüfen. Außerdem verifizieren, dass berechtigte portalübergreifende Zugriffe auf nicht gelöschte Daten erhalten bleiben.
### F08 — Admin-Gutschriften prüfen Rechte nur in `mount()`
**Priorität:** P2.
**Fundstellen:** `resources/views/livewire/admin/users/show.blade.php:25–27,39–82`; `app/Services/Billing/AdminGrantService.php:29–49,59–99`; `routes/admin.php`, Benutzer-Detailroute.
`mount()` verlangt `users:manage`; `grantCredits()` und `grantFreePressReleases()` prüfen diese Berechtigung nicht erneut. Auch der Service prüft den übergebenen Ausführenden nicht. Die persistente Admin-Middleware kontrolliert lediglich den Adminbereichszugang.
**Trigger:** Ein berechtigter Admin öffnet die Seite. Danach wird ihm `users:manage` entzogen oder er wird zum Editor herabgestuft. Als Editor erfüllt er weiterhin `access-admin` und kann aus dem alten Livewire-Zustand Gutschriften auslösen. Das gesperrte `$id` verhindert die Mutation des Zielbenutzers, aber nicht diesen Rechteentzugfall.
**Empfehlung:** Jede finanzwirksame Action erneut autorisieren; alternativ bzw. ergänzend den Aufruf zentral im Service absichern. Berechtigungen auf der Route als persistente Grenze setzen, wenn das gesamte Modul diese benötigt.
**Regression:** Komponente berechtigt öffnen, Rolle/Berechtigung während ihrer Lebensdauer entziehen, dann beide Actions aufrufen. Weder Ledger noch Käufe dürfen verändert werden. Der vorhandene Test `AdminGrantTest` prüft nur den initial gesperrten Seitenaufruf.
### F09 — Superadmin-Kurzschluss ignoriert deaktivierte Konten
**Priorität:** P2.
**Fundstellen:** `app/Providers/AppServiceProvider.php:114–119`; `app/Models/User.php:537–558`; `app/Http/Middleware/EnsureUserIsAdmin.php:21–25` und `EnsureUserIsCustomer.php`.
`Gate::before` liefert für jeden Superadmin `true`, bevor die Access-Gates überhaupt `canAccessAdmin()` oder `canAccessCustomer()` ausführen. Diese Methoden prüfen eigentlich zuerst `is_active`.
**Trigger:** Ein bereits angemeldetes Superadmin-Konto wird deaktiviert. Bei nachfolgenden Requests mit bestehender Sitzung kann der Gate-Kurzschluss die Sperre umgehen. Die gesonderten Login-Prüfungen schützen einen neuen Login, ersetzen aber nicht die Prüfung bereits bestehender Sitzungen.
**Empfehlung:** Kontodeaktivierung als vorrangige, nicht übersteuerbare Grenze behandeln. Superadmin-Erleichterungen erst danach anwenden. Bestehende Sitzungen und Tokens im Sperrprozess nach definierter Regel widerrufen.
**Regression:** Aktiven Superadmin anmelden, deaktivieren, Admin-/Customer-Route und Livewire-Folgeaktion aufrufen. Access-Gate und Model-Methode müssen denselben Ablehnungswert liefern.
### F10 — Automatisch verliehene Firmenrechte besitzen keinen Entzugslebenszyklus
**Priorität:** P2.
**Fundstellen:** `app/Services/Auth/ContactAccessService.php:104–119,225–246`; `resources/views/livewire/customer/press-kits/show.blade.php:193–209`; `app/Policies/CompanyPolicy.php:64–74`.
Die E-Mail-Synchronisation ist ausdrücklich rein additiv. Wird eine Kontaktadresse geändert, ein Pressekontakt gelöscht oder eine Firmenadresse ersetzt, bleibt ein zuvor automatisch vergebener `responsible`-Pivot bestehen. Die Policy benötigt später nur diesen Pivot und nicht mehr die ursprüngliche E-Mail-Übereinstimmung.
**Trigger:** Ein ehemaliger Ansprechpartner erhält zunächst legitimen Zugang, wird anschließend aus den Firmenkontakten entfernt und behält trotzdem Schreibrechte. Dies ist unabhängig vom missbräuchlichen Verifikationspfad F01.
**Empfehlung:** Herkunft automatisch verliehener Rechte speichern und einen eigenen Entzugsprozess implementieren. Manuell erteilte Team-/Eigentümerrechte dürfen dabei nicht pauschal mitgelöscht werden. Im Administrationsprozess sichtbar machen, dass Kontaktlöschung und Berechtigungsentzug aktuell verschiedene Vorgänge sind.
**Regression:** Automatische Zuordnung erzeugen, die letzte berechtigende E-Mail-Grundlage entfernen, anschließend Firmenzugriff prüfen. Manuell zusätzlich erteilte Rechte müssen nach der fachlich festgelegten Regel erhalten bleiben.
## 6. Veröffentlichung, API, Medien und öffentliche Auslieferung
### F07 — KI-Bewertung kann auf einer veralteten Inhaltsversion beruhen
**Priorität:** P1.
**Fundstellen:** `app/Jobs/ClassifyPressRelease.php:44–81`; `app/Jobs/ScorePressRelease.php:38–61`; `app/Services/PressRelease/PressReleaseService.php:123–160,177–189,214–240`.
Die Jobs kennen die PM-ID, jedoch keine Inhaltsversion. Sie laden den Inhalt, warten auf die externe Bewertung und speichern danach das Ergebnis ohne Vergleich mit dem inzwischen aktuellen Inhalt. Die Veröffentlichung lädt und sperrt zwar das aktuelle Model, prüft aber nicht, ob dieses den tatsächlich bewerteten Text enthält.
**Trigger:** Während Job A Version V1 bewertet, wird V2 gespeichert. Danach kommt A zurück und übernimmt sein Ergebnis für V2; ein jüngerer Job kann zusätzlich durch eine ältere Antwort überschrieben werden. Bei der Erstprüfung sind `classification`/`content_score` noch `null`; die Recheck-Helfer planen in diesem Zustand keinen Ersatzlauf ein. Administrative Änderungen und erlaubte Review-Bearbeitung bieten entsprechende Übergänge.
**Auswirkung:** Veröffentlichter Inhalt, Bewertung und KI-Audit können auseinanderfallen. Die erneute Blacklistprüfung im Veröffentlichungsservice kontrolliert eine andere, engere Regel und ersetzt keine Inhaltsversionsbindung.
**Empfehlung:** Monotone Inhaltsversion oder Hash bei Einplanung und Analyse speichern. Ergebnisübernahme und daraus abgeleitete Publikation nur bei übereinstimmender aktueller Version erlauben. Veraltete Antworten nachvollziehbar verwerfen und gegebenenfalls eine aktuelle Prüfung einplanen.
**Regression:** Fake-Treiber verändert den Text während der Analyse; zwei Jobs beenden sich in umgekehrter Reihenfolge; erste Analyse läuft parallel zu einer Bearbeitung. Ein altes Ergebnis darf weder neue Werte überschreiben noch V2 freigeben.
### F11 — API-Eingaben können Pagination und fachliche Verträge umgehen
**Priorität:** P2.
**Fundstellen:** `app/Http/Controllers/Api/V1/PressReleaseController.php:36–41`; `app/Http/Controllers/Api/V1/CompanyController.php:17–20`; `app/Http/Requests/Api/V1/StorePressReleaseRequest.php:33–46`; entsprechender Update-Request.
`paginate(min((int) per_page, 100))` begrenzt nur nach oben. Bei `per_page=-1` bleibt ein negativer Wert übrig. Der installierte Eloquent-Builder reicht diesen an `forPage()` weiter; `Query\Builder::limit()` setzt für negative Werte kein Limit (`vendor/.../Eloquent/Builder.php:1116–1126`, `vendor/.../Query/Builder.php:3110–3118`). Die Abfrage kann dadurch den vollständigen für den Benutzer sichtbaren Bestand einschließlich Relationen laden.
Weitere Vertragslücken: `language` verlangt lediglich zwei Zeichen statt der unterstützten Ausgaben `de|en`; `category_id` verlangt Existenz, aber keine aktive, zum Zielportal passende Kategorie. Der Statusfilter wird ungeprüft an einen auf `string` typisierten Callback übergeben; ein Arrayparameter kann statt einer kontrollierten 422-Antwort einen Typfehler auslösen.
**Auswirkung:** Unbegrenzte Ergebnismengen für große Konten, unnötige Speicher-/Datenbanklast und fachlich ungültige Datensätze. Dies ist keine nachgewiesene fremde Datenfreigabe: die Eigentumsbedingungen bleiben bestehen.
**Empfehlung:** Queryparameter explizit als Integer im Bereich 1–100, Status als erlaubten Enumwert und Sprache als unterstützte Ausgabe validieren. Kategorie und Portal zusammen validieren; Legacy-Verträge bewusst separat berücksichtigen. Maximale Textgröße definieren, wenn API- und Webeingaben dieselben fachlichen Grenzen haben sollen.
**Regression:** `per_page` mit -1, 0, 1, 100, 101, Text und Array; `status[]`; `language=zz`; inaktive bzw. portalunpassende Kategorie. Ungültige Eingaben kontrolliert ablehnen, SQL-Limit stets begrenzen.
### F12 — Melde-URL wird ohne Portal und Sprache einer PM zugeordnet
**Priorität:** P2.
**Fundstellen:** `app/Http/Controllers/Web/PressReleaseReportController.php:325–335`; `app/Http/Controllers/Web/ReportFormController.php:112–124`; `app/Models/PressRelease.php:43–45` und Schemaindex `press_releases_portal_language_slug_unique`.
Beide Controller extrahieren lediglich den Slug hinter `/press-release/`, entfernen den Portal-Scope und wählen die erste veröffentlichte PM. Host und Ausgabe der gemeldeten URL werden nicht berücksichtigt. Laut tatsächlich vorhandenem Unique-Index ist ein Slug nur innerhalb von Portal und Sprache eindeutig.
**Trigger:** Zwei Portale oder Ausgaben besitzen denselben Slug. Eine Meldung zur zweiten URL kann den zuerst gefundenen Datensatz der anderen Ausgabe treffen. Auch eine fremde Domain mit passendem Pfad wird nicht als fremd erkannt.
**Auswirkung:** Falsche Zuordnung im Bearbeitungsprozess und gegebenenfalls Versand des Verifikationslinks an den falschen hinterlegten Pressekontakt. Es wird nicht an eine beliebig eingegebene Angreiferadresse versendet; diese vorhandene Schutzmaßnahme bleibt wirksam.
**Empfehlung:** Gemeinsamen Resolver für eigene kanonische und ausdrücklich unterstützte Legacy-URLs verwenden: Host-Whitelist, Portal, Sprache und Slug zusammen auflösen; öffentliche Sichtbarkeit einheitlich berücksichtigen. Nicht zuordenbare Meldungen im allgemeinen Meldeweg bewusst ohne PM-Bezug behandeln.
**Regression:** Gleicher Slug in beiden Portalen und in `de/en`, URL einer fremden Domain, kodierter Slug, gelöschte bzw. künftig terminierte PM. Jeweils genau die beabsichtigte Zuordnung oder einen kontrollierten Nichttreffer erwarten.
### F13 — Öffentliche Caches überleben normale Firmenlöschung/-deaktivierung
**Priorität:** P2.
**Fundstellen:** `resources/views/livewire/admin/companies/edit.blade.php:121–186,250–265`; `app/Observers/AdminPerformanceCacheObserver.php:17–35`; `app/Services/Web/NewsroomDetailPageData.php:26–57,65–74`; `app/Support/WebPageCache.php:25–33,136–138`.
Der Firmenobserver invalidiert Admin-Statistiken. Die normale Firmenlöschung ruft lediglich `delete()` auf. Der öffentliche Newsroom prüft vor dem Seiten-Cache nur den Sonderfall `gone_at`, nicht die normale Löschung bzw. Deaktivierung. Beim Cachetreffer bleibt der vorher gespeicherte Inhalt verfügbar.
**Trigger:** Newsroomseite wird gecacht; danach wird die Firma normal gelöscht oder deaktiviert. Der nächste Aufruf erhält zunächst die gespeicherten Daten. Das öffentliche Cachesystem erlaubt ein Stale-Fenster bis 3.600 Sekunden. Das tatsächliche Ende hängt vom Neuaufbau und dessen Fehlerbehandlung ab; eine exakte maximale Sichtbarkeitsdauer wurde nicht gemessen.
**Abgrenzung:** Der spezielle 410-Entfernungsprozess besitzt bereits einen bewussten öffentlichen Cache-Flush. Gerade deshalb darf dieses Verhalten nicht auf die übrigen Löschwege übertragen werden.
**Empfehlung:** Alle öffentlichen Sichtbarkeitswechsel an denselben Cache-/Entfernungsprozess anschließen. Bei sensiblen Entfernungen den aktuellen Sichtbarkeitsstatus vor jedem Cachetreffer prüfen. Seiten-Cache und Browser-/CDN-Mediencache getrennt behandeln; ein Anwendungsflush löscht keine bereits extern gecachten Bilder.
**Regression:** Cache aufbauen, Firma löschen/deaktivieren, unmittelbar danach Newsroom, Kategorie, Startseite und Medien abrufen. Auch Kontaktänderungen mit gecachter Newsroomansicht prüfen.
### F23 — Dateisystemfehler können Erfolg vortäuschen oder bestehende Logos verlieren
**Priorität:** P2.
**Fundstellen:** `app/Services/PressRelease/PressReleaseAttachmentStorage.php:59–80`; `config/filesystems.php:33–47`; `resources/views/livewire/customer/press-kits/show.blade.php:115–130`; entsprechender Austausch in `resources/views/livewire/admin/companies/edit.blade.php:150–165`.
Der Attachment-Service ignoriert den Rückgabewert von `$disk->put()`. Gleichzeitig konfigurieren die Disks `throw => false` und `report => false`. Ein fehlgeschlagener Schreibvorgang kann somit normale Dateimetadaten zurückliefern, obwohl die Datei nicht gespeichert wurde.
Beim Logo-Austausch wird das bisherige Logo einschließlich Varianten vor der erfolgreichen Speicherung des neuen Logos gelöscht. Scheitert die neue Speicherung oder die anschließende Datenbankänderung, kann das alte Logo bereits unwiederbringlich fehlen.
**Trigger:** Voller Datenträger, Schreibrechteproblem oder fehlgeschlagener Storagezugriff beim Upload/Austausch.
**Empfehlung:** Storagefehler durch geprüften Rückgabewert bzw. geeignete Exceptionkonfiguration eindeutig behandeln. Neue Datei zuerst vollständig speichern, dann DB-Referenz wechseln und alte Datei erst anschließend entfernen. Neue Dateien bei gescheitertem DB-Commit aufräumen. Eine DB-Transaktion allein macht Dateisystemoperationen nicht atomar.
**Regression:** Storage-Fake/Mock liefert `false`; neue Datei kann nicht geschrieben werden; DB-Save nach erfolgreichem Upload schlägt fehl. Kein Erfolgshinweis ohne Datei und kein Verlust des bisherigen Logos.
### F26 — Anmeldemodal ohne Fokusführung
**Priorität:** P2, Bedienbarkeit/Barrierefreiheit.
**Fundstellen:** `resources/views/components/web/site-header.blade.php:102–105,158,189–236`.
Das eigene Modal verwendet `x-show`, `role="dialog"` und `aria-modal="true"`. Eine explizite Übernahme des Tastaturfokus, Fokusbegrenzung innerhalb des Modals und Rückgabe an den Auslöser sind im untersuchten Template nicht implementiert. Anders als ein Flux-Modal erhält dieser eigene Div-Aufbau solche Funktionen nicht allein durch ARIA-Attribute.
**Trigger:** Öffnen über Tastatur und anschließende Tab-Navigation. Der Fokus kann im Hintergrund bleiben bzw. außerhalb des sichtbaren Dialogs weiterlaufen. Escape ist implementiert und als positive Teilabsicherung zu berücksichtigen.
**Empfehlung:** Bestehende zugängliche Dialogkomponente verwenden oder Fokusübernahme, Fokusbegrenzung, Rückgabe und geeignete Hintergrund-Deaktivierung vollständig ergänzen. Kein pauschaler rechtlicher Konformitätsbefund: Ein visueller und assistiver Browserlauf fehlt.
**Regression:** Browserprüfung mit Tab/Shift-Tab, Escape, Öffnen/Schließen, Rückfokus und Screenreader-Beschriftung bei Desktop- und Mobilbreite.
## 7. Newsletter, Abrechnung und Hintergrundverarbeitung
### F05 — Newsletter dupliziert und überspringt Empfänger ab der zweiten Seite
**Priorität:** P1.
**Fundstellen:** `app/Services/Newsletter/CampaignSender.php:37–50`; `app/Services/Newsletter/CampaignRecipients.php:175–176`; `vendor/laravel/framework/src/Illuminate/Database/Query/Builder.php:3179–3190`; `database/migrations/2026_07_21_111646_create_newsletter_campaign_tables.php:36–46`.
Der Sender verwendet `chunkById(500)` auf einer Empfängerquery, die bereits nach `created_at` oder `last_login_at` sortiert. Laravel ersetzt dabei nur die Sortierung der ID-Spalte; die andere führende Sortierung bleibt bestehen.
**Deterministisches Beispiel:** 1.000 Benutzer mit zeitlich steigenden IDs und unterschiedlichen `created_at`-Werten, Sortierung `created_at DESC`. Die erste Seite liefert 1000 bis 501. Die zweite Seite setzt `id > 501`, sortiert aber weiterhin zeitlich absteigend und liefert 1000 bis 502 erneut. Die IDs 1 bis 500 werden nicht erreicht.
Die Migration und die tatsächlich abgefragten MySQL-Indizes enthalten keinen Unique-Constraint auf Kampagne/Benutzer oder Kampagne/E-Mail. Die wiederholten Empfängerzeilen können daher gespeichert und als eigene Jobs versendet werden.
**Auswirkung:** Doppelversand, ausgelassene Empfänger und falsche Kampagnenstatistiken. Das ist ein konkreter Grenzfehler, kein bloßer Performancevorschlag.
**Empfehlung:** Auswahlquery von der UI-Sortierquery trennen. Vor ID-Chunking eine eindeutige ID-Reihenfolge herstellen, beispielsweise über `reorder()`. Falls die Versandfolge fachlich relevant ist, einen passenden zusammengesetzten Cursor bzw. eine eingefrorene Versandposition einsetzen. Empfänger zusätzlich eindeutig absichern.
**Regression:** Mindestens 1.001 Benutzer, unterschiedliche Zeitwerte und alle angebotenen Sortierungen. Jede ausgewählte Benutzer-ID genau einmal und vollständige Abdeckung prüfen. Die vorhandenen Senderprüfungen in `tests/Feature/Admin/NewsletterCampaignTest.php:230–281` überschreiten die Chunk-Grenze nicht.
### F06 — MAN-Abrechnung läuft über das explizite Vertragsende hinaus
**Priorität:** P1.
**Fundstellen:** `app/Services/Billing/ManualInvoiceService.php:44–58,71–80,143–148`; `app/Models/UserPaymentOption.php:94–115`; `app/Console/Commands/GrandfatherLegacySubscriptions.php:99–100,207–209`.
Der Import übernimmt `valid_until_date` nach `grandfathered_until`. Die aktive Legacy-Berechtigungslogik berücksichtigt dieses Vertragsende. Weder die Rechnungsauswahl noch die erneute Prüfung unter dem Row Lock berücksichtigen es.
**Trigger:** Status bleibt `Grandfathered`, das explizite Vertragsende ist bereits überschritten, `current_period_end` ist fällig. Der Service erstellt weiterhin eine MAN-Rechnung und verlängert die Periode.
**Auswirkung:** Zugang und Abrechnung beurteilen denselben Vertrag widersprüchlich. Es können Rechnungen für einen technisch bereits als abgelaufen behandelten Vertrag entstehen. Ob solche Fälle im Bestand vorhanden sind, wurde nicht anhand personenbezogener Rechnungsdaten untersucht.
**Empfehlung:** Eine zentrale fachliche Abrechenbarkeitsregel verwenden und das Vertragsende sowohl bei der Auswahl als auch direkt vor dem Schreiben unter Sperre beachten. Behandlung des Endtags und einer gegebenenfalls abschließenden Abrechnung ausdrücklich festlegen.
**Regression:** Gestern beendeter, heute endender und unbefristeter Vertrag; auch direkter Aufruf von `invoiceFor()` muss dieselbe Grenze beachten.
### F14 — Newsletter-Start ist nicht gegen Parallelität und Teilabbrüche abgesichert
**Priorität:** P2.
**Fundstellen:** `app/Services/Newsletter/CampaignSender.php:29–75`; Empfängertabelle wie F05.
Der Draft-Guard prüft ein bereits geladenes Model. Empfänger werden ohne gemeinsamen Start-Lock eingefügt, anschließend wird `Sending` gesetzt und erst danach werden Jobs einzeln erzeugt.
**Fehlerfälle:** Zwei Requests starten denselben Entwurf; ein Start bricht mitten im Empfängerimport ab; oder der Queue-Dispatch scheitert nach einigen Jobs. Je nach Zeitpunkt bleiben doppelte Empfänger, eine teilweise befüllte Draft-Kampagne oder eine nicht mehr startbare `Sending`-Kampagne mit dauerhaft wartenden Empfängern zurück.
**Empfehlung:** Atomare Übernahme des Kampagnenstarts, eindeutige Empfänger und wiederaufnehmbare Einplanung aus persistentem Zustand. Beispielsweise vorbereitender Status plus Compare-and-swap/Row Lock und separater Dispatcher/Outbox. Eine große DB-Transaktion allein garantiert die Übergabe an eine externe Queue nicht.
**Regression:** Zwei unabhängig geladene Modelinstanzen derselben Kampagne; Ausnahme nach dem ersten Empfängerbatch; Ausnahme nach dem ersten Dispatch. Wiederanlauf muss alle Empfänger genau einmal einplanen.
### F15 — Versandjob kann doppelt senden und Fortschritt falsch zählen
**Priorität:** P2.
**Fundstellen:** `app/Jobs/SendNewsletterCampaignMail.php:32–57,80–102`.
Das Lesen von `Pending` ist kein atomarer Claim. Erst nach SMTP-Versand wird der Empfängerstatus geändert; Empfängerabschluss und Kampagnenzähler erfolgen in separaten Schreibvorgängen.
**Trigger:** SMTP nimmt eine Nachricht an, anschließend scheitert das DB-Update. Der Retry sieht weiterhin `Pending` und sendet nochmals. Bei einem Abbruch nach Empfängerupdate, aber vor Zählererhöhung bleibt der Fortschritt dagegen dauerhaft zu niedrig. Parallel laufende Instanzen können dieselbe Pending-Zeile gleichzeitig bearbeiten.
**Empfehlung:** Atomarer Versandclaim und idempotenter, transaktionaler DB-Abschluss. Für die Grenze SMTP/DB eine bewusste Zustellstrategie definieren: Provider-Idempotenz, falls verfügbar, oder sichtbarer Zustand „Zustellung unklar“. Exakt einmalige SMTP-Zustellung lässt sich nicht durch einen DB-Lock allein garantieren.
**Regression:** Fehler vor/nach SMTP-Annahme und zwischen Statusupdate und Zählerpflege; parallele Bearbeitung derselben Empfängerzeile. Wiederanlauf und Abschlussstatus prüfen.
### F16 — Die 50 ältesten nicht abrechenbaren Verträge können alles Weitere blockieren
**Priorität:** P2.
**Fundstellen:** `app/Console/Commands/GenerateManualInvoices.php:19–21,30,41–68`; `app/Services/Billing/ManualInvoiceService.php:44–58,87–107`; `routes/console.php:111–115`.
Der tägliche Lauf wählt standardmäßig die 50 ältesten fälligen Vereinbarungen. Nicht abrechenbare Fälle, beispielsweise ohne Rechnungsadresse oder Intervall, werden übersprungen und behalten ihre alte Fälligkeit. Ein nächster Batch wird nicht verarbeitet.
**Trigger:** Die ersten 50 fälligen Vereinbarungen sind unvollständig. Sie werden jeden Tag erneut ausgewählt, während die 51. vollständig abrechenbare Vereinbarung nie erreicht wird. Auch ohne ungültige Fälle ist der planmäßige Durchsatz auf 50 Vereinbarungen täglich begrenzt.
**Empfehlung:** Den fälligen Bestand mit begrenzten Cursor-Batches durchgehen; fehlerhafte Fälle separat markieren und erneut bearbeiten. Metriken für Rückstand und Alter des ältesten bearbeitbaren Falls ergänzen.
**Regression:** 50 unvollständige Vereinbarungen vor mindestens einer vollständigen. Die vollständige muss trotz der übersprungenen Fälle verarbeitet werden.
### F17 — KI-HTTP-Versuche können länger dauern als der Worker zulässt
**Priorität:** P2, bedingter Betriebsbefund.
**Fundstellen:** `app/Services/PressRelease/Classification/Drivers/OpenAiClassificationDriver.php:35–45`; `app/Services/PressRelease/ContentScore/Drivers/OpenAiContentScoreDriver.php:43–53`; `config/scoring.php:38,62`; `app/Jobs/ClassifyPressRelease.php:33`; `app/Jobs/ScorePressRelease.php:29`; `app/Console/Commands/RunClassificationQueue.php:23–34`; `config/queue.php:42,70`.
Der Content-Score-Treiber hat standardmäßig bis zu 30 Sekunden pro HTTP-Versuch; die Retry-Liste `[200, 500]` erlaubt drei Versuche. Sein mögliches Gesamtbudget liegt damit bei ungefähr 90,7 Sekunden zuzüglich sonstiger Arbeit. Die Klassifikation verwendet laut `config/scoring.php` dagegen standardmäßig 15 Sekunden und kommt auf ungefähr 45,7 Sekunden; das gleiche Überschreitungsrisiko entsteht dort erst bei entsprechend höherer Konfiguration oder zusätzlicher Laufzeit. Die Jobs besitzen kein eigenes Timeout; der Projekt-Worker setzt ebenfalls keines, sodass der Laravel-Standard von 60 Sekunden relevant ist. `retry_after` liegt standardmäßig bei 90 Sekunden.
**Trigger:** Mehrere langsame Transporttimeouts beim Content-Score. Der Worker kann den Prozess beenden, bevor der vorgesehene deterministische Fallback erreicht wird. Produktionsseitig anders konfigurierte Timeouts können das Verhalten ändern und wurden nicht verifiziert.
**Empfehlung:** HTTP-Gesamtbudget, Jobtimeout, Workerparameter und `retry_after` zusammen abstimmen. `retry_after` mit Sicherheitsabstand über dem maximalen Jobtimeout halten. Ein isoliertes Anheben nur des Worker-Timeouts kann Doppelverarbeitung begünstigen.
**Regression:** Kontrollierte langsame/fehlgeschlagene Treiberantworten mit realem Queue-Worker; Fallback und endgültiges Scheitern beobachten. Ein synchroner Jobtest beweist dieses Verhalten nicht.
### F18 — Monatskontingent-Reset ist nicht idempotent
**Priorität:** P2.
**Fundstellen:** `app/Console/Commands/ResetMonthlyPressReleaseQuota.php:30–34`; `routes/console.php:121–126`; `app/Services/PressRelease/PressReleaseService.php:291–296`.
Der Command setzt alle positiven Monatszähler auf null, ohne die bereits zurückgesetzte Periode zu speichern. Er ist am Monatsersten um 00:05 geplant.
**Trigger:** Publikationen zwischen Mitternacht und 00:05 werden kurz darauf wieder aus dem Verbrauch entfernt. Eine Wiederholung nach weiteren Publikationen löscht den Verbrauch nochmals. Wird der Schedulertermin verpasst, ist im Resetpfad kein automatischer Catch-up vorgesehen.
**Empfehlung:** Verbrauch an eine eindeutige Kalenderperiode binden und den Periodenwechsel atomar genau einmal vornehmen. Alternativ periodisierte Verbrauchsdatensätze verwenden. Nachholung beim ersten Zugriff definieren, damit ein einzelner Crontermin nicht die gesamte Kontingentlogik bestimmt.
**Regression:** Monatswechsel, zweimaliger Reset mit Publikation dazwischen, verspäteter Lauf. Kalenderkontingent nicht ohne fachliche Entscheidung auf Stripe-Aboperioden umstellen.
### F19 — Zwei Aufrufaggregatoren können dieselben Events doppelt zählen
**Priorität:** P2, Parallelitätsbedingung.
**Fundstellen:** `app/Console/Commands/AggregatePressReleaseViews.php:45–46,73–91,99–113`; Schedulerregistrierung in `routes/console.php`.
Counter und Fortschrittsmarke werden erfreulicherweise gemeinsam transaktional geschrieben. Die Fortschrittsmarke wird jedoch vorher gelesen und nicht unter einer gemeinsamen Sperre neu geprüft.
**Trigger:** Ein manueller Command-Lauf überlappt den Scheduler oder zwei unabhängig gestartete Prozesse laufen gleichzeitig. Beide lesen denselben Marker und addieren dieselben Ereignisse. `withoutOverlapping()` schützt den Scheduleraufruf, jedoch nicht automatisch jeden manuellen Einstieg.
**Empfehlung:** Einen für alle Einstiegspfade gemeinsamen Command-Lock verwenden oder die Watermark unter Row Lock lesen und den Batch innerhalb der geschützten Sequenz bestimmen. Laufzeitbegrenzung für dauerhaften Ereignisnachschub ergänzen.
**Regression:** Zwei DB-Verbindungen mit kontrolliertem Interleaving. Jeder Event darf nur einmal in `hits` eingehen; Wiederholung ohne neue Events muss wirkungslos sein.
### F20 — Geplante Publikationen melden trotz technischer Fehler Erfolg
**Priorität:** P2.
**Fundstellen:** `app/Console/Commands/PublishScheduledPressReleases.php:93–117`; `routes/console.php`, Failure-Hook für `press-releases:publish-scheduled`.
Technische Exceptions werden gezählt und über `report()` protokolliert. Der Command liefert anschließend trotzdem immer `SUCCESS`. Der konfigurierte `onFailure`-Alarm hängt jedoch vom fehlgeschlagenen Command ab.
**Trigger:** Einzelne oder alle fälligen PMs scheitern im Veröffentlichungsservice. Exit-Code-basierte Überwachung meldet einen erfolgreichen Lauf.
**Empfehlung:** Erwartete fachliche Ablehnungen von technischen Fehlern unterscheiden. Bei technischen Fehlern einen Fehler-Exit-Code liefern oder einen ausdrücklichen Alarm auslösen. Vorhandenes Fehlerlogging beibehalten; der Befund lautet nicht „Fehler werden überhaupt nicht protokolliert“.
**Regression:** Service wirft technische Exception: Fehler-Exit-Code bzw. Alarm nachweisen. Blacklistablehnung separat testen und fachlich definieren.
## 8. Datenbank, Importe und Performance
### F21 — Legacy-Import ist nicht durchgehend speicherbegrenzt oder atomar
**Priorität:** P2; Relevanz abhängig von weiteren Import-/Delta-Läufen.
**Fundstellen:** `app/Services/Import/PressReleaseImporter.php:34–58,159–170,182–265`; insbesondere die Identitätsübernahme bei `:189`.
Vor dem 200er-PM-Chunking werden die vollständigen Legacy-Bild- und Kontaktpivot-Tabellen geladen und gruppiert. Auch ein kleiner `--since`-Deltaimport benötigt dadurch Speicher abhängig vom Gesamtbestand.
Pro PM erfolgen Hauptdatensatz, Zeitstempel, Bilder, Kontakte und Importmap ohne gemeinsame Zieltransaktion. Scheitert der Import nach Speicherung der PM, aber vor dem Mapping, existiert ein Teilimport. Der Wiederanlauf identifiziert den bereits vorhandenen Datensatz nicht in allen Fällen unabhängig von der Importmap und kann seine öffentliche UUID-/Slug-Identität verändern.
**Empfehlung:** Relationen nur für die Legacy-IDs des aktuellen Chunks laden. Zieländerungen einschließlich Mapping je PM transaktional speichern. Vorhandene Zielidentität auch ohne Mapping anhand des stabilen Legacy-Schlüssels erkennen. Delta-Wiederanlauf ausdrücklich als fachlichen Vertrag testen.
**Regression:** Ausnahme nach Hauptdatensatz, aber vor Mapping; Wiederholung muss dieselbe UUID und denselben Slug behalten. Großer Mediengesamtbestand mit kleinem PM-Delta darf den Speicherbedarf nicht linear zum Gesamtbestand anwachsen lassen.
### F22 — E-Mail-Suche umgeht den bereits angelegten Index
**Priorität:** P2.
**Fundstellen:** `app/Services/Auth/ContactAccessService.php:128–146`; `app/Models/User.php:186`; Indexmigration für `companies.email` und tatsächlich vorhandener `companies_email_idx`.
Die Firmenzuordnung fragt `LOWER(email) = ?` ab. Ein normaler Index auf `email` kann diese Funktionsbedingung in der beobachteten Query nicht verwenden. Das wurde mit zwei ausschließlich lesenden `EXPLAIN`-Abfragen überprüft; als Vergleichswert wurde eine synthetische Adresse unter `example.invalid` verwendet.
| Vergleich | MySQL-Zugriff | Index | Geschätzte Zeilen |
|---|---|---|---:|
| `LOWER(email) = ... AND deleted_at IS NULL` | `ALL` | keiner | 110.291 |
| `email = ... AND deleted_at IS NULL` | `ref` | `companies_email_idx` | 1 |
Die verfügbare Datenbank verwendet für `companies.email` `utf8mb4_unicode_ci`, also eine bereits case-insensitive Kollation. Die Zahlen sind **Optimizerschätzungen**, keine gemessenen Laufzeiten und keine exakt gelesenen Datenmengen. Für `users` und `contacts` wurde in diesem Audit kein eigener EXPLAIN-Vergleich vorgenommen.
**Auswirkung:** Ein vorhandener Performanceindex bleibt gerade in einem häufigen Login-/Zuordnungspfad ungenutzt. Mehrere Loginverfahren und erneute Zuordnungen können diese Suche wiederholt auslösen.
**Empfehlung:** E-Mail-Normalisierung und Kollationsvertrag festlegen. Unter der vorhandenen MySQL-Kollation einen direkten Vergleich nutzen und Groß-/Kleinschreibungsfälle testen; bei abweichenden Datenbanken gegebenenfalls eine indizierte normalisierte Spalte verwenden. Nicht ungeprüft die gleiche Annahme auf alle Tabellen/Umgebungen übertragen.
**Regression:** Groß-/Kleinschreibung, führende/nachfolgende Leerzeichen und Legacy-Adressen; zusätzlich EXPLAIN für den finalen Querypfad. Erfolgskriterium ist korrekte Zuordnung mit Indexzugriff, nicht nur eine kürzere Query.
### 8.1 Beobachtete Größenordnung und Konsequenzen
Lesend aus `information_schema.TABLES` entnommene InnoDB-Schätzwerte der verfügbaren Verbindung:
| Tabelle | Geschätzte Zeilen |
|---|---:|
| `press_releases` | 134.979 |
| `companies` | 110.291 |
| `users` | 65.572 |
| `contacts` | 19.018 |
| `press_release_events` | 952.754 |
| `activity_events` | 5.316 |
Diese Werte sind ungefähre Schema-/Optimierermetadaten. Es wurden dafür keine einzelnen Personen-, Kunden- oder Nachrichtendatensätze ausgelesen. Sie belegen, dass Vollscans, unbegrenzte Collection-Ladevorgänge und kleine Batch-Grenzfehler praktisch relevante Größenordnungen erreichen können.
### 8.2 Weitere Performanceverbesserungen
1. **Cacheinvalidierung gezielter machen:** `AdminPerformanceCacheObserver` verwirft bei jeder beobachteten Änderung die gesamte Liste der Admin-Cachekeys. Bei Importen oder vielen einzelnen Änderungen entstehen unnötige Löschoperationen. Zuerst Anzahl und Dauer messen; danach nach Domäne bzw. Batch invalidieren.
2. **Abfragebudget pro Seite definieren:** Für PM-Listen, Firmenansicht und Dashboard sowohl Abfrageanzahl als auch geladene Zeilen messen. Eine konstante Queryanzahl allein schließt übergroße Relationen nicht aus.
3. **Kein zusätzlicher Index ohne Query-Plan:** Bereits viele kombinierte Indizes sind vorhanden. Weitere Indizes erhöhen Schreib- und Migrationskosten. Kandidaten erst anhand realer WHERE-/ORDER-BY-Pfade bewerten.
4. **Öffentliche Suche begrenzen:** Kurze Suchbegriffe fallen bewusst auf LIKE zurück. Eingabelänge, Wiederholrate, teure Sortierungen und tiefe Seiten bei großen Beständen beobachten. Keine unbelegte SQL-Injection behaupten: die betrachteten Rohbedingungen verwenden Parameterbindung.
5. **Firmen-/Kontaktzuordnung bündeln:** Die Zuordnung führt pro Firma weitere Existenz-/Pivotabfragen aus. Nach F01/F10 kann dieser Pfad auf konsistente, gebündelte Berechtigungsdaten ausgerichtet werden; Sicherheit geht vor Queryverkürzung.
## 9. Tests, CI und Entwicklungsqualität
### F24 — Lint-Workflow prüft den eingecheckten Stil nicht verbindlich
**Priorität:** P2.
**Fundstellen:** `.github/workflows/lint.yml:13–14,31–46`.
Der Workflow führt `vendor/bin/pint` im schreibenden Modus aus. Die nachfolgende Commit-Stufe ist auskommentiert; ein Diff-/Fehlergate fehlt. Der CI-Checkout kann daher erfolgreich formatiert werden, während der eigentliche Commit unverändert unformatiert bleibt. Ein erfolgreicher Workflow ist kein Nachweis, dass der eingecheckte Code bereits dem Stil entspricht.
**Empfehlung:** Einen bewusst prüfenden CI-Mechanismus verwenden: beispielsweise nach dem erforderlichen Formatierungslauf einen verbleibenden Git-Diff als Fehler werten. Die für dieses Projekt geltende lokale Pint-Regel und den CI-Vertrag ausdrücklich unterscheiden. Schreibrechte `contents: write` sind für einen rein prüfenden Job nicht notwendig.
**Regression/Abnahme:** Absichtlich unformatierter Probe-Commit muss den erforderlichen CI-Check rot machen; sauberer Commit grün. In diesem Audit wurde kein Formatter ausgeführt, da keine PHP-Datei geändert werden sollte.
### F25 — Hohe Testzahl, aber Lücken bei produktionsnahen und sicherheitskritischen Zuständen
**Priorität:** P2.
**Fundstellen:** `phpunit.xml:7–14,29–43`; `phpstan.neon:16–27`; `.github/workflows/tests.yml:35–61`; `tests/Feature/PressReleasePreviewTest.php:237–247`; `tests/Feature/CustomerProfileSecurityTest.php:253–270`.
Die Testkonfiguration erzwingt SQLite `:memory:` und synchrone Queues. Das ist für viele schnelle Featuretests sinnvoll, beweist aber keine MySQL-Zeilensperren, Fulltext-Ausführung, Kollationsdetails, Queue-Timeouts oder echte konkurrierende Transaktionen. Die CI-Datei zeigt keine ergänzende MySQL-/Queue-Matrix.
PHPStan arbeitet auf Level 4 mit Baseline. Die Analysepfade umfassen `app`, Bootstrap, Config, Factories, Seeder und Routen, jedoch nicht die umfangreiche PHP-Logik in Volt-Blade-Dateien. Diese Logik ist nicht schon dadurch statisch analysiert, dass das Projekt Larastan verwendet.
Hinzu kommen konkret irreführende bzw. zu kleine Tests: Der Soft-Delete-Test benutzt Force Delete (F04); Newslettertests überschreiten die Chunk-Grenze nicht (F05); die Recovery-Tests prüfen normale UI-Sichtbarkeit, nicht die Manipulation der öffentlichen Property (F03). Ein JS-/Browser-Testlauf ist in den betrachteten Paket- und CI-Dateien nicht eingerichtet.
**Empfehlung:** Bestehende schnelle Tests behalten und gezielt ergänzen: MySQL-Integration für Locks/Fulltext, echte Workerprüfung für Zeitgrenzen, Browserfälle für Fokus/Livewire-Navigation und negative Lifecycle-Tests für Rechte-/Identitätsänderungen. Geschäftskritische Logik schrittweise aus großen Volt-Dateien in bereits etablierte analysierbare Services/Actions verlagern. Baseline nur begründet verändern; sie nicht als allgemeine Fehlerablage behandeln.
**Abnahme:** F01–F07 erhalten jeweils einen zuvor fehlschlagenden Regressionstest; mindestens ein produktionsnaher Lauf beweist die DB-/Queue-spezifischen Fälle. Eine Code-Coverage-Prozentzahl wurde nicht ermittelt und wird nicht aus der Testanzahl abgeleitet.
### F27 — `composer run dev` startet standardmäßig keinen Vite-Server
**Priorität:** P3.
**Fundstellen:** `composer.json`, Script `dev`; `package.json`, Scripts `dev`, `dev:portal`, `dev:web`, `dev:all`.
Composer startet neben Server, Queue und Logs `npm run dev`. Dieses npm-Script gibt jedoch nur einen Hinweistext aus und beendet sich. Die tatsächlichen Vite-Server werden ausschließlich durch die spezialisierten Scripts gestartet.
**Auswirkung:** Der übliche Entwicklungsstart wirkt aktiv, liefert aber keine laufende Asset-Neukompilierung. Vorhandene Builddateien können den Fehler zunächst verdecken. Ohne Assets können zusätzlich Manifest-/Darstellungsfehler auftreten.
**Empfehlung:** Einen funktionierenden Standardstart definieren und die bewusste Auswahl einzelner Bundles zusätzlich anbieten. Den Bedarf an externem MySQL, Redis, Mailpit und Traefik im Startprozess klar dokumentieren: `docker-compose.yml` referenziert diese Infrastruktur, stellt sie selbst aber nicht vollständig bereit.
**Abnahme:** Aus einer vorbereiteten frischen Entwicklungsumgebung den dokumentierten Hauptbefehl starten; beide gewünschten Hotfiles/Ports und eine sichtbare Assetänderung prüfen. Kein Dependency-Upgrade dafür erforderlich.
### F28 — Entwicklungsdokumentation enthält widersprüchliche aktuelle Zustände
**Priorität:** P3.
**Fundstellen:** `docs/README.md:3–6,24–45`; `dev/roadmap.md:3–6`; `dev/README.md`; `resources/views/admin/README.md`; `resources/views/admin/BACKEND_STATUS.md`.
`docs/README.md` bezeichnet den Stand vom 12.06.2026 als Einstieg und führt inzwischen vorhandene Funktionen als noch nicht umgesetzt. `dev/roadmap.md` ist als Stand vom 20.07.2026 formuliert, während Entwicklung und Berichte unter `dev/2026-09-01` bis September weiterlaufen. Mindestens der Link auf `dev/frontend/hub-flux/PROGRESS.md` verweist auf die alte, inzwischen archivierte Struktur. Admin-Statusdateien enthalten ebenfalls ältere TODO-Stände.
**Auswirkung:** Neue Entwickler können vorhandene Funktionen nochmals planen, veraltete Entscheidungen als verbindlich ansehen oder wichtige offene Betriebsaufgaben übersehen. Umfangreiche Dokumentation hilft nur, wenn Gegenwart, historische Entscheidung und Archiv eindeutig unterscheidbar sind.
**Empfehlung:** Einen einzigen aktuellen Einstieg mit Datum, Verantwortlichkeit und Links auf Code-/Betriebsreferenzen festlegen. Historische Dokumente als solche markieren und verschobene Verweise korrigieren. Für jede neue Entscheidung den betroffenen bisherigen Entscheidungsstand ausdrücklich ersetzen.
**Abnahme:** Ein neuer Entwickler kann aus dem Einstieg Architektur, lokalen Start, Prüfkommandos, aktuellen Backlog und Deploymentablauf ohne widersprüchliche Statusangaben finden. Links automatisiert auf vorhandene lokale Ziele prüfen.
### 9.1 Entwicklungsverlauf
Die betrachtete lokale Git-Historie zeigt seit April 2026 folgende Commitverteilung: Mai 7, Juni 110, Juli 222, September 14. Diese Zahlen sind nur die sichtbare Historie des Checkouts, keine Produktivitätsmessung oder vollständige Teamhistorie.
Die jüngsten Änderungen sind fachlich fokussiert: Firmenkonsolidierungs-Trockenlauf, Konversionsmessung, API-Portalauflösung, Newsletter-Bounces, E-Mail-Index, Wiederherstellung der statischen Analyse und Entfernung mit 410. Das spricht für eine aktive Nachbesserungsphase nach dem Launch.
Das wiederkehrende Entwicklungsrisiko ist **teilweise Zentralisierung**: Neue Services und abgesicherte Pfade existieren, alte Alternativwege bleiben gleichzeitig aktiv. Beispiele sind der sichere `pending_email`-Dialog neben der sofortigen Fortify-Änderung, 2FA im Magic-Login neben dem direkten Google-Login und ein korrekter 410-Cacheflush neben normalem `delete()`. Künftige Änderungen sollten deshalb immer alle Einstiegspfade einer fachlichen Operation erfassen.
## 10. Weitere Optimierungen und gezielt offene Prüfungen
Die folgenden Punkte sind bewusst nicht als zusätzliche bestätigte Sicherheitslücken gezählt. Sie benötigen Messung, eine fachliche Entscheidung oder einen produktionsnahen Nachweis.
| Bereich | Beobachtung | Nächster sinnvoller Schritt |
|---|---|---|
| Authentifizierungsarchitektur | Mehrere Login- und Profiländerungspfade sind auseinanderentwickelt | Gemeinsame Identitäts-/Loginabschlusslogik und einheitlicher E-Mail-Änderungsservice |
| Google-Identität | `SocialAuthService` verknüpft primär per E-Mail und überschreibt Providerdaten | Umgang mit stabiler Provider-ID, verifizierter Adresse, Providerwechsel und Account-Linking ausdrücklich definieren; nicht als nachgewiesene Google-Tokenfälschung einstufen |
| Rollen/Presets | Preset-Routen sind nicht mit einer eigenen Managementpermission eingeschränkt | Entscheiden, ob Editoren globale Voreinstellungen ändern dürfen; dann Route und Actions symmetrisch absichern |
| MAN-Perioden | `addMonth()` / `addYear()` können an Monats-/Schaltjahresgrenzen überlaufen | Vertraglichen Periodenanker definieren; 31. Januar und 29. Februar testen |
| MAN-Steuerdaten | MAN-Resolver und Checkout nutzen unterschiedliche Prüftiefe bei USt-IDs | Technischen Datenvalidierungsvertrag angleichen und fachlich freigeben; keine Steuerrechtsbewertung aus diesem Audit ableiten |
| Legacy-Aufrufzahlen | Reimport schreibt absolute Legacy-`hits`, neuer Aggregator addiert eigene Events | Bei weiteren Delta-Läufen Eigentümerschaft des Zählers definieren; Verlust bereits aggregierter neuer Aufrufe verhindern |
| Mediencache | Öffentliche Medien erhalten `public, max-age=86400` | Anforderungen an Entfernungsfristen, CDN-Purge und versionierte URLs prüfen; gespeicherte Browserkopien sind nicht durch DB-Änderung widerrufbar |
| Bildverarbeitung | MIME-/Bytegrenzen vorhanden, GD-Verarbeitung im Uploadpfad | Maximale Pixelmaße und Dekompressions-/Speicherbudget prüfen; Tests mit großen stark komprimierten Bildern |
| Newsletter-Anmeldung | API erzeugt unbestätigte Einträge; Sync-Provider ist teilweise Platzhalter | End-to-End bestätigen, wer Bestätigungsmail, DOI-Abschluss und externen Sync tatsächlich übernimmt; keine funktionierende Integration aus einem 201 allein ableiten |
| Queue-Priorisierung | Klassifikation und Default-Jobs teilen laut Runbook Worker | Bei Last separate Kapazität für Transaktionsmails/Newsletter/KI erwägen; Wartezeiten zuerst messen |
| Retention | Events/Magic Links besitzen eigene Bereinigung; weitere Protokolle wachsen | Aufbewahrungs- und Archivregeln pro Datentyp festlegen, einschließlich API-Logs, KI-Audits und Versandprotokollen |
| Abhängigkeiten | `npm i` statt `npm ci` in Test-CI; kein Dependency-Auditgate in den betrachteten Workflows | Reproduzierbare Installation und gesonderte Security-Prüfung einrichten; Updates nur nach Kompatibilitätsprüfung |
| Sicherheitsheader | Grundheader vorhanden; keine CSP in `SecurityHeaders` | Eine zu Livewire/Alpine und bewusst erlaubten Footer-Scripts passende CSP zunächst beobachten; fehlende CSP allein ist kein XSS-Nachweis |
| Proxy/Hosts | `trustProxies('*')` und Host-Whitelist vorhanden, einschließlich IP-/localhost-Ausnahmen | Reale Proxykette und Erreichbarkeit des Appports prüfen; Schutz vor manipulierten Forwarded-Headern nicht allein aus lokaler Config ableiten |
| Formulare/Assets | Zwei Vite-Bundles und teilweise historische Kommentare zu Alpine | Browser-Smokes für Bundlewechsel, Livewire-Navigation, Fehlerscroll und erneute Initialisierung |
| Große Volt-Komponenten | UI und Geschäftslogik eng gekoppelt | Schrittweise entlang tatsächlicher Aktionen zerlegen: Teilen, Boost, Nachweis, Korrektur, DSGVO-Anfrage, Abrechnung |
| PDF-Erzeugung | Umfangreicher eigener Renderer | Repräsentative Unicode-, Umbruch-, Mehrseiten- und Randfall-PDFs visuell vergleichen; Parser-/Layouttests ergänzen |
| Betriebliche Nachweise | Runbook vorhanden, tatsächliche Cron-/Worker-/Backupzustände nicht geprüft | Wiederherstellungstest und Alarmtest mit dokumentiertem Ergebnis; RTO/RPO und Zuständigkeit festlegen |
## 11. Priorisierter Umsetzungsplan
Alle folgenden Schritte sind **Empfehlungen für Folgearbeit**. In diesem Audit wurde keiner dieser Eingriffe umgesetzt.
### Paket A — Identitäts- und Veröffentlichungsgrenzen schließen
**Reihenfolge:** F01 → F02/F03 → F04 → F07.
1. Unverifizierte E-Mail als Grundlage für Berechtigungen ausschließen; alten Fortify-Profilpfad konsolidieren.
2. Google in die gleiche 2FA-Abschlusslogik führen; Sicherheitsaktionen mit erneutem Identitätsnachweis schützen.
3. Portalübergreifende Abfragen von Soft-Delete-Ausnahmen trennen; Medien-/Vorschauzugriff mitprüfen.
4. KI-Ergebnisse und Freigaben an eine Inhaltsversion binden.
**Abnahmekriterium:** Die jeweiligen negativen Regressionstests schlagen vor dem Fix fehl und danach fehlerspezifisch nicht mehr an. Bestehende reguläre Passwort-, Magic-, Google- und Veröffentlichungsflüsse funktionieren weiter. Für F01 zusätzlich eine gezielte Bestandsprüfung automatisch verliehener Rechte planen.
### Paket B — Versand und Rechnungslogik stabilisieren
**Reihenfolge:** F05/F06 → F16 → F14/F15.
1. Newsletter-Chunking und Eindeutigkeit korrigieren, bevor größere Kampagnen erneut gestartet werden.
2. Vertragsende im MAN-Auswahl- und Schreibpfad beachten.
3. Rechnungslauf gegen blockierende unvollständige Altfälle absichern.
4. Versandstart und Zustellung wiederaufnehmbar machen; Zustellunsicherheit offen behandeln.
**Abnahmekriterium:** Über 1.000 Empfänger werden vollständig und eindeutig eingeplant; beendete Verträge erzeugen keine unzulässige Folgeperiode; Fehler in einem Datensatz blockieren keine anderen. Keine Tests mit echten Mailadressen oder echten Zahlungen ausführen.
### Paket C — Rechtewechsel, Cache und Eingabegrenzen
**Umfang:** F08–F13 sowie F23.
Rechte erneut pro Action prüfen, Superadmin-Deaktivierung wirksam machen, automatischen Rechteentzug definieren, API-Parameter validieren, Melde-URL korrekt auflösen, öffentliche Caches konsistent invalidieren und Uploadfehler sauber behandeln.
**Abnahmekriterium:** Tests ändern Rechte, Datenzustand und Storageverfügbarkeit zwischen zwei Requests. Ein ausschließlich erfolgreicher Erstdurchlauf genügt nicht.
### Paket D — Scheduler, Queue und Importwiederanlauf
**Umfang:** F17–F21.
Zeitbudgets harmonisieren, Monatsverbrauch periodisieren, Aggregation gegen gleichzeitige Einstiege schützen, technische Publikationsfehler alarmierbar machen und Importtransaktionen/Batchspeicher korrigieren.
**Abnahmekriterium:** Wiederholung und Unterbrechung sind ausdrücklich getestete Normalfälle. Ein versäumter oder doppelt gestarteter Hintergrundlauf darf keine stillen fachlichen Mehrfachwirkungen verursachen.
### Paket E — Schnelle Optimierungen und Qualitätsgates
**Umfang:** F22, F24–F28 und ausgewählte Punkte aus Abschnitt 10.
| Maßnahme | Erwarteter Nutzen | Relativer Aufwand / Risiko |
|---|---|---|
| Direkter indizierter Firmen-E-Mail-Vergleich nach Kollationsprüfung | Konkrete Vermeidung eines Vollscans | Klein; Normalisierungsregeln testen |
| Unter-/Obergrenze für API-Pagination | Begrenzter Speicher- und Queryaufwand | Klein; API-Fehlervertrag beachten |
| Technische Schedulerfehler mit Fehlerstatus melden | Vorhandene Alarme werden aussagekräftig | Klein; fachliche Ablehnungen trennen |
| Gutschriften erneut autorisieren | Rechteentzug sofort wirksam | Klein; Lifecycle-Test erforderlich |
| Lintjob mit verbindlichem Diff-Gate | Formatierung des tatsächlichen Commits gesichert | Klein |
| Funktionierender Standard-Devstart | Weniger Asset-/HMR-Verwirrung | Klein; beide Bundles prüfen |
| Dokumentationseinstieg aktualisieren | Schnellere Orientierung und weniger Doppelarbeit | Klein |
| MySQL-/Worker-/Browserchecks ergänzen | Produktionsrelevante Regressionen auffindbar | Mittel; gezielt beginnen |
| Große Volt-Dateien fachlich zerlegen | Bessere Prüfbarkeit und statische Analyse | Mittel bis groß; kein pauschaler Umbau |
Die Aufwandsklassen sind relative Planungswerte, keine belastbaren Zeitschätzungen. Sicherheits- und Datenbestandsfolgen können den Aufwand wesentlich erhöhen.
## 12. Konkreter Test- und Abnahmeplan
### 12.1 Höchste Priorität
| Testpaket | Prüffälle | Passende vorhandene Anknüpfung |
|---|---|---|
| Identität/Firmenrechte | Unverifizierte E-Mail, Rückwechsel, legitime Verifikation, Kontaktentzug | `Auth/ContactAccessTest.php`, `CustomerProfileSecurityTest.php`, `Auth/AccessGatesTest.php` |
| Login/2FA | Google mit TOTP, Magic mit TOTP, Passwort mit TOTP, inaktive Konten | `Auth/GoogleLoginTest.php`, `Auth/TwoFactorLoginTest.php`, `Auth/MagicLinkLoginTest.php` |
| Sensitive Actions | Recovery-Propertymutation, Disable/Regenerate ohne erneuten Nachweis, Rechteentzug | `CustomerProfileSecurityTest.php`, `Admin/AdminGrantTest.php` |
| Löschen/Sichtbarkeit | Soft Delete versus Force Delete; PM/Firma/Kontakt; Vorschau/Medien/API | `PressReleasePreviewTest.php`, `SecurityHardeningTest.php`, `Api/V1/PressReleaseApiTest.php` |
| Newsletter | Mehrere Chunks, Eindeutigkeit, Parallelstart, Teilabbruch, SMTP-/DB-Fehler | `Admin/NewsletterCampaignTest.php` |
| MAN-Rechnung | Vertragsende, Blockade durch 50 ungültige Fälle, Monatsende/Schaltjahr | `Billing/ManualInvoiceGenerationTest.php` |
| KI-Inhaltsversion | Textänderung während Prüfung, alte Antwort nach neuer Antwort | `PressReleaseClassificationJobTest.php`, `PressReleaseContentScoreTest.php` |
### 12.2 Produktionsnahe Ergänzungen
- **MySQL:** Parallelität mit zwei Verbindungen, echte Zeilensperren, Unique-Konflikte, Slug-/E-Mail-Kollation, Fulltext und EXPLAIN. SQLite-Tests parallel behalten.
- **Queue:** Reale Worker mit den vorgesehenen Timeouts und `retry_after`, künstliche Treiberverzögerung, Prozessabbruch und Wiederanlauf; keine echten externen Kosten verursachen.
- **Browser:** Login-/Kontowechsel, Domain-/Bundlewechsel, Mobilnavigation, Fokus im Dialog, Validierungsanzeige und Tastaturbedienung.
- **Betrieb:** Testalarm bei technischem Schedulerfehler; Nachweis, dass ein älterer Pending-/Sending- oder Abrechnungsrückstand sichtbar wird.
- **Dateien:** Fehlgeschlagene Writes, DB-Abbruch nach Write, sehr große Pixelmaße, abgelaufene/gelöschte Medienlinks.
### 12.3 Reihenfolge der Prüfungen nach Korrekturen
Zuerst die jeweils betroffenen konkreten Pest-Dateien über Sail ausführen. Danach die betroffenen Fachgruppen, PHPStan und schließlich vor Freigabe die gesamte Suite sowie die ergänzten MySQL-/Worker-/Browserprüfungen. Formatierung nur im Rahmen einer autorisierten Codeänderung durchführen.
Beispielhafte vorhandene Dateiaufrufe:
```bash
vendor/bin/sail artisan test --compact tests/Feature/Auth/GoogleLoginTest.php
vendor/bin/sail artisan test --compact tests/Feature/Auth/ContactAccessTest.php
vendor/bin/sail artisan test --compact tests/Feature/CustomerProfileSecurityTest.php
vendor/bin/sail artisan test --compact tests/Feature/PressReleasePreviewTest.php
vendor/bin/sail artisan test --compact tests/Feature/Admin/NewsletterCampaignTest.php
vendor/bin/sail artisan test --compact tests/Feature/Billing/ManualInvoiceGenerationTest.php
vendor/bin/sail bin phpstan analyse --memory-limit=1G --no-progress
vendor/bin/sail composer audit --locked --format=json
vendor/bin/sail npm audit --json
```
Diese Liste ist ein Folgeplan und keine Behauptung, dass die Befehle hier erfolgreich liefen.
## 13. Durchgeführte Prüfungen und Ausführungsprotokoll
| Prüfung | Tatsächliches Ergebnis |
|---|---|
| Git-Ausgangszustand | `git status --short` zu Beginn leer; geprüfter HEAD oben festgehalten |
| Dateiinventar und Größen | Erfasst; siehe Abschnitt 3.2 |
| Boost `application-info` | Erfolgreich; tatsächliche Paketversionen erfasst |
| Boost `database-schema`, Summary | Erfolgreich; Tabellen und Spalten lesend betrachtet |
| Index-/Kollationsmetadaten | Erfolgreich; u. a. Empfängertabelle, PM-Slugindex und Firmen-E-Mail |
| Zwei Firmen-E-Mail-EXPLAINs | Erfolgreich; Fullscan versus Indexzugriff, siehe F22 |
| InnoDB-Tabellenschätzungen | Erfolgreich; keine exakten Nutzdatencounts, siehe 8.1 |
| Framework-/Livewire-/Fortify-Dokumentation | Über Boost abgefragt; sicherheitsrelevante Aussagen zusätzlich am installierten Framework-Code geprüft |
| Bestehende relevante Tests | Lesend geprüft, nicht als bestanden gewertet |
| Audit-Dokumentprüfung | 28 eindeutige Detailbefunde und passende Prioritätszählung; Dateireferenzen und Zeilengrenzen geprüft. Der in F28 bewusst beanstandete historische Pfad ist erwartungsgemäß nicht vorhanden. |
| Abschließender Git-Zustand | Ausschließlich `dev/2026-09-11-audit/` neu; keine Änderung an versionierten Anwendungsdateien |
| `vendor/bin/sail artisan route:list --except-vendor --json --no-interaction` | Exit 1: Docker/Podman nicht verfügbar |
| `vendor/bin/sail up -d` | Exit 1: Docker/Podman nicht verfügbar |
| `vendor/bin/sail artisan test --compact --filter='GoogleLogin\|TwoFactorLogin\|ContactAccess\|PressReleasePreview\|NewsletterCampaign\|ManualInvoice'` | Exit 1 vor Teststart: Docker/Podman nicht verfügbar |
| `vendor/bin/sail bin phpstan analyse --memory-limit=1G --no-progress` | Exit 1 vor Analyse: Docker/Podman nicht verfügbar |
| `vendor/bin/sail composer audit --locked --format=json` | Exit 1 vor Advisory-Prüfung: Docker/Podman nicht verfügbar |
| `vendor/bin/sail npm audit --json` | Exit 1 vor Advisory-Prüfung: Docker/Podman nicht verfügbar |
| Frontend-Build / Browserlauf | Nicht durchgeführt; kein erfolgreicher Laufzeit-/Darstellungsnachweis |
| Formatter / Migration / Versand / Abrechnung | Nicht ausgeführt; kein Anwendungscode verändert |
**Interpretation:** Die Umgebung verhindert dynamische Validierung, nicht die oben beschriebenen statischen Befunde. Umgekehrt beweisen ein großer Testbestand, vorhandene CI-Dateien und installierte Pakete keine aktuelle fehlerfreie Ausführung. Weder „alle Tests grün“ noch „keine Sicherheitslücken in Abhängigkeiten“ wäre auf Basis dieser Sitzung eine zulässige Aussage.
## 14. Quellen und Nachvollziehbarkeit
### 14.1 Primäre Projektquellen
- Routen: `bootstrap/app.php`, `routes/domains.php`, `routes/auth.php`, `routes/admin.php`, `routes/customer.php`, `routes/api.php`, `routes/legacy-api.php`, `routes/web.php`, `routes/legal.php`, `routes/console.php`.
- Zugriffsgrenzen: `app/Authorization/Permission.php`, `app/Policies/*`, Auth-/Panel-/API-Middleware, Auth-Services und Login-/Verifizierungslistener.
- Geschäftslogik: `app/Services/PressRelease/*`, `app/Services/Billing/*`, `app/Services/Newsletter/*`, `app/Services/Import/*`, Jobs und zugehörige Commands.
- Oberfläche: kritische Volt-Komponenten unter `resources/views/livewire`, öffentliche Layouts/Komponenten, Vite-Konfiguration, JavaScript und CSS-Einstiegspunkte.
- Datenmodell: betreffende Eloquent-Modelle, Migrationsdateien und lesend abgefragte MySQL-Metadaten.
- Entwicklungsqualität: `composer.json`, `package.json`, Lockdateien als Bestand, `phpunit.xml`, `phpstan.neon`, `phpstan-baseline.neon`, `tests/`, `.github/workflows/`, Git-Historie.
- Projektentwicklung: `dev/README.md`, `dev/roadmap.md`, `dev/2026-09-01/`, `dev/betrieb/deploy-runbook.md`, `docs/README.md` und einschlägige Statusreferenzen.
Datei-/Zeilenverweise gelten für den oben genannten Commit. Historische Kommentare und frühere Auditnummern im Code wurden nicht ungeprüft als aktuelle Tatsachen übernommen.
### 14.2 Dokumentation zur Gegenprüfung
- [Livewire 4: Security](https://livewire.laravel.com/docs/4.x/security) — öffentliche Actions/Properties und serverseitige Autorisierung.
- [Livewire 4: Locked](https://livewire.laravel.com/docs/4.x/attribute-locked) — Schutz von Properties; ersetzt keine fachliche Berechtigungsprüfung.
- [Laravel 12: Authorization](https://laravel.com/docs/12.x/authorization) — Gates, Policies und `before`-Prüfungen.
- [Laravel 12: Fortify](https://laravel.com/docs/12.x/fortify) — Loginpipeline, 2FA und Passwortbestätigung.
- [Laravel 12: Queues](https://laravel.com/docs/12.x/queues) — Worker-Timeouts und `retry_after`.
- [Laravel 12: Database Testing](https://laravel.com/docs/12.x/database-testing) — Testisolation und Datenbanktests.
Die Boost-Suche lieferte bei einer Fortify-Abfrage einen als `latest` bezeichneten Auszug aus dem 13.x-Dokumentationszweig. Dieser wurde nicht als Beleg einer Laravel-12-spezifischen API übernommen; entscheidend waren für F01/F03 die tatsächlich installierten Fortify-Routen und Actions. Entsprechend wurden auch Pagination und Chunking direkt am installierten Laravel-Code gegengeprüft.
## 15. Freigabebild nach diesem Audit
**Anwendungscode unverändert. Optimierungen wurden ausschließlich beschrieben.**
Für eine belastbare technische Freigabe fehlen die Korrektur bzw. explizite Bearbeitung der P1-Befunde und die anschließend erfolgreichen Regressionen in einer ausführbaren Umgebung. Die größte Verbesserung ist nicht zusätzliche Funktionsmenge, sondern ein einheitlicher Vertrag pro fachlicher Operation: eine Identitätsgrundlage, eine Berechtigungsentscheidung, eine Inhaltsversion, eine Löschsemantik und ein wiederholbarer Hintergrundprozess.
Die vorhandenen zentralen Services, Datenbankstrukturen und Tests bieten dafür eine gute Ausgangsbasis. Der empfohlene Weg ist eine Reihe kleiner, fachlich abgegrenzter Änderungen mit jeweils konkretem Fehlernachweis und Regressionstest.

View file

@ -0,0 +1,494 @@
# Entwicklungsplan zum Audit vom 11.09.2026
**Stand:** 05.10.2026 (Blöcke 1–10 umgesetzt und live seit 05.10.2026 als **v1.1.0**, siehe [`deploy-v1.1.0.md`](../betrieb/deploy-v1.1.0.md))
**Grundlage:** [AUDIT.md](AUDIT.md) (geprüfter Commit `5d0570b`), abgeglichen mit HEAD `24c1bbf` auf `technik/betriebsfixes-2026-09`
**Zweck:** Nur die Befunde, die am aktuellen Code belegt sind, in eine abarbeitbare Reihenfolge bringen. Jeder Punkt hat einen konkreten Fix, einen Regressionstest und eine Aufwandsklasse.
---
## 1. Ergebnis des Abgleichs
Der Audit hält. Von 28 Befunden sind 25 am aktuellen Stand bestätigt, zwei nur teilweise (F08, F17), einer ist zwar richtig, aber praktisch ohne Relevanz (F21). Kein Befund wurde durch die Commits seit dem 11.09. behoben, obwohl mehrere der betroffenen Dateien angefasst wurden (`ContactAccessService`, `ManualInvoiceService`, `PressReleaseService`, `NewsroomDetailPageData`, `press-kits/show`).
Was über den Audit hinausgeht und bei der Prüfung aufgefallen ist:
- **F05 ist schlimmer als beschrieben.** Auf einer Kopie der Live-Daten liefert der Newsletter-Versand an alle 59.705 berechtigten Nutzer genau 500 eindeutige Empfänger, davon 480 doppelt. Die übrigen 59.205 werden nie erreicht. Der Grund ist `chunkById` hinter einer `created_at`-Sortierung; Laravel entfernt nur eine vorhandene `id`-Sortierung, nicht die führende.
- **F15 hat einen zweiten Fehler durch die Mail-Sperrliste vom 15.09.** Ist ein Empfänger gesperrt, bricht `GateOutgoingMail` den Versand ab, der Job wertet den Rückgabewert nicht aus und markiert den Empfänger als „gesendet“. Nach einer Pause durch den Circuit Breaker landen Empfänger endgültig auf „Failed“, und `queue:retry` holt sie nicht nach.
- **F04 hat eine Fundstelle, die der Audit nicht kennt.** Die öffentliche Meldungsseite (`ReleaseDetailPageData.php:76-80`) fällt auf `Contact::withoutGlobalScopes()` zurück und zeigt damit gelöschte Kontakte mit Name, Mail und Telefon öffentlich an.
- **F04 im Kundenpanel ist ein sichtbarer Bug.** Nach „Kontakt löschen“ im Pressefach bleibt der Kontakt in der Liste und ist weiter editierbar (`press-kits/show.blade.php:273-306`).
- **F22 ist gemessen.** `LOWER(email)` auf `companies` (114.100 Zeilen) ergibt einen Vollscan mit 108.424 geschätzten Zeilen; der direkte Vergleich nutzt `companies_email_idx` mit 1 Zeile. Die Abfrage läuft bei jedem Login. Die Kollation ist bereits `utf8mb4_unicode_ci`, `LOWER()` bringt semantisch nichts.
### Befundtabelle
| ID | Audit | Prüfung an HEAD | Eigene Einordnung | Block |
|---|---|---|---|---|
| F01 | P1 | bestätigt, Route aktiv, Kette vollständig | **kritisch**, sofort | 1 |
| F02 | P1 | bestätigt | mittel | 1 |
| F03 | P1 | bestätigt, `showRecoveryCodes` ohne `#[Locked]` | mittel, Fix klein | 1 |
| F04 | P1 | bestätigt, plus zwei Fundstellen mehr, eine falsch benannt | mittel (PII öffentlich, Panel-Bug) | 3 |
| F05 | P1 | bestätigt und auf Live-Kopie bewiesen | **kritisch**, vor der nächsten Kampagne | 2 |
| F06 | P1 | bestätigt, aber latent: kein Datensatz hat `grandfathered_until` | niedrig heute, Fix klein | 5 |
| F07 | P1 | bestätigt, `content_hash` wird nur für Duplikate genutzt | mittel, Fenster: Admin-/Korrektur-Bearbeitung im Status Review | 4 |
| F08 | P2 | teilweise: ohne Snapshot kein Aufruf möglich, nur Stale-Session-Fall | niedrig, Fix klein | 1 |
| F09 | P2 | bestätigt per Tinker | niedrig bis mittel, Einzeiler | 1 |
| F10 | P2 | bestätigt, keinerlei Entzugslogik, Pivot ohne Herkunft | mittel, macht F01 dauerhaft | 1 |
| F11 | P2 | bestätigt, nur negative `per_page` unbegrenzt, `status[]` gibt 500 | niedrig bis mittel | 3 |
| F12 | P2 | bestätigt, Canonical-Arbeit vom 18.09. hat die Report-Controller nicht erfasst | mittel-niedrig | 3 |
| F13 | P2 | bestätigt, bis 1 h Stale | mittel | 3 |
| F14 | P2 | bestätigt | mittel, zusammen mit F05 | 2 |
| F15 | P2 | bestätigt, plus Sperrlisten-Fehler | mittel | 2 |
| F16 | P2 | bestätigt, aktuell 4 fällige Fälle, alle nicht abrechenbar | niedrig | 5 |
| F17 | P2 | teilweise: nur der Score-Job kann 60 s reißen | niedrig bis mittel, Einzeiler | 4 |
| F18 | P2 | bestätigt | mittel (verpasster Cron = Monat ohne Kontingent) | 4 |
| F19 | P2 | bestätigt, kein `Isolatable` | niedrig, Einzeiler | 4 |
| F20 | P2 | bestätigt, Runbook verspricht Alarm, der nie kommt | niedrig bis mittel, Einzeiler | 4 |
| F21 | P2 | bestätigt, Importer ist stillgelegt | **nicht aufgenommen** | – |
| F22 | P2 | bestätigt und gemessen | mittel, Einzeiler je Stelle | 3 |
| F23 | P2 | bestätigt | niedrig bis mittel | 3 |
| F24 | P2 | bestätigt | klein | 6 |
| F25 | P2 | bestätigt; die irreführenden Tests werden in den Blöcken 2 und 3 ersetzt | die breite MySQL-/Worker-Matrix ist **aufgeschoben** | 6 |
| F26 | P2 | bestätigt, kein Fokus-Plugin geladen | niedrig | 6 |
| F27 | P3 | bestätigt | klein | 6 |
| F28 | P3 | bestätigt, Link auf `dev/frontend/hub-flux/PROGRESS.md` ist tot | klein | 6 |
Abschnitt 10 des Audits (unbelegte Beobachtungen) wurde nicht geprüft und ist nicht im Plan. Die drei übrigen Dateien im Ordner sind keine Audit-Befunde: Bildkennzeichnung ist eine eigene Spezifikation mit Feedback-Audit (noch nicht umgesetzt), Kontakterreichbarkeit ist seit den Commits `ada41cb` ff. umgesetzt.
---
## 2. Aufwandsskala und Arbeitsregeln
| Kürzel | Bedeutung |
|---|---|
| S | eine Stunde bis ein halber Tag |
| M | ein bis drei Tage |
| L | länger oder abhängig von einer Entscheidung |
Für jeden Punkt gilt: zuerst der Regressionstest, der am heutigen Code rot ist, dann der Fix, dann `php artisan test --compact` für die betroffene Datei, am Blockende die gesamte Suite und PHPStan. Kein Block wird ohne Deploy abgeschlossen. Blöcke 1 und 2 sind voneinander unabhängig und können parallel laufen; Block 1 hat Vorrang, weil er ausnutzbar ist.
---
## Block 1 — Identität und Rechte (F01, F10, F03, F02, F09, F08)
Der Kern: Firmenrechte entstehen aus einer E-Mail-Übereinstimmung, die nie auf Verifikation geprüft wird, und werden nie wieder entzogen. Alles andere in diesem Block sind Nebenwege, die dieselbe Regel unterlaufen.
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| 1.1 | Fortify-Feature `updateProfileInformation` in `config/fortify.php:150` abschalten. Die Kunden-UI nutzt den Endpunkt nicht; sie hat den `pending_email`-Prozess. Test: `PUT /user/profile-information` liefert 404. | S | E1 |
| 1.2 | `ContactAccessService::syncMembershipsForEmail()` verlangt `hasVerifiedEmail()` und `is_active`. Gilt für alle Aufrufer, nicht nur den Login-Listener. Test: unverifizierter Nutzer mit passender Firmenadresse erhält keinen Pivot; nach Verifikation schon. | S | keine |
| 1.3 | Herkunft am Pivot speichern: Spalte `company_user.granted_via` (`auto`, `manual`, `owner`). Migration klein, Tabelle klein. Bestehende Zeilen: `manual`, wenn sie nicht durch den Sync erklärbar sind (siehe 1.4). | S | E2 |
| 1.4 | Bestandsprüfung als Befehl mit `--dry-run`: alle `responsible`-Pivots, deren Nutzer-Mail heute weder zur Firma noch zu einem Kontakt der Firma passt. Ausgabe als Liste, keine Löschung. Danach Entscheidung je Zeile. | S | 1.3 |
| 1.5 | Entzug beim Login-Sync: `auto`-Pivots mit `responsible`, die keinen aktuellen E-Mail-Treffer mehr haben, entfernen (E7, revidiert E2: `member` ist keine Leserolle). Hochgestufte manuelle Mitgliedschaften (`auto-upgrade`) fallen auf `member` zurück; `manual` und `owner` bleiben unberührt. Test: Kontakt löschen oder Adresse ändern, erneut anmelden, Schreibrecht auf fremde Firmen-PMs ist weg. | M | 1.3, E7 |
| 1.6 | `security.blade.php`: `#[Locked]` auf `showRecoveryCodes`; die Codes nur im Render nach einer Action ausgeben, nicht aus einer Property. Test: `set('showRecoveryCodes', true)` scheitert. | S | keine |
| 1.7 | Erneuter Nachweis vor `disableTwoFactorAuthentication()`, `regenerateRecoveryCodes()` und `updateEmail()`: aktueller TOTP-Code oder Recovery-Code. Das funktioniert auch für Konten ohne Passwort. Test: ohne Code 422, mit Code Erfolg. | M | E3 |
| 1.8 | `GoogleController.php:50`: 2FA-Zweig aus `MagicLinkConsumeController.php:73-81` übernehmen; danach die drei Login-Abschlüsse in einen Service ziehen. Test in `GoogleLoginTest`: Konto mit TOTP endet als Gast auf der Challenge. | S | keine |
| 1.9 | `AppServiceProvider.php:125`: `Gate::before` nur für `is_super_admin && is_active`. Test: deaktivierter Superadmin mit Session bekommt 403 auf Admin- und Kundenroute. | S | keine |
| 1.10 | `routes/admin.php:101-104`: Nutzer-Detailroute unter `can:users:manage` stellen, zusätzlich `authorize()` in beiden Grant-Actions. Test: Rechteentzug bei offener Komponente, danach `grantCredits` scheitert. | S | keine |
**Entscheidungen**
- **E1** Fortify-Profiländerung abschalten (empfohlen) oder auf den `pending_email`-Prozess umbiegen. Abschalten ist ein Einzeiler und schließt den Einstieg vollständig; niemand nutzt den Endpunkt.
- **E2** Entzugsregel: auto-vergebene `responsible` ohne aktuellen Treffer werden beim nächsten Login auf `member` gesenkt (empfohlen), oder komplett entfernt. Absenken ist reversibel und verliert keine manuellen Rechte. *Revidiert durch E7 am 18.09.2026: `member` ist die Teamrolle mit Schreibrecht auf alle Firmen-PMs; die Zuordnung wird entfernt.*
- **E3** Nachweisform für Sicherheitsaktionen: TOTP-/Recovery-Code (empfohlen, funktioniert für alle Kontotypen) oder Passwort mit Sonderweg für passwortlose Konten.
**Abnahme:** Die Kette aus F01 (fremde Adresse setzen, neu anmelden, zurückwechseln) ist als Test in `ContactAccessTest` abgebildet und hinterlässt keinen Pivot. Alle Login-Wege (Passwort, Magic Link, Google) landen bei aktivem TOTP auf der Challenge. Ergebnis von 1.4 ist gesichtet.
---
## Block 2 — Newsletter (F05, F14, F15)
Vor der nächsten Kampagne. Der Versand erreicht heute nur die ersten 500 und verschickt 480 davon doppelt.
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| 2.1 | `CampaignSender.php:39`: Empfängerquery vor `chunkById` mit `reorder()` auf reine ID-Reihenfolge stellen. Die Sortierung ist eine UI-Sortierung der Vorschau, keine Versandreihenfolge. Test: 1.001 Nutzer mit gemischten `created_at`, jede ID genau einmal in den Empfängerzeilen. | S | E4 |
| 2.2 | Unique-Index `(newsletter_campaign_id, user_id)` auf `newsletter_campaign_recipients`. Tabelle ist klein, kein FULLTEXT, ALTER ist sofort. | S | 2.1 |
| 2.3 | Kampagnenstart atomar: `UPDATE … SET status='sending' WHERE id=? AND status='draft'`, nur bei einer betroffenen Zeile weitermachen. Empfänger mit `insertOrIgnore`. Test: zwei Instanzen derselben Kampagne, nur eine startet. | S | 2.2 |
| 2.4 | Versandjob: atomarer Claim (`UPDATE … SET status='sending' WHERE id=? AND status='pending'`), Abschluss von Empfängerstatus und Zähler in einer Transaktion. `$tries` und `$backoff` am Job setzen statt am Worker. | M | keine |
| 2.5 | Rückgabewert von `Mail::send()` auswerten: Bei Abbruch durch die Sperrliste Empfänger auf `suppressed` statt `sent`, Zähler nicht erhöhen. Neuer Status-Wert im Enum. Test: gesperrte Adresse endet nicht als „gesendet“. | S | 2.4 |
| 2.6 | Befehl `newsletter:resume {campaign}`: alle `pending`-Empfänger einer `sending`-Kampagne neu einplanen, `failed` optional zurücksetzen. Ersetzt den unwirksamen `queue:retry`-Hinweis des Circuit Breakers. | S | 2.4 |
**Entscheidung**
- **E4** Versandreihenfolge: keine fachliche Bedeutung (empfohlen, dann reicht 2.1) oder „neueste zuerst“ als Anforderung (dann Cursor über `created_at, id`, Aufwand M).
**Abnahme:** Test über die 500er-Grenze in `NewsletterCampaignTest`; Doppelstart erzeugt keine zweite Empfängerzeile; gesperrte Adressen erscheinen in der Statistik als gesperrt.
---
## Block 3 — Sichtbarkeit, Löschung, Cache, Eingaben (F04, F13, F22, F12, F11, F23)
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| 3.1 | `LOWER(email)` entfernen in `ContactAccessService.php:132,146`, `User.php:186`, `ImportNewsletterBounces.php:131,174`. B-Tree-Index auf `contacts.email` (6 MB, FULLTEXT vorhanden, Rebuild dauert Sekunden). Test: Groß-/Kleinschreibung und Leerzeichen bleiben korrekt; EXPLAIN auf dem Dev-Dump zeigt `ref`. | S | keine |
| 3.2 | `ReleaseDetailPageData.php:76-80` und `press-kits/show.blade.php:273-306`: `withoutGlobalScopes()` durch `withoutGlobalScope(PortalScope::class)` ersetzen. Test: gelöschter Kontakt erscheint weder öffentlich noch im Pressefach. | S | keine |
| 3.3 | Dasselbe für API v1 (`PressReleaseController.php:36,238,257`, `CompanyController.php:17,34`), Vorschau (`PressReleasePreviewController.php:33-42`), Medien-Controller und `PressRelease.php:194,207,219,430,440,730`. Den Test `PressReleasePreviewTest.php:237-247` auf `delete()` umstellen. Test je Modell: PM, Firma, Kontakt. | M | 3.2 |
| 3.4 | Firmen-Observer oder Aufruf in `companies/edit.blade.php`: `WebCaches`-Flush bei `delete()` und bei Wechsel von `is_active`. Kontakt-Speichern und -Löschen im Pressefach flushen den Newsroom. `NewsroomDetailPageData.php:41` prüft vor dem Cache-Treffer auch `is_active` und `deleted_at`. Test: Cache warm, Firma deaktivieren, nächster Aufruf 404. `GoneTombstoneTest.php:162` so umbauen, dass der Cache vorher warm ist. | S | keine |
| 3.5 | Gemeinsamer URL-Resolver für Meldeverfahren (`PressReleaseReportController.php:325`, `ReportFormController.php:102`): Host gegen `config/domains.php` prüfen, Portal und Sprache aus Host und Pfad, dann Slug. Bei Duplikaten die kanonische PM bevorzugen (`canonical_press_release_id is null desc`, wie in `RedirectPortalOnlyPagesFromHub.php:73`). Fremder Host: kein Treffer. Test: gleicher Slug in beiden Portalen und beiden Sprachen. | M | keine |
| 3.6 | API v1: `per_page` als `integer|min:1|max:100`, `status` als `in:` über das Enum, `language` als `in:de,en`, `category_id` zusätzlich `is_active` und Portal-Match. Test: `per_page=-1`, `status[]=x` liefern 422. | S | keine |
| 3.7 | `PressReleaseAttachmentStorage.php:66` und `ImageService.php:85`: Rückgabewert von `put()` prüfen, bei `false` Exception. Logo-Tausch in `press-kits/show.blade.php:153` und `companies/edit.blade.php:157`: erst speichern, dann DB, dann altes Logo löschen. Test mit gemocktem Storage, der `false` liefert. | S | keine |
**Abnahme:** Kein `withoutGlobalScopes()` ohne Argument mehr außerhalb von Admin-Verwaltung und Importern (Architekturtest in Pest, der die Trefferliste einfriert). Login-Pfad ohne Vollscan.
---
## Block 4 — KI-Prüfung und Scheduler (F07, F18, F20, F17, F19)
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| 4.1 | `ClassifyPressRelease` und `ScorePressRelease` bekommen den `content_hash` zum Zeitpunkt der Einplanung im Konstruktor. Vor dem Speichern Vergleich mit dem aktuellen Hash; bei Abweichung Ergebnis verwerfen, ins `ki_audits` protokollieren, neuen Lauf einplanen. Hash ebenfalls in `ki_audits` speichern. | M | keine |
| 4.2 | `PressReleaseService::publish()` und `PublishScheduledPressReleases` prüfen, dass `classified_at` nach der letzten Inhaltsänderung liegt oder der Audit-Hash zum aktuellen passt; sonst nicht veröffentlichen, sondern neu prüfen. Wenn der Re-Check einer terminierten PM rot wird, Kunde und Admin benachrichtigen statt still überspringen. Test: Text während der Analyse ändern, altes Ergebnis darf nicht freigeben. | M | 4.1 |
| 4.3 | Kontingent periodisieren: Spalte `users.press_release_quota_period` (`YYYY-MM`). `consumePublishSlot()` und `pressReleaseQuotaRemaining()` setzen den Zähler auf null, wenn die Periode nicht dem aktuellen Monat entspricht. Der Cron wird zum Aufräumer, nicht zur einzigen Wahrheit. Test: Periode wechselt ohne Cron, doppelter Reset löscht nichts. | M | E5 |
| 4.4 | `PublishScheduledPressReleases.php:117`: `FAILURE`, wenn `$failed > 0`. Fachliche Ablehnungen (Blacklist, rot) bleiben `SUCCESS`. Test auf Exit-Code. | S | keine |
| 4.5 | `ScorePressRelease`: `public int $timeout = 120`. `retry_after` in `config/queue.php` auf 150. Runbook ergänzen. | S | keine |
| 4.6 | `AggregatePressReleaseViews implements Isolatable`, Schedule mit `--isolated`. | S | keine |
**Entscheidung**
- **E5** Kontingent bleibt an den Kalendermonat gebunden (empfohlen, entspricht dem heutigen Verhalten) oder wechselt auf die Stripe-Abo-Periode. Nur Ersteres ist in diesem Plan.
---
## Block 5 — MAN-Abrechnung (F06, F16)
Beide latent. Aufnehmen, weil die Fixes klein sind und der Fehler beim ersten Datensatz mit Vertragsende ohne Vorwarnung eintritt.
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| 5.1 | `ManualInvoiceService::duePaymentOptions()` und die Sperr-Nachprüfung berücksichtigen `grandfathered_until`: keine Rechnung für eine Periode, die nach dem Vertragsende beginnt. Test: gestern beendet, heute endend, unbefristet. | S | E6 |
| 5.2 | Ablauf-Befehl im Scheduler: Vereinbarungen mit überschrittenem `grandfathered_until` auf beendet setzen und `GrandfatheringTerminated` senden. Heute passiert das nur manuell. | S | 5.1 |
| 5.3 | `GenerateManualInvoices`: nicht abrechenbare Vereinbarungen aus dem Fenster ausschließen (Zustandsmarke `billing_hold_reason` an der Vereinbarung) und in Seiten weiterlaufen, bis kein abrechenbarer Kandidat mehr da ist. Die Admin-Zahlungsseite zeigt die Hold-Fälle bereits; nur die Marke fehlt. Test: 50 unvollständige vor einer vollständigen. | M | keine |
**Entscheidung**
- **E6** Verhalten am Vertragsende: letzte Teilperiode wird noch abgerechnet oder nicht. Empfehlung: keine Rechnung mehr, sobald `current_period_end` nach `grandfathered_until` liegt.
---
## Block 6 — Entwicklung und Qualitätsgates (F24, F27, F28, F26, F25)
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| 6.1 | `lint.yml`: `vendor/bin/pint --test` statt Schreibmodus, `contents: write` entfernen, auskommentierten Commit-Schritt löschen. | S | keine |
| 6.2 | `composer.json` Script `dev`: `npm run dev:all` statt `npm run dev`. | S | keine |
| 6.3 | `docs/README.md`: Stand-Absatz und toten Link auf `dev/archive/frontend/hub-flux/PROGRESS.md` korrigieren, Hinweis „historisch, aktueller Einstieg ist `dev/README.md`“. Kein Umbau der Doku. | S | keine |
| 6.4 | Login-Modal in `site-header.blade.php:190`: Fokus beim Öffnen auf das erste Element, Rückgabe beim Schließen, Tab-Begrenzung. Ohne neue Abhängigkeit als kleine Alpine-Direktive; alternativ `@alpinejs/focus` (Abhängigkeit, Freigabe nötig). | S | keine |
| 6.5 | Architekturtest: `withoutGlobalScopes()` ohne Argument nur in einer Whitelist erlaubt (schließt Block 3 ab). | S | 3.3 |
| 6.6 | Aufgeschoben: MySQL-/Worker-Integrationstests als zweite CI-Stufe. Sinnvoll erst nach Block 2 und 4, wenn es konkrete Sperr- und Timeout-Fälle zu beweisen gibt. | L | 2, 4 |
---
## Block 7 — Nacharbeit aus dem Umsetzungsaudit vom 18.09.2026
Grundlage: `UMSETZUNGSAUDIT-2026-09-18.md` (13 Restbefunde U01–U13, Abschnitt 7 mit kleineren Punkten, Abschnitt 8 Betriebsnachweise). Am 18.09.2026 gegen `fb03fb1` geprüft.
### Abgleich der Restbefunde
| ID | Bewertung | Befund im Code | Aufnahme |
|---|---|---|---|
| U01 | bestätigt, aber Entscheidungsfrage | `PressReleasePolicy::canManage()` erlaubt jedem Firmenmitglied Bearbeiten, Einreichen, Löschen und Korrektur der Firmen-PMs; `member` ist im System keine Leserolle, sondern die normale Teamrolle. Die Rückstufung nach E2 nimmt deshalb nur die Firmenstammdaten weg. | 7.13, wartet auf E7 |
| U02 | bestätigt | Beide Jobs vergleichen den Hash per separatem SELECT und speichern danach ohne Bedingung; `publish()` prüft unter der Sperre keine Klassifikation. Fenster zwischen Vergleich und Schreiben sowie zwischen Scheduler-Prüfung und Sperre. | 7.3 |
| U03 | bestätigt | `reclassifyIfClassified()` läuft mit `route: false`; ein rotes Ergebnis bleibt ohne Statuswirkung, der Scheduler wählt nur Grün/Gelb. Terminierte rote PM hängt still in Review. | 7.4 |
| U04 | bestätigt | Lesen zählt `period = NULL` als laufenden Monat, `consumePublishSlot()` setzt bei NULL auf 1 statt +1. Dev-Kopie: zwei Nutzer mit Altverbrauch. | 7.2 |
| U05 | bestätigt, aufgeschoben | Kampagne wird Sent, sobald keine offene Zeile mehr da ist, auch bei nur Failed; `newsletter:resume` verlangt Sending. | E9, nicht jetzt |
| U06 | bestätigt, aufgeschoben | Harter Abbruch beim Einfrieren lässt Sending mit Teilbestand zurück; Resume ergänzt die Auswahl nicht. | E9, nicht jetzt |
| U07 | bestätigt, aufgeschoben | `RecordMailActivity` fragt nach der Übergabe die Datenbank; ein Fehler dort landet im Catch um `Mail::send()` und gibt den Claim frei. | E9, nicht jetzt |
| U08 | bestätigt | Bereinigungsmigration behält `MIN(id)` ohne Blick auf den Status. Dev-Kopie: 470 Gruppen, keine mit späterem Sent-Nachweis, Live nicht geprüft. | 7.1 |
| U09 | bestätigt | Fortify registriert `two-factor.enable/confirm/disable/qr-code/secret-key/recovery-codes/regenerate-recovery-codes` mit `password.confirm` (drei Stunden gültig). Die Sicherheitsseite ruft die Fortify-Actions direkt auf, nutzt die Routen also nicht. | 7.5 |
| U10 | bestätigt | Update prüft die Kategorie nur bei mitgesendeter `category_id`; ein reiner Portalwechsel behält eine portalfremde Kategorie. | 7.6 |
| U11 | bestätigt | Update im Ablauf-Command filtert nur `id` und `cancelled_at`; Ereignis und Mail hängen nicht an der tatsächlich geänderten Zeile. | 7.7 |
| U12 | bestätigt | Mail wird nach dem Commit eingeplant; ein Fehler dazwischen ist nicht nachholbar. Produktion nutzt die Datenbank-Queue, ein Marker macht es unabhängig vom Treiber. | 7.7 |
| U13 | bestätigt | Lock 600 s fest, Schleife ohne Zeitbudget; `AppSetting::setValue()` schreibt die Marke unbedingt. | 7.8 |
| 7.1 | teilweise | `--classify` schreibt jede erklärbare Zeile ohne Vorschau und ohne Datumsgrenze um; `ConfirmEmailChangeController` stößt keinen Sync an. | 7.10 |
| 7.2 | bestätigt | `PressReleasePolicy::before()` und `CompanyPolicy::before()` fragen nur `is_super_admin`. Über Panel- und API-Middleware ist ein inaktives Konto zwar draußen, die Policy sollte es trotzdem nicht bejahen. | 7.9 |
| 7.3 | richtig, nicht aufgenommen | Der Fingerprint deckt Titel und Text ab, der Score liest weitere Felder. Der Score hat keine Statuswirkung; eine eigene Bewertungsversion ist Aufwand M ohne Fehlerfolge. Notiz bleibt. | — |
| 7.4 | teilweise | Whitelist hat 40 Einträge, Protokoll nannte 39. Die Grenze der Textsuche ist bekannt und dokumentiert. | 7.12 |
| 7.5 | bestätigt | `ReleaseDetailPageData` lädt mit `first()` ohne die Sortierung des Resolvers; bei Doppelveröffentlichung mit gleichem Slug könnten Seite und gemeldeter Datensatz auseinanderfallen. | 7.11 |
| 7.6 | offen, Nutzer | Browsernachweis für die Fokusführung; Cmd/Ctrl+K bei offenem Dialog. Manuell abnehmen. | — |
| 7.7 | bestätigt | Protokoll ist an vier Stellen weiter gefasst als der Code (`--dry-run`, 1.8, „ohne Tageskappe“, „nur 6.6 offen“). | 7.12 |
| 8.x | Betrieb | Die Dev-Kopie war noch nicht migriert; die Migrationen werden hier eingespielt und damit auf MariaDB geprüft. Die Live-Schritte bleiben wie dokumentiert. | Live-Schritte |
Nicht aufgenommen aus Block 7: U05–U07, weil das Kampagnenmodul nicht in Betrieb ist (siehe E9); 7.3 als Notiz; 7.6 als manuelle Abnahme.
### Arbeitspakete
| Nr. | Punkt | Aufwand | Inhalt |
|---|---|---|---|
| 7.1 | U08 | S | Bereinigungsmigration behält je Paar zuerst eine `sent`-Zeile, sonst die älteste. Test mit Pending→Sent, Failed→Sent, Sent→Sent. |
| 7.2 | U04 | S | Migration setzt `press_release_quota_period` auf den laufenden Monat, wo Verbrauch > 0 und Periode NULL. `consumePublishSlot()` behandelt NULL wie den laufenden Monat (+1). Test: Altzähler 2 von 3, nächste Veröffentlichung ergibt 3 von 3. |
| 7.3 | U02 | M | Jobs speichern Ergebnis und Audit nur per bedingtem UPDATE `where content_hash = analysierter Hash`; trifft es nicht, Audit als verworfen und neuer Lauf. `publish()` prüft bei automatischen Quellen (`ki`, `scheduler`) unter der Zeilensperre, dass die jüngste gültige Klassifikation den aktuellen Hash trägt; sonst kein Publish, neuer Prüfjob mit Routing, `StaleClassificationException`. Admin-Publish bleibt manuelle Übersteuerung. |
| 7.4 | U03 | S | Rotes Ergebnis eines Re-Checks ohne Routing bei einer PM in Review führt zur Ablehnung mit Autorenmail wie bei der Erstprüfung. Veröffentlichte PMs bleiben unverändert (Admin-Signal). |
| 7.5 | U09 | S | Middleware sperrt die sieben ungenutzten Fortify-2FA-Verwaltungsrouten (404); Challenge-Routen bleiben. Test je Route mit passwortbestätigter Session. |
| 7.6 | U10 | S | API-Update prüft die resultierende Kategorie gegen das resultierende Portal beim tatsächlichen Portalwechsel oder bei mitgesendeter `category_id`; ein erneut gesendetes, unverändertes Portal löst allein keine Prüfung aus (Präzisierung 25.09.2026, Umsetzungsaudit 5.3). |
| 7.7 | U11, U12 | M | Bedingtes UPDATE mit Status, Enddatum und `cancelled_at`; nur getroffene Zeilen erzeugen Ereignis und Mail. Marker `grandfathering_expired_at` und `grandfathering_notified_at`; Mail-Einplanung in zweitem Schritt, nachholbar beim nächsten Lauf. |
| 7.8 | U13 | S | Zeitbudget 480 s pro Lauf (Lock 600 s), Rest beim nächsten Lauf. Marke nur bedingt fortschreiben (`where value = alte Marke`), sonst Transaktion abbrechen. |
| 7.9 | 7.2 | S | Policy-`before()` bejaht nur aktive Super-Admins. |
| 7.10 | 7.1 | S | `--classify` zeigt vorher die betroffenen Zeilen (Anzahl, Stichprobe) und nimmt mit `--before=` nur Pivots vor dem Deploy. `ConfirmEmailChangeController` stößt den Sync an. |
| 7.11 | 7.5 | S | `ReleaseDetailPageData` sortiert wie der Resolver (Erstquelle zuerst, dann ID). |
| 7.12 | 7.4, 7.7 | S | Protokoll korrigieren: 40 Ausnahmen, `--classify` statt `--dry-run`, 1.8 teilweise, Rechnungslauf mit Kappe 500 pro Lauf, E6 Gleichheitsfall. |
| 7.13 | U01 | M | Nach E7: automatische Zuordnung beim Wegfall der Grundlage entfernen statt senken; neuer Pivot-Wert `auto-upgrade` für hochgestufte manuelle Mitgliedschaften. |
**E6, Gleichheitsfall:** Endet der Vertrag genau am letzten Tag der Periode (`grandfathered_until = current_period_end`), wird die Periode noch berechnet. Das ist die Regel „Rechnung, wenn die Periode nicht über das Vertragsende hinausreicht“. Bestätigt am 18.09.2026 mit E6.
---
## Block 8 — Nacharbeit aus dem Umsetzungsaudit vom 25.09.2026
Grundlage: [`UMSETZUNGSAUDIT-2026-09-25.md`](UMSETZUNGSAUDIT-2026-09-25.md) (22 Befunde: 4 × P1, 18 × P2, dazu Abschnitt 5 mit Optimierungen). Jeder Befund wurde am 25.09.2026 am Code gegengeprüft, bevor etwas geändert wurde; Plan Fassung 2 und Decision-Update vom 22.09. wurden auf abweichende Entscheidungen durchgesehen. Ergebnis: **alle 22 Befunde bestätigt, keiner war anders entschieden.** Zwei Befunde (N13, 5.1 a) sind Lücken zwischen einer eigenen Entscheidung (O1, E-B10) und ihrer Umsetzung.
### Abgleich
| Nr. | Ergebnis | Umsetzung |
|---|---|---|
| N01 (P1) | bestätigt | Gemeinsamer Freigabezustand `PressReleaseImage::isPubliclyDeliverable()` (nicht zurückgehalten; v2 nur mit Rechtebestätigung; Bestand `rules_version` NULL bleibt sichtbar), Scope gleichlautend; Medienroute wendet ihn an, nicht freigegebene Bilder nur mit Signatur/`view`-Recht und `private, no-store`. Cache-Lebensdauer öffentlicher Medien unverändert (E17). |
| N02 (P1) | bestätigt | `tombstone()` liest den Eintrag unter `lockForUpdate` neu und prüft Hold, Frist und Tombstone erneut; Hold setzen/aufheben in Transaktion mit Sperre, Hold auf Tombstone wird abgelehnt. |
| N03 (P1) | bestätigt | Bedingtes UPDATE, `syncOriginal` und Audit-INSERT in einer Transaktion (Klassifikation und Score); Re-Dispatch erst nach Commit. Keine neue Regel „ohne Audit-Hash kein Auto-Publish" (E11). |
| N04 (P2) | bestätigt | Erwarteter Hash bis in `reject()`/`holdForManualReview()` unter der Sperre; Abweichung → `StaleClassificationException` + Re-Check für den aktuellen Stand (`rescheduleForCurrentContent()`); Vorprüfung in `redRecheckMustReject()`. |
| N05 (P2) | bestätigt | Eigene Ability `confirmImageRights` (nur Kontoinhaber, auch bei Published; Super-Admin-Kurzschluss in Policy und globalem `Gate::before` ausgenommen); Service prüft Identität und ausstehende Bestätigung selbst (E10). |
| N06 (P2) | bestätigt | Livewire-Komponenten tragen die gezeigten Fassungen (`legalAcceptanceVersions` per Trait-Hook `rendering`, `legalTextKey`/`imageLegalTextKey` als `#[Locked]`); Abweichung beim Absenden → Ablehnung, neuer Wortlaut wird gezeigt. Fehlendes Pflichtdokument bei aktivem Gate ist ein Konfigurationsfehler. |
| N07 (P2) | bestätigt | `store(..., allowedStatuses)`: Status-Regel des Aufrufers wird unter der PM-Sperre erneut geprüft; nach Kundenbestätigung an veröffentlichter PM folgt der Veröffentlichungssnapshot. |
| N08 (P2) | bestätigt | Cache-Invalidierung nach dem Commit außerhalb der Dateibereinigung; Fehler nur `report()`. |
| N09 (P2) | offen | Snapshot-Deduplizierung nur über Bildbytes, `put()`-Ergebnisse ungeprüft — **noch nicht umgesetzt** (siehe unten). |
| N10 (P2) | bestätigt | `delete()`/`put()` ausgewertet; Tombstone erst nach bestätigter Löschung; `purgeDue()` liefert `{due, purged, failed}`; Export über `.tmp` + `move`; Commands enden mit FAILURE bei Fehlern (Scheduler-Alarm greift). |
| N11 (P2) | bestätigt | `record()` schreibt `.tmp` → Zeile → `move`; Marker `press_releases.archive_pending_at` in der fachlichen Transaktion, `archive:retry-pending` (03:35) holt `pm_depublished`/`gdpr_change`/`image_removed` nach, `archive:sweep-orphans` (03:40) räumt Dateien ohne Zeile; Anonymisierung/Korrektur archivieren aus dem Redaction-Snapshot; Systemstatus zeigt offene Marker. `deleteFromAdmin` bleibt blockierend (E15). |
| N12 (P2) | bestätigt | Anonymisierung entfernt Bilder nach dem Commit über `PressReleaseImageRemover` (`image_removed`, Grund `gdpr`, Antrag referenziert); lokale Beweisdateien danach weg, Pfade an der Zeile geleert. |
| N13 (P2) | bestätigt (Lücke zu O1) | `press-releases:purge-drafts` erfasst zusätzlich `rejected` mit `published_ever_at` NULL: gleiche Frist ab letzter Änderung, nur Medien, Status bleibt (E12). Altbestand mit früherer Veröffentlichung unangetastet. |
| N14 (P2) | bestätigt | Phase 2 je Kunde in Transaktion: Posten sperren, Marker per bedingtem UPDATE beanspruchen, Mail in derselben Transaktion einplanen (DB-Queue, E18). |
| N15 (P2) | bestätigt | Kohorten halboffen `[lower, upper)`, lückenlos; Kohortensumme = Segment. Basiswert 0.1 bleibt mit Fußnote stehen. |
| N16 (P2) | bestätigt | `byReader()` gruppiert nach Art, Schlüssel, Portal und Sprache wie `topFeeds()`. |
| N17 (P2) | bestätigt | `sitemap_role` (`source`/`copy`/`none`) und `sitemap_portals` aus derselben Regel wie die Sitemaps; Kundenansicht mit drittem Zweig für Kopien. |
| N18 (P2) | bestätigt | Nenner und Zustände auf derselben Menge (`visibleOn()`); jüngster Check über `max(id)`. |
| N19 (P2) | bestätigt | `reassignCopiesOf()` bei Depublikation, Gone, Löschung und Admin-Statuswechsel; Erstquelle nur ohne Soft-Delete-Umgehung aufgelöst; Admin-Republish ordnet zu. |
| N20 (P2) | bestätigt | Strikte Schemaprüfung im OpenAI-Treiber (Objekt je Kategorie, echtes Bool, endliche Konfidenz 0…1) → Zustand `error`; `normalize()` wertet nur `hit === true`. |
| N21 (P2) | bestätigt | Marke ungecacht (`AppSetting::getValueFresh()`) in Aggregation und Purge; Cache im Konfliktfall verworfen; `setValue()` löscht den Cache erst nach Commit. |
| N22 (P1) | bestätigt | Guard in `Tests\TestCase::setUpTraits()`, also vor `RefreshDatabase`; phpunit.xml pinnt die Feature-Flags. |
| 5.1 a | bestätigt | Beim Tombstone eines `image_removed`-Eintrags werden Bestätigungen, Korrekturen und die weich gelöschte Bildzeile hart gelöscht (E13). |
| 5.1 b | bestätigt | `label_corrected` nur für veröffentlichte Bilder (E14). |
| 5.1 c | teilweise | Abgleich nach Restore läuft zusätzlich über alle DB-Tombstones; externes Exportziel nicht gebaut (E16), Runbook-Hinweis. |
| 5.1 d | bestätigt | `archive_entries.legal_request_id`, Activity-Meta trägt `archive_entry_id`, Archivseite filtert nach Vorgang. |
| 5.1 e | bestätigt | `App\Support\FileDeletion` (Ergebnis + `exists()`); nie veröffentlicht → Exception in der Transaktion, veröffentlicht → Log + Systemstatus-Zähler. |
| 5.2 Kategorienfeed | bestätigt | Kategorie-Admin leert die Seiten-Caches bei Änderung von Sichtbarkeit, Portal, Eltern oder Slug. |
| 5.2 Dry-Run | bestätigt | `purge-events --dry-run` zählt Crawler- und Feed-Zeilen mit. |
| 5.2 übrige | offen | Synchroner Bildcheck (Zeitbudget), Dekodieraufwand, Report-Skalierung, OG-Cache bei Korrektur: Messung bzw. Betreiberentscheidung, nicht Teil dieser Nacharbeit. |
| 5.3 | erledigt | Audit, Decision-Update und Plan Fassung 2 versioniert, Antwortkopie entfernt; U10-Formulierung präzisiert. Browser-/Tastaturabnahme und MySQL-/Worker-CI bleiben offen. |
### Live-Schritte für Block 8
1. `php artisan migrate --force` — zwei nullable Spalten (`archive_entries.legal_request_id`, `press_releases.archive_pending_at`), INSTANT, Sekunden.
2. `php artisan schedule:list` — neu `archive:retry-pending` 03:35 und `archive:sweep-orphans` 03:40 (beide mit Alarm bei Fehlschlag).
3. `php artisan press-releases:purge-drafts --dry-run` — vor dem ersten Wochenlauf prüfen, dass nur `rejected`-Meldungen **ohne** `published_ever_at` erfasst werden (10.463 „offline genommen"-Altfälle müssen draußen bleiben).
4. `php artisan archive:retry-pending --dry-run` und `archive:sweep-orphans --dry-run` einmal ansehen; Systemstatus zeigt offene Marker und lokale Beweisreste.
5. `view:cache`, `config:cache`, `queue:restart`, FPM-Neustart wie üblich.
**Nicht in dieser Nacharbeit:** N09 (Snapshot-Versionierung nach Credit-/KI-Korrektur und `put()`-Prüfung im Snapshot) — eigener Schritt, weil die Snapshot-Datei damit versioniert werden muss (Bildbytes plus Metadaten) und der Neuaufbau bei Korrektur mit der OG-Cache-Frage (5.2) zusammenhängt.
## Block 9 — Umsetzung des Umsetzungsaudits vom 29.09.2026
Grundlage: [`UMSETZUNGSAUDIT-2026-09-29.md`](UMSETZUNGSAUDIT-2026-09-29.md) (4 × P1, 22 × P2, P3-Liste, OPT-1 bis OPT-9). Auftrag des Betreibers vom 29.09.2026: Block 9 vollständig und die kleinen Punkte (P3, OPT) mit umsetzen. Umgesetzt am 29.09.2026 in vier parallelen Gruppen mit getrennter Dateihoheit (Rechte/KI/Jobs, Bild-Pipeline, Archiv, SEO/Berichte); Integration, Qualitätsgates und Doku im Anschluss. Jeder Befund zuerst als roter Regressionstest, dann der Fix.
### Stand je Befund
| Schritt | Befunde | Stand | Kern der Umsetzung |
|---|---|---|---|
| 9.1 | R-01, K-04 | erledigt | API-Zuordnung zu bestehender Firma nur für Owner/Mitglieder oder Allowlist (`NEWSROOM_API_ASSIGNMENT_USER_IDS`), Pivot `granted_via = api`, Befehl `company-memberships:api-assignments` (Liste, `--remove` mit Vorschau). OAuth-Verifikation eines offenen Kontos: Passwort `null`, `remember_token` neu, Sanctum-Tokens weg, alle Sessions widerrufen (`SessionRevocation` + Middleware `EnsureSessionIsNotRevoked`, weil Redis-Sessions nicht je Nutzer aufzählbar sind) |
| 9.2 | K-01, B-01, B-02, B-03 | erledigt | Scheduler und `publish()` (Quellen `ki`/`scheduler`) respektieren `manual_review_required_at` (`ManualReviewRequiredException`); Feed, Card-Helfer und Nachweis-PDF nur mit `isPubliclyDeliverable()`; Livewire-Temp-Upload 16 MiB |
| 9.3 | R-02/K-03, R-03, N19-Rest | erledigt | Aggregation und Merge ohne `updated_at`; neue Spalte `press_releases.content_changed_at`, `PressRelease::contentModifiedAt()` für lastmod, `dateModified`, `article:modified_time`; Sitemap und Legacy-`view_url` mit `/en/` für englische Meldungen; `archive()` gibt Kopien frei |
| 9.4 | A-01, A-02, A-03 | erledigt | Auftragstabelle `archive_pending_jobs` (`ArchivePendingQueue::push()`/`completeWith()`), `archive_pending_at` nur noch Sammelmarker; Nachholer ohne Schlüssel FAILURE; Sweep verschiebt in Quarantäne (30 Tage), Pause per `--pause`/`--resume` bzw. automatisch beim Restore-Abgleich, `archive:reregister` aus `manifest.json` |
| 9.5 | N09, B-04 bis B-07, A-04, A-06 | erledigt | Snapshot versioniert (Bytes- plus Metadaten-Hash, OG-Variante enthalten, `put()` geprüft, alle Fassungen ins Archiv); Bestätigung an Bild-ID und SHA gebunden; Entfernen unter Sperre mit frischem Stand; Korrektur archiviert über Auftrag; `precheck_withheld_at` getrennt von `withheld_at`; Depublikationsstand am Auftrag; Restore-Abgleich löscht Anwendungskopien |
| 9.6 | K-02, R-04, R-05, E9-Schalter | erledigt | Kontingent-/Bildsperre im Scheduler als „blockiert" (Exit 0, einmaliger Verlaufseintrag), KI-Job ohne Retry; nach Kundenbestätigung erneut routen; `X-Pk-Ref` per HMAC signiert, Hard-Sperre nur bei gültiger Signatur; Unmerge setzt nur unveränderte Füllwerte zurück (`company_merge_moves.merged_value`); `NEWSLETTER_SENDING_ENABLED` (Standard aus) |
| 9.7 | Q-01, Q-02, Q-06, P3, OPT | erledigt bis auf Q-05 | Testhelfer nach `tests/Pest.php` (Suite läuft parallel); Larastan voller Scope ohne Meldung; P3-Liste und OPT-1 bis OPT-9 umgesetzt (OPT-4 teilweise, Integritätshash bleibt bewusst); Q-07 war ein Fehlalarm (`IMAGE_PRECHECK_MODEL` steht auskommentiert in `.env.example`). Q-05 entschieden am 05.10.2026: `docs/` ist der per Devcontainer eingebundene Obsidian-Vault und kommt aus dem Repository (`.gitignore`), die Historie bleibt. |
Zusätzlich bei der Integration: `press-release-images:sweep-orphan-originals` wöchentlich (So 04:45) im Scheduler, löscht nichts, solange der Archiv-Sweep pausiert ist; `purge-events` von 03:20 auf 03:50; alle `withoutOverlapping()` mit kurzer Sperrdauer (`ScheduleConfigurationTest`); Scheduler-Tabelle im Runbook vollständig. **Im Runbook stand seit `665c94f` (21.07.2026) ein Passwort neben dem KAS-SSH-Zugang** — aus der Arbeitskopie entfernt; in der Git-Historie und auf `origin` bleibt es, deshalb rotieren (Betreiber). **Erledigt 06.10.2026:** SSH-Zugang des Altsystems geändert und deaktiviert (Betreiber).
### Entscheidungen (bei der Umsetzung getroffen, **vom Betreiber am 02.10.2026 bestätigt**, einschließlich der Revision von E25)
| Nr. | Befund | Entscheidung |
|---|---|---|
| E19 | R-01 | Allowlist per `.env`, Standard leer; ohne Eintrag verhält sich connektar wie vor 1a.4 (neue Firma je Einlieferung). Live: connektar = User 13993 |
| E20 | K-04 | Passwort bei OAuth-Verifikation eines offenen Kontos verwerfen; Anmeldung danach über Google, Magic Link oder Passwort-Reset |
| E21 | K-02 | Keine Autorenmail bei Kontingent- oder Bildsperre (keine passende Vorlage; Bildbestätigung wurde schon angefordert), nur Verlaufseintrag und Log |
| E22 | K-08 | Google-Login ohne erzwungenes „Angemeldet bleiben"; `magic-login` ohne `guest`, fremde Links wie bisher |
| E23 | E9 | Newsletter-Versand per Schalter aus, bis U05–U07 umgesetzt sind |
| E24 | R-02 | `content_changed_at` ohne Backfill; Fallback `published_at` |
| E25 | R-04 | ~~Unsignierte Rückläufer im Übergang `soft`, nach etwa 14 Tagen auf `policy`~~ — **revidiert 02.10.2026 (O01):** jeder nicht verifizierte Rückläufer, hart wie weich, nur zur Sichtung (`BOUNCE_UNSIGNED_KIND=policy`); `soft` nur als bewusste, fälschbare Ausnahme |
| E26 | R-09 | Claim-Links je Firma und Tag 5 statt 3, gezählt erst nach Versand |
| E27 | R-10 | `email_bounces` 395 Tage wie DSE 5a; die Sperrliste selbst bleibt. DSE erwähnt Rückläufer noch nicht (Betreiber/Anwalt) |
| E28 | R-11 | Canonical-Portal über Kategorie-ID (1–14), Slug-Liste als Rückfall |
| E29 | A-02 | Auftragstabelle statt Zeitstempel-Marker |
| E30 | A-03 | Quarantäne 30 Tage statt Löschen |
| E31 | A-15 | Schlüsselrotation über `ARCHIVE_PREVIOUS_KEYS`, Export-HMAC per HKDF; alte Exporte bleiben prüfbar |
| E32 | B-05 | Entfernen eines veröffentlichten Bildes blockiert bei Archivfehler nicht (E15-Muster), Auftrag bleibt offen, Beweisdateien bleiben liegen |
| E33 | B-09 | Admin als Eigentümer der Meldung bekommt im Bildmanager die Kundenregel |
| E34 | B-10 | Eigener Magic-Link-Zweck `image_confirmation`, 72 h, einmalig |
### Deploy-Plan für die Blöcke 9 und 10
Gilt für beide Blöcke zusammen (Block 10 hat keine eigene Migration). In Klammern die Entscheidung bzw. der Befund, aus dem der Schritt folgt.
**A — Vor dem Deploy**
1. `.env` auf Live ergänzen:
- `NEWSROOM_API_ASSIGNMENT_USER_IDS=13993` — connektar, auf Live gegenprüfen (`select id from users where email = 'info@connektar.de'`). Ohne Eintrag legt connektar wieder je Einlieferung eine neue Firma an (E19).
- `NEWSLETTER_SENDING_ENABLED=false` — bleibt aus, bis U05–U07 umgesetzt sind (E23).
- `BOUNCE_UNSIGNED_KIND=policy` — unsignierte Rückläufer nur zur Sichtung (E25 revidiert, O01).
- Optional, Standardwerte greifen: `BOUNCE_SIGNING_KEY` (leer = aus `APP_KEY`), `BOUNCE_RETENTION_DAYS` (395, E27), `ARCHIVE_PREVIOUS_KEYS` (nur bei Schlüsselrotation, E31), `ARCHIVE_QUARANTINE_DAYS` (30, E30), `ARCHIVE_SWEEP_PAUSED`, `CONVERSION_MAIL_CLICK_THROTTLE_MINUTES` (30), `NEWSROOM_CLAIM_LINKS_PER_DAY` (5, E26).
2. Hauptkategorien prüfen: `select id, slug from category_translations where locale = 'de' and category_id between 1 and 14` muss zu `config/canonical.php` → `portal_by_category_id` passen (E28). Weicht etwas ab, vor dem Deploy die Config anpassen — sonst ändert sich die Canonical-Zuordnung still.
3. Uploadgrenzen: PHP `upload_max_filesize` und `post_max_size` sowie nginx `client_max_body_size` ≥ 16 MiB mit Puffer, z. B. 20M (B-03).
4. DB-Backup ziehen; `ARCHIVE_KEY` liegt getrennt gesichert vor.
**B — Deploy**
5. Dateien hochladen (Branch `technik/betriebsfixes-2026-09`).
6. `php artisan migrate --force` — acht Migrationen, alle additiv: `press_releases.content_changed_at` (INSTANT, E24), `company_merge_moves.merged_value`, `press_release_images.precheck_withheld_at` + Übernahme offener Sichtungen, Magic-Link-Zweck `image_confirmation` (E34), `archive_pending_jobs` (E29), `archive_entries.key_id` + Index `payload_path` (E31), `archive_access_log.admin_name/-email`. Dev-Kopie (MySQL): 0,7 s gesamt.
7. `php artisan config:cache`, `php artisan view:clear && php artisan view:cache`, `php artisan schedule:clear-cache` (alte 24-h-Sperren von `withoutOverlapping`), `php artisan queue:restart`, FPM-Neustart.
**C — Direkt nach dem Deploy**
8. `php artisan archive:retry-pending` einmal von Hand — vorhandene Altmarker werden zu Aufträgen `legacy_marker` und laufen einmal (E29). Danach im Admin-Systemstatus die Kacheln `archive_key`, `archive_pending`, `archive_sweep` grün.
9. `php artisan company-memberships:api-assignments` sichten; nur bei Treffern ohne Freischaltung `--remove` (zeigt vorher die Zeilen, E19).
10. `php artisan press-releases:assign-canonicals` — setzt veraltete Verweise zurück und leert die Caches (R-06, E28).
11. `php artisan press-release-images:sweep-orphan-originals` einmal als Trockenlauf; ab dann läuft er sonntags 04:45 scharf (B-11).
12. `php artisan archive:sweep-orphans --dry-run` — auf Live war der Sweep am 28.09. leer; Funde wandern jetzt in die Quarantäne statt gelöscht zu werden (E30).
13. `php artisan schedule:list` — 19 Tasks, darunter `press-release-images:sweep-orphan-originals` So 04:45 und `press-releases:purge-events` 03:50.
**D — Abnahme auf Live (Testkonto)**
14. Anmelden per Passwort, Magic Link und Google; danach einige Seiten aufrufen — die neue Session-Middleware läuft auf jedem Request (E20, E22, K-08). Ein bereits eingeloggter Nutzer, der einen eigenen Magic Link öffnet, landet auf dem Ziel.
15. Titelbild mit 13–15 MiB über das Formular hochladen (B-03).
16. `sitemap.xml` stichprobenartig: englische Meldungen unter `/en/press-release/…` (R-03); Meldungsseite: `article:modified_time` = Veröffentlichung bzw. letzte Textänderung, nicht der letzte Aufruf (E24).
17. Admin ▸ Newsletter: Kampagne zeigt den Sperrhinweis, „Senden" ist aus (E23).
18. Admin ▸ Mail-Sperrliste nach dem nächsten `mail:fetch-bounces`: unsignierte Rückläufer erscheinen als Richtlinie mit „(unsigniert)", sperren aber nicht (E25).
**E — Danach, außerhalb des Codes**
19. ~~**KAS-Passwort rotieren**~~ — **erledigt 06.10.2026:** SSH-Passwort des Altsystems geändert, SSH-Zugang deaktiviert. Das KAS-Hosting bleibt für die E-Mail-Postfächer.
20. **Datenschutzerklärung:** Rückläufer-Protokoll (`email_bounces`, 395 Tage) in Abschnitt 5a/13 aufnehmen (E27, Betreiber/Anwalt).
21. Restore-Übung in isolierter Umgebung (DB alt/Disk neu, Disk alt/DB neu, beide alt) mit den neuen Schritten aus Runbook Abschnitt 9 (O04/O05).
22. Protokoll unten um Deploy-Datum und Ergebnisse von 8–18 ergänzen.
Keine Kundenkommunikation nötig: E20 betrifft nur offene, nie verifizierte Selbstregistrierungen; E21 erzeugt bewusst keine Mail; E33 ändert nur die Admin-Sicht.
## Block 10 — Abgleich Umsetzungsaudit vom 02.10.2026
Grundlage: [`UMSETZUNGSAUDIT-2026-10-02.md`](UMSETZUNGSAUDIT-2026-10-02.md) (2 × P1, 6 × P2) zur noch nicht committeten Arbeitskopie von Block 9. Jeder Befund am 02.10.2026 am Code gegengeprüft: **alle acht valide**, keiner durch eine dem Audit unbekannte Entscheidung gedeckt. Alle als offensichtliche Fehler behoben; einzig O01 ändert eine Entscheidung (E25).
| ID | Ergebnis | Umsetzung |
|---|---|---|
| O01 (P1) | valide — die Signaturprüfung griff nur bei `hard`; `delayed` und zu `soft` umgestufte 4.x-Berichte zählten ungeprüft, drei gefälschte DSNs sperrten jede Adresse | Jede Art, die auf eine Sperre zählt, braucht die gültige empfängergebundene Signatur. Ohne: `bounces.unsigned_kind` (`BOUNCE_UNSIGNED_KIND`, Standard `policy`, nie hart), falsch signiert: immer `policy`. Folge: auch die Freitextberichte des KAS-Relays (ohne Original-Header) sperren nicht mehr selbst, sie landen zur Sichtung |
| O02 (P1) | valide — `DB_URL` überschreibt Treiber und Datenbank erst beim Verbindungsaufbau | phpunit.xml leert `DB_URL` (env und server), der Guard in `TestCase::setUpTraits()` bricht bei gesetzter `url` ab |
| O03 (P2) | valide — `published_ever_at` setzte erst der nachgelagerte Snapshot | `PressReleaseImage::markPublishedIfNowPublic()` in der Freigabetransaktion: Kundenbestätigung, Freigabe der Vorab-Sichtung, Korrektur-Freigabe |
| O04 (P2) | valide — Restore-Abgleich fand die Zeile per ID | Zuordnung über den Payload-Pfad (UUID); gleiche ID mit anderem Payload = Konflikt, nichts angefasst, Exit ≠ 0 |
| O05 (P2) | valide — fehlendes `hmac` galt als Altformat; `reregister` ignorierte `invalid` | Unsignierte Exporte nur mit `--accept-unsigned`; `archive:reregister` bricht bei ungültigem/unsigniertem Export ab, bei fehlendem Export nur mit `--without-export` |
| O06 (P2) | valide — blockierte Meldungen belegten dauerhaft die vordere Seite | Cursor über `(scheduled_at, id)`, Limit zählt bearbeitete Meldungen, Blockaden nur auf ein Scan-Budget (10 × Limit) |
| O07 (P2) | valide — Direktweg und Nachholer starteten mit geladener Instanz | `ArchivePendingQueue::claim()` (Zeilensperre, neu lesen) in `completeWith()` und `PendingArchiveRetry::retry()`; bereits erledigt = nichts tun, kein Fehlversuch |
| O08 (P2) | valide — fehlende Snapshot-Datei wurde still ausgelassen | Datei weg: Verweis auf den `image_removed`-/`image_replaced`-Eintrag des Bildes (`archived_in`), sonst Exception, Auftrag bleibt sichtbar offen; Altbestand ohne Snapshot als `snapshot: none` gekennzeichnet |
Abschnitt 5 des Audits (Betriebsnachweise, Restore-Übung, Bildbetrieb, Fachfreigaben) bleibt offen wie dort beschrieben; Abschnitt 5.7 (veraltete „offen"-Notizen zu 1.8 und N09) ist unten im Protokoll korrigiert. Echte Parallelität (O03, O07) ist weiter nur logisch geschlossen, nicht mit zwei MySQL-Verbindungen nachgestellt (6.6).
**Runbook:** Restore-Schritte 2 und 3 um Pfad-Zuordnung, `--accept-unsigned` und `--without-export` ergänzt.
## Nicht aufgenommen
- **F21 Legacy-Import.** Befund korrekt (Vollladen der Pivot-Tabellen, kein Transaktionsschutz, Wiederanlauf ändert UUID und Slug). Der Importer läuft seit dem 16.07. nicht mehr und ist nicht im Scheduler. Falls das Altsystem noch einmal einen Delta-Lauf braucht: `importRow()` in `DB::transaction` und vorhandene PM über `(legacy_portal, legacy_id)` statt über die Importmap erkennen. Bis dahin steht nur diese Notiz.
- **Audit Abschnitt 10** (CSP, Proxy, Bildpixelgrenzen, Retention, PDF, Queue-Priorisierung). Nicht geprüft, nicht belegt. Einzelne Punkte können nach Block 6 als eigene Prüfaufträge folgen.
- **Große Volt-Dateien zerlegen.** Richtig, aber kein Fehler. Läuft nebenbei, wenn ein Block ohnehin in `press-kits/show` oder `press-releases/show` arbeitet.
---
## Reihenfolge und Deploys
Tatsächlich umgesetzt am 18.09.2026 in fünf Commits auf `technik/betriebsfixes-2026-09`. Block 3 und 4 teilen sich Dateien (User-Modell, PressReleaseService) und wurden deshalb zusammen committet.
| Deploy | Inhalt | Commit | Migrationen |
|---|---|---|---|
| A | Block 1, Punkte 1.1, 1.2, 1.6, 1.8, 1.9, 1.10 | `0ab53bf` | keine |
| B | Block 2 komplett | `a7f991f` | Unique-Index `newsletter_campaign_recipients` (räumt Doppelungen vorher weg) |
| C | Block 1, Punkte 1.3, 1.4, 1.5, 1.7 | `62202a4` | `company_user.granted_via` |
| D | Block 3 und Block 4 | `f235156` | `contacts_email_idx`; `ki_audits.content_hash/discarded_at`; `users.press_release_quota_period` (COPY, Sekunden) |
| E | Block 5 und Block 6 | `375f4df` | `user_payment_options.billing_hold_reason/-at` |
| F | Block 7 | `235d392`, `1cd9fd1`, `ea68362`, 7.13 siehe Protokoll | `user_payment_options.grandfathering_expired_at/-notified_at`; Backfill `users.press_release_quota_period` (DML, Sekunden); Newsletter-Bereinigung angepasst (noch nicht live) |
Migrationen berühren `press_releases` und `press_release_search` nicht.
### Live-Schritte nach dem Deploy
1. `php artisan migrate --force` (acht Migrationen, alle kurz; `users` als einziges COPY, nachts einspielen; die Newsletter-Bereinigung behält je Paar die Zustellzeile). Auf der Dev-Kopie am 18.09.2026 auf MariaDB durchgelaufen: 470 Doppelungen entfernt, `users` in 2 s.
2. `php artisan company-memberships:audit` – Liste sichten (Tabelle: nicht erklärbare `responsible` seit Go-Live; CSV vollständig in `storage/app`). Danach `--classify --before="<Deploy-Zeitpunkt>"`: der Befehl zeigt vorher Anzahl und Stichprobe der umzustufenden Zeilen; jüngere Zuordnungen tragen ihre Herkunft schon.
3. `php artisan schedule:list` – neuer Task `billing:expire-grandfathering` um 04:15 muss erscheinen.
4. Queue-Worker neu starten (Job-Timeouts und `retry_after` 150 greifen erst dann).
5. Kunden mit aktiver 2FA sehen auf der Sicherheitsseite ein neues Code-Feld vor „Deaktivieren“ und „Neue Codes“; E-Mail-Änderung fragt nach Passwort oder Code.
6. Nächste Newsletter-Kampagne erst nach diesem Deploy starten – und erst, wenn U05–U07 (E9) umgesetzt sind, falls das Kampagnenmodul in Betrieb geht.
7. Fortifys 2FA-Verwaltungsrouten (`/user/two-factor-…`) antworten jetzt mit 404; die Sicherheitsseite ist davon unabhängig.
## Entscheidungen
Am 18.09.2026 alle sechs nach Empfehlung entschieden. Umsetzung ab demselben Tag.
| Nr. | Frage | Entscheidung |
|---|---|---|
| E1 | Fortify-Profiländerung abschalten oder umbiegen | abschalten |
| E2 | Auto-Rechte bei Wegfall der Grundlage senken oder entfernen | auf `member` senken – **revidiert durch E7** |
| E3 | Nachweis vor Sicherheitsaktionen | TOTP- oder Recovery-Code |
| E4 | Newsletter-Versandreihenfolge fachlich relevant | nein |
| E5 | Kontingentperiode | Kalendermonat |
| E6 | Letzte MAN-Periode am Vertragsende | keine Rechnung mehr |
| E7 | U01: automatische Zuordnung bei Wegfall der Grundlage | Zuordnung entfernen (18.09.2026). `member` ist die normale Teamrolle mit PM-Schreibrechten; eigene PMs bleiben über die Autorenschaft, Eigentum, manuelle Rechte und weiterhin passende Adressen bleiben. Eine automatisch hochgestufte manuelle Mitgliedschaft fällt auf `member` zurück. |
| E8 | U09: ungenutzte Fortify-2FA-Verwaltungsrouten | abschalten, folgt aus E3 |
| E9 | U05–U07: Newsletter-Wiederaufnahme und Doppelversand | aufgeschoben, bis das Kampagnenmodul in Betrieb geht; bis dahin nur Abmeldung und Sperrliste live (Nutzer, 18.09.2026) |
Entscheidungen aus Block 8 (25.09.2026, bei der Umsetzung getroffen; **alle acht vom Betreiber am 25.09.2026 bestätigt**):
| Nr. | Frage | Entscheidung |
|---|---|---|
| E10 | N05: Wer darf die Kundenbestätigung eines Admin-Uploads abgeben? | nur der Kontoinhaber der Meldung (`press_releases.user_id`), keine Teammitglieder, kein Super-Admin — er ist der Vertragspartner (DU E1) |
| E11 | N03: Altbestand an Klassifikationen ohne Audit-Hash | keine Regel „ohne Audit-Hash kein Auto-Publish"; alle 3.387 Alt-Audits haben keinen Hash, die Transaktion schließt das Fenster |
| E12 | N13: abgelehnte, nie veröffentlichte Einreichungen | Medien nach derselben Frist wie Entwürfe (180 Tage ab letzter Änderung), Status bleibt `rejected` (Vertragsdokumentation nach DU 4.1), früher veröffentlichter Altbestand unangetastet |
| E13 | 5.1 a: Kopien außerhalb des Archivs | Bestätigungs- und Korrekturzeilen sowie die weich gelöschte Bildzeile werden mit dem Archiv-Tombstone hart gelöscht (E-B10, DSE 9a) |
| E14 | 5.1 b: Korrektur eines nie veröffentlichten Bildes | kein Archiveintrag; die Korrekturzeile dokumentiert, das Manifest beim späteren `image_removed` enthält alle Korrekturen |
| E15 | N11: Archivausfall bei Löschung/Anonymisierung | blockiert nicht (Art. 12 Abs. 5 DSGVO), Marker + täglicher Nachholer; nur `deleteFromAdmin` bleibt blockierend |
| E16 | 5.1 c: Tombstone-Export bei vollständigem Disk-Restore | externes Ziel nicht gebaut; Runbook verlangt, den aktuellen Export vor dem Abgleich einzuspielen — Betreiber entscheidet über Storage Box/S3 |
| E17 | N01: Cache-Lebensdauer öffentlicher Medien (24 h) | unverändert; ein zurückgehaltenes Bild verschwindet für neue Abrufe sofort, Browser-/CDN-Kopien laufen aus |
| E18 | N14: Idempotenz der Ablaufmail | Transaktion setzt die DB-Queue voraus (`QUEUE_CONNECTION=database`, kein eigenes `DB_QUEUE_CONNECTION`); bei Wechsel auf Redis Zwei-Spalten-Claim nötig |
## Umsetzungsprotokoll
| Datum | Punkte | Commit | Bemerkung |
|---|---|---|---|
| 18.09.2026 | 1.1, 1.2, 1.6, 1.8 (teilweise), 1.9, 1.10 | `0ab53bf` | Fortify-Profilendpunkt aus, Sync verlangt verifiziertes aktives Konto, Google-2FA (Zusammenführung der drei Login-Abschlüsse in einen Service steht aus), `#[Locked]`, Gate nur für aktive Super-Admins, Route unter `can:users:manage` |
| 18.09.2026 | 2.1–2.6 | `a7f991f` | Auf der Dev-Kopie 470 doppelte Empfängerzeilen, die Migration räumt sie weg; Zusatzbefund: Sperrlisten-Adressen galten als „gesendet“; `newsletter:resume` |
| 18.09.2026 | 1.3, 1.4, 1.5, 1.7 | `62202a4` | `granted_via`, `company-memberships:audit` (Standardaufruf ändert nichts, `--classify` schreibt; kein `--dry-run`), Entzug beim Login, TOTP-/Recovery-Nachweis; passwortlose Konten (66.000 im Bestand) können die Adresse weiter ändern |
| 18.09.2026 | 3.1–3.7, 6.5 | `f235156` | 28 Dateien auf `withoutGlobalScope(PortalScope::class)`, 40 Ausnahmen per Architekturtest eingefroren (Admin, Import, Legacy-Medien, einzelne Services); `EmailQuery` (MySQL direkt, SQLite LOWER); Firmen-Observer; `ReportedUrlResolver` |
| 18.09.2026 | 4.1–4.6 | `f235156` | Hash-Bindung in beiden Jobs und im Scheduler; Kontingentperiode; Exit-Code; Timeout 120 / retry_after 150; Aggregations-Lock |
| 18.09.2026 | 5.1–5.3 | `375f4df` | Vertragsende in Auswahl und Sperre; `billing:expire-grandfathering` mit Mail; Sperrvermerk und Seitenlauf (Blocker zählen nicht mehr, Kappe 500 erstellte Rechnungen je Lauf bleibt) |
| 18.09.2026 | 6.1–6.4 | `375f4df` | `pint --test`, `dev:all`, Doku-Einstieg, Fokusführung ohne neue Abhängigkeit |
| 18.09.2026 | 7.1, 7.5, 7.6, 7.8–7.12 | `235d392` | Fortify-2FA-Routen 404, Policy-Kurzschluss nur aktiv, API-Portalwechsel prüft Kategorie, Aggregation mit Zeitbudget und gesperrter Marke, Bereinigung behält `sent`, Sync nach E-Mail-Wechsel, `--classify` mit Vorschau und `--before`, Detailseite sortiert wie Resolver, Protokoll korrigiert |
| 18.09.2026 | 7.7 | `1cd9fd1` | Vertragsablauf: bedingtes Beenden unter Sperre, Marker, Mail nachholbar mit FAILURE-Exit |
| 18.09.2026 | 7.2, 7.3, 7.4 | `ea68362` | Bedingtes UPDATE in beiden Jobs, Prüfung im Publish-Lock für `ki`/`scheduler`, `StaleClassificationException`; roter Re-Check einer Review-PM lehnt ab (gilt auch für den Admin-Re-Check, PMs mit `manual_review_required_at` ausgenommen); NULL-Periode = laufender Monat plus Backfill |
| 25.09.2026 | Block 8: N22, N06 (Gate), 5.3 | `4e34c3d` | Guard in `setUpTraits()`, Flags in phpunit.xml gepinnt, Zustimmung an gezeigte Fassung gebunden, Audit/Decision-Update/Plan versioniert |
| 25.09.2026 | Block 8: N01–N05, N07, N08, N10–N21, 5.1 a–e, 5.2 | `48129e9` | vier parallel geprüfte und umgesetzte Gruppen (Bilder, Archiv, KI/Hintergrund, Berichte); zwei Migrationen; 12 + 9 + 7 + 8 neue Tests |
| 29.09.2026 | Block 9: 9.1–9.7 (R-01, K-01–K-09, B-01–B-14, N09, A-01–A-15, R-02–R-11, N17/N18/N19-Rest, Q-01/Q-02/Q-06, OPT-1–OPT-9, E9-Schalter) | `dfb0513`, `1d417f8`, `c2e0b85`, `f0eeef0`, `7cd84b2`, `5ec252d`, `aa47e11` (am 02.10. zusammen mit Block 10 nach Themen committet) | vier parallele Gruppen; acht Migrationen, auf der Dev-Kopie (MySQL) in 0,7 s; neue Befehle `company-memberships:api-assignments`, `archive:reregister`, `archive:verify-export`, `press-release-images:sweep-orphan-originals`; KAS-Passwort aus dem Runbook entfernt |
| 02.10.2026 | Block 10: O01–O08 aus dem Umsetzungsaudit 02.10. | in denselben Commits (O02 `dfb0513`, O01 `c2e0b85`, O06 `f0eeef0`, O04/O05/O07/O08 `7cd84b2`, O03 `5ec252d`) | alle acht Befunde valide und behoben; keine Migration; E25 revidiert (`BOUNCE_UNSIGNED_KIND=policy`); Runbook-Restore um `--accept-unsigned`/`--without-export` ergänzt |
Gesamtsuite nach Block 7: 1.930 Tests grün, einer übersprungen; PHPStan ohne Meldung; Pint sauber.
Gesamtsuite nach Block 8 (25.09.2026, HEAD `48129e9`, Befehl `php artisan test --compact` nach `config:clear`): 2.122 Tests grün, einer übersprungen; Larastan Level 4 ohne Meldung auf allen geänderten Dateien; Pint sauber. Offen: N09, Browser-/Tastaturabnahme, MySQL-/Worker-CI, die Messpunkte aus 5.2. Ein Test (`ApiPortalResolutionTest`) brauchte wegen der neuen Kategorieprüfung eine gemeinsame Kategorie statt einer zufälligen.
Gesamtsuite nach Block 9 (29.09.2026, Befehl `php artisan test --compact` nach `config:clear`): **2.265 Tests grün, einer übersprungen** — seriell (305 s) und erstmals auch parallel (`--parallel --processes=6`, 57 s); Larastan Level 4 über den vollen Scope ohne Meldung (vorher 1); Pint sauber. Offen: Q-05 (`docs/`), U05–U07 (Newsletter, per Schalter gesperrt), 6.6 MySQL-/Worker-CI, 7.6 Browserabnahme.
Gesamtsuite nach Block 10 (02.10.2026, `php artisan test --compact --parallel --processes=6` nach `config:clear`): **2.281 Tests grün, einer übersprungen** (95 s); Larastan Level 4 voller Scope ohne Meldung; Pint sauber.
Offen: U05–U07 nach E9 aufgeschoben; 7.6 (Fokusführung) manuell im Browser abnehmen.
*Stand 02.10.2026: 1.8 ist mit Block 9 (K-08, `LoginCompletion`) und N09 mit Block 9.5 erledigt; die beiden Absätze unten sind historisch.*
Offen aus dem Plan: 6.6 (MySQL-/Worker-Integrationstests) bleibt aufgeschoben; 1.8 nur als Google-Zweig, nicht als Service-Zusammenführung; 4.2 hatte die Prüfung in `publish()` nicht (siehe Block 7, U02). Nachträglich zwei zufallsabhängige Tests festgezurrt (Kategorie-Portal in den API-Tests, Titel in der MAN-PDF-Adresse). Gesamtsuite nach allen Commits: 1.891 Tests grün, einer übersprungen; PHPStan ohne Meldung.
**05.10.2026 — Blöcke 9 und 10 live (v1.1.0).** Zusammen mit der
Betriebsüberwachung Phase 1 und dem Composer-Update ausgeliefert; Ablauf und
Ergebnisse der Schritte 8–13 des Deploy-Plans in
[`deploy-v1.1.0.md`](../betrieb/deploy-v1.1.0.md) (Protokoll). Kurz: keine
offenen Archivaufträge, drei API-Zuordnungen von connektar alle freigeschaltet,
342 Canonical-Paare neu zugeordnet und 13 veraltete Verweise zurückgesetzt,
Sweeps leer, Scheduler mit 21 Tasks. Schritte 14–18 (Abnahme) nicht einzeln
gemeldet; 19 (KAS-Passwort) erledigt 06.10.2026; 20–21 (DSE, Restore-Übung) offen. Q-05 entschieden
(`docs/` aus dem Repository).

View file

@ -0,0 +1,464 @@
# Umsetzungs-Audit zum Entwicklungsplan vom 18.09.2026
**Prüfdatum:** 18.09.2026
**Geprüfter Code:** `fb03fb166d08fe891ae5ab3a221c8a232b7c8366`
**Vergleich:** `24c1bbf..fb03fb1`, insbesondere die fünf Umsetzungscommits `0ab53bf`, `a7f991f`, `62202a4`, `f235156`, `375f4df` und die nachfolgenden Testkorrekturen.
**Grundlagen:** [Entwicklungsplan einschließlich Umsetzungsprotokoll](ENTWICKLUNGSPLAN.md), [ursprünglicher Audit](AUDIT.md).
**Auftrag:** Umsetzungen prüfen und dokumentieren. Keine Änderungen an Anwendung, Tests, Konfiguration, Datenbestand oder bestehenden Dokumenten.
## 1. Ergebnis und Einordnung
Die Umsetzung behebt zahlreiche konkrete Fehler des ersten Audits. Besonders nachvollziehbar sind die Abschaltung des unsicheren Fortify-Profilendpunkts, die verifizierungsgebundene Rechtevergabe, die Korrektur der Newsletter-Empfängersortierung, die gezielten Soft-Delete-Filter, die Prüfung fehlgeschlagener Storage-Schreibvorgänge und das nun tatsächlich prüfende Formatierungs-Gate.
**Eine vollständige Abnahme des Entwicklungsplans ist dennoch nicht gerechtfertigt.** Einige Arbeitspakete erfüllen zwar ihren lokalen Test, aber noch nicht das fachliche Ziel über den gesamten Ablauf. Die wichtigsten Beispiele:
- Die Rückstufung auf `member` entzieht Firmenstammdatenrechte, lässt aber Schreibrechte auf Pressemitteilungen bestehen.
- Die KI-Ergebnisse tragen jetzt einen Hash. Der entscheidende Veröffentlichungsschritt prüft diesen unter seiner Datenbanksperre jedoch weiterhin nicht.
- Die Newsletter-Wiederaufnahme scheitert am regulären Endzustand einer vollständig fehlgeschlagenen Kampagne; ein harter Abbruch beim Aufbau der Empfängerliste lässt weiterhin Empfänger aus.
- Der Übergang bestehender Kontingentzähler auf die neue Monatsspalte ist nicht konsistent.
- Der neue Vertragsablauf-Command hat eigene Parallelitäts- und Benachrichtigungslücken.
Dieser Bericht enthält **13 priorisierte Rest- beziehungsweise Folgebefunde: 2 × P1 und 11 × P2**. Davon ist U13 ausdrücklich an eine Laufzeit von mehr als zehn Minuten gebunden. Hinzu kommen getrennt gekennzeichnete Betriebsnachweise und kleinere Verbesserungen. Die Befunde sind anhand des Codes belegt; ihre Reproduktionsabläufe wurden in diesem Audit wegen der nicht verfügbaren Sail-Laufzeit **nicht dynamisch ausgeführt**. Es wird kein bereits eingetretener Produktionsschaden behauptet.
### Bewertungsmaßstab
| Kennzeichnung | Bedeutung |
|---|---|
| P1 | Vor fachlicher Abnahme des betroffenen Sicherheits- oder Veröffentlichungsablaufs beheben. |
| P2 | Konkreter Fehler bei einem nachvollziehbaren Eingabe-, Zustands-, Fehler- oder Parallelitätsfall; gezielt nacharbeiten. |
| Im Code umgesetzt | Änderung und passende vorhandene Tests sind nachvollziehbar; kein unabhängig bestätigter grüner Lauf dieses Audits. |
| Teilweise | Wesentliche Korrektur vorhanden, fachliches Ziel oder Fehlerfall bleibt offen. |
| Betriebsnachweis offen | Codeprüfung reicht nicht aus, um Migration, Worker-Neustart oder Produktivwirkung zu bestätigen. |
Die Prioritäten beziehen sich auf diese Nachprüfung. Sie bedeuten insbesondere bei P1 nicht automatisch, dass ein unangemeldeter Internetnutzer den Fehler ausnutzen kann.
## 2. Vorgehen in abgeschlossenen Prüfschritten
1. **Bestand und Nachweise:** Entwicklungsplan, Entscheidungen E1–E6, Commitfolge, Arbeitsverzeichnis und Umsetzungsprotokoll abgeglichen.
2. **Identität und Berechtigungen:** Fortify-Routen, Google-Anmeldung, Livewire-Sicherheitsaktionen, Firmenzuordnungen, Policies und Admin-Grant-Actions verfolgt.
3. **Newsletter:** Auswahl, Migration, Kampagnenstart, Versandclaim, Abschluss, Fehlerbehandlung und Wiederaufnahme als zusammenhängenden Zustandsablauf geprüft.
4. **Öffentliche Daten und API:** Soft Deletes, Vorschau/Medien, Kontakt-Fallback, Newsroom-Cache, URL-Auflösung, API-Validierung und Storage-Reihenfolge geprüft.
5. **KI und Kontingent:** Dispatch, Analyse, Hashvergleich, Ergebnispersistenz, Veröffentlichungslock, Re-Checks, Monatswechsel und Übergangsdaten geprüft.
6. **Abrechnung und Scheduler:** Vertragsende, Ablaufbenachrichtigung, Rechnungsauswahl, Hold-Markierung, Queue-Zeitbudgets und Aggregationssperre geprüft.
7. **Entwicklungsqualität:** Ergänzte Tests, CI-Gates, PHPStan-Scope, Entwicklungsstart, Dokumentation und Fokusführung gelesen.
8. **Betriebsabgleich:** Ausschließlich lesende Boost-Datenbankabfragen zu Migrationen, neuen Spalten/Indizes, Newsletter-Dubletten und vorhandenen Kontingentzählern durchgeführt.
Die Backend-Prüfung von Newsletter, Abrechnung und Aggregation wurde zusätzlich separat gegengelesen. Für Framework-Verhalten wurden auch die tatsächlich installierten Laravel-/Fortify-Quelldateien herangezogen.
### Grenzen der Verifikation
Der Entwicklungsplan dokumentiert **1.891 erfolgreiche Tests, einen übersprungenen Test und PHPStan ohne Meldung**. Das ist ein vorhandener Entwicklungsnachweis, kein in diesem Audit wiederholtes Ergebnis.
`vendor/bin/sail ps` meldete, dass Docker oder Podman nicht läuft. Der gezielte Versuch
```text
vendor/bin/sail artisan test --compact tests/Feature/Auth/ContactAccessTest.php tests/Feature/Admin/NewsletterCampaignResilienceTest.php tests/Feature/PressReleaseQuotaTest.php
```
endete ebenfalls vor dem Teststart mit Exit-Code 1 und `Docker or Podman is not running.` Es wurden deshalb keine Tests als bestanden gewertet, keine Umgehung über direktes PHP verwendet und keine Testdateien ergänzt. Auch PHPStan, Browserabläufe und echte parallele MySQL-/Queue-Prozesse wurden nicht neu ausgeführt.
Die Testkonfiguration erzwingt SQLite `:memory:` und verwendet eine synchrone Queue. Das schützt den regulären Testlauf vor Zugriff auf den Anwendungsdatenbestand, beweist aber keine MySQL-Sperrsemantik und keinen Worker-Abbruch. Die im Plan bewusst verschobene zweite CI-Stufe wird nicht nachträglich als unerfüllte ursprüngliche Zusage behandelt. Für die jetzt konkreten Restfehler ist sie allerdings besonders relevant.
## 3. Abgleich sämtlicher ursprünglicher Befunde
| Alt-ID | Ergebnis dieser Nachprüfung | Begründung / verbleibender Punkt |
|---|---|---|
| F01 | Einstieg im Code geschlossen; Bestandsbereinigung offen | `updateProfileInformation` deaktiviert, Action/Registrierung entfernt, Sync verlangt aktives verifiziertes Konto. Vorhandene unberechtigte Zuordnungen verschwinden dadurch nicht automatisch. |
| F02 | Sicherheitszweig umgesetzt | Google übergibt bestätigte 2FA an die Challenge und authentifiziert vorher nicht. Geplante Zusammenführung der Login-Abschlüsse in einen Service fehlt. |
| F03 | Teilweise | `#[Locked]` und Nachweis in den Volt-Actions vorhanden; alternative Fortify-2FA-Routen bleiben bei Passwortbestätigung, siehe U09. |
| F04 | Geprüfte Zielpfade korrigiert | PortalScope wird gezielt entfernt; SoftDeletes bleiben wirksam. Kontakt-Fallback und Pressefach korrigiert, Vorschautest verwendet nun echtes Soft Delete. Whitelist bleibt eine bewusst begrenzte Absicherung. |
| F05 | Auswahlfehler korrigiert | ID-Sortierung und Test über 1.001 Empfänger vorhanden. Bereinigungsmigration muss Zustellzustände beachten, siehe U08. |
| F06 | Kernregel umgesetzt; neuer Ablauf nicht vollständig robust | Vertragsende in Auswahl und gesperrter Rechnungsprüfung berücksichtigt. Neue Fehler im Ablauf-Command: U11/U12. |
| F07 | Teilweise | Hashübergabe, Verwerfen und Audit vorhanden. Letzte atomare Schranke fehlt; roter Re-Check kann hängen bleiben, siehe U02/U03. |
| F08 | Im Code umgesetzt | Nutzerdetailroute besitzt Permission-Middleware; beide Grant-Actions autorisieren erneut. |
| F09 | Geplante Panel-Sperre umgesetzt | Globaler Superadmin-Kurzschluss verlangt `is_active`; Panel-Test vorhanden. Eigenständige Policy-Kurzschlüsse bleiben als Härtungspunkt. |
| F10 | Teilweise | Herkunft und Rückstufung vorhanden; `member` ist für Pressemitteilungen weiterhin schreibberechtigt, siehe U01. |
| F11 | Teilweise | Listenparameter und Sprache validiert, Kategorien beim Anlegen geprüft. Portalwechsel ohne neue Kategorie umgeht die Zuordnungsprüfung, siehe U10. |
| F12 | Im Code umgesetzt | Gemeinsamer Resolver prüft Host, Portal, Sprache und Sichtbarkeit; passende Mehrportal-/Sprachtests vorhanden. |
| F13 | Im Code umgesetzt | Firmen-Observer, vorgelagerte Sichtbarkeitsprüfung und Pressefach-Kontakt-Flush vorhanden; Tests mit warmem Cache ergänzt. |
| F14 | Teilweise | Atomarer Start und Unique-Schutz vorhanden; harter Abbruch beim Empfängeraufbau nicht vollständig wiederaufnehmbar, siehe U06. |
| F15 | Teilweise | Empfängerclaim, transaktionale Zähler und Suppressed-Zustand vorhanden; Abschluss/Resume und Fehler nach SMTP bleiben problematisch, siehe U05/U07. |
| F16 | Im Code umgesetzt | Nicht abrechenbare Fälle blockieren die erste Seite nicht mehr; Regression mit 50 Blockern vorhanden. Es bleibt ein bewusstes Limit von 500 erstellten Rechnungen je Lauf. |
| F17 | Defaults korrigiert | Klassifikation und Score haben 120 s Jobtimeout; Database/Redis standardmäßig 150 s `retry_after`. Wirksame ENV-Werte und Worker-Neustart gesondert prüfen. |
| F18 | Teilweise | Monatsbindung und idempotenter Reset für explizite Perioden vorhanden; `NULL`-Altbestand inkonsistent, siehe U04. |
| F19 | Für normale Überlappungen korrigiert | Gemeinsamer Lock im Command schützt auch manuelle Aufrufe. Schutz endet nach 600 s, siehe U13. |
| F20 | Im Code umgesetzt | Technische Publish-Fehler führen zu `FAILURE`; Test für Exit-Code vorhanden. |
| F21 | Bewusst ausgeschlossen | Stillgelegter Legacy-Importer entsprechend Plan nicht nachbearbeitet. Keine negative Bewertung für diesen ausdrücklich verschobenen Bereich. |
| F22 | Code und Migration vorhanden | Indexfreundliche MySQL-Abfragen, SQLite-Fallback und Kontaktindex-Migration. Neuer Index fehlt in der hier erreichbaren Datenbank noch. |
| F23 | Im Code umgesetzt | `put(false)` wird erkannt; Logo erst schreiben, dann Referenz ändern, dann alte Datei löschen. Fehlerfälle getestet. |
| F24 | Im Code umgesetzt | CI verwendet `pint --test`, Schreibpermission entfernt. |
| F25 | Gezielte Tests erweitert; breite Matrix bewusst offen | Bessere Regressionen vorhanden. MySQL-/Worker-Matrix bleibt vereinbarungsgemäß verschoben; aktuelle Restfehler zeigen konkrete Ergänzungen auf. |
| F26 | Fokusmechanik im Code vorhanden | Öffnen, Tab-Grenzen und Fokusrückgabe implementiert; Browser-/Tastaturnachweis fehlt. |
| F27 | Im Code umgesetzt | Composer-Entwicklungsstart verwendet `npm run dev:all`. |
| F28 | Geplante Dokuänderung umgesetzt | Historischer Stand kenntlich, Einstieg und Archivpfad korrigiert. Kleinere Widersprüche im neuen Umsetzungsprotokoll siehe Abschnitt 7. |
## 4. Priorisierte Rest- und Folgebefunde
### U01 — P1: Rückstufung auf `member` entzieht nicht alle Schreibrechte
**Bezug:** F10, Plan 1.5, Entscheidung E2.
**Belege:** `app/Services/Auth/ContactAccessService.php:148`, `app/Models/User.php:588`, `app/Policies/PressReleasePolicy.php:36`, `:101`; `resources/views/livewire/customer/press-releases/show.blade.php:379`.
Der Sync senkt automatische `responsible`-Pivots korrekt auf `member`. `CompanyPolicy::update()` verweigert diesem Mitglied danach die Änderung der Firma. `PressReleasePolicy::canManage()` fragt jedoch nur nach Autorenschaft oder `canAccessCompany()`. Letzteres berücksichtigt jede Mitgliedschaft ohne Rollenfilter.
Damit darf der herabgestufte Kontakt weiterhin fremde Firmen-PMs im Status Draft, Rejected oder Review bearbeiten; je nach Status auch einreichen oder löschen. Bei veröffentlichten PMs gibt es einen weiteren konkreten Schreibweg: `applyCorrection()` autorisiert nur `view`, das ebenfalls jedem Firmenmitglied erlaubt ist, und übergibt die Änderung anschließend an den Korrekturservice.
**Nachvollziehbarer Ablauf:** Automatische Verantwortlichkeit vergeben → Firmen-/Kontaktadresse wechseln → erneut anmelden → Pivot ist `member` → eine PM eines anderen Autors derselben Firma bearbeiten beziehungsweise eine zulässige kleine Korrektur einer veröffentlichten PM anwenden. Der Firmenstammdatentest ist dabei weiterhin grün.
**Testlücke:** `ContactAccessTest` prüft nach dem Entzug nur `update/view` auf der **Company**, nicht auf ihren PMs und nicht auf Korrektur-Actions.
**Empfehlung:** E2 beibehalten, aber Lese- und Verwaltungsberechtigungen über alle PM-/Medien-/Korrekturaktionen trennen. Schreibrechte müssen auf einer ausdrücklichen Verwaltungsrolle oder einer bewusst definierten Autorberechtigung beruhen. Für den Entzugstest einen Nutzer wählen, der nicht PM-Autor ist.
**Abnahme:** Nach Rückstufung bleibt das gewünschte Lesen möglich; PM-Änderung, Einreichung, Löschung und Korrektur einer fremden Firmen-PM werden serverseitig verweigert.
### U02 — P1: KI-Hashprüfung ist nicht mit Ergebnisspeicherung und Veröffentlichung atomar verbunden
**Bezug:** F07, Plan 4.1/4.2.
**Belege:** `app/Jobs/ClassifyPressRelease.php:75–123`, `app/Jobs/ScorePressRelease.php:56–95`, `app/Services/PressRelease/PressReleaseService.php:275–305`, `app/Console/Commands/PublishScheduledPressReleases.php:103`, `:144`.
Beide Jobs lesen den aktuellen Hash separat und speichern danach ohne gemeinsame Sperre ihr Ergebnis. `publish()` lädt zwar die PM mit `lockForUpdate()`, prüft dort jedoch nur Status, Blacklist und Kontingent. Der im Plan ausdrücklich geforderte Vergleich des freigebenden KI-Ergebnisses mit dem **gesperrten aktuellen Inhalt** fehlt.
**Ablauf:** Job bewertet Version A → separater Hash-SELECT sieht A → ein Editor speichert Version B → Job speichert die grüne Klassifikation von A → `publish()` lädt B unter Sperre und veröffentlicht B. Die Blacklist ersetzt keine Klassifikationsprüfung. Auch zwischen Scheduler-Prüfung und `publish()` besteht dieses Fenster. Beim Score kann entsprechend eine alte Bewertung auf einer neueren Version landen.
Ein zweiter Grenzfall liegt im Scheduler: Fehlt ein Audit-Hash, gilt die Klassifikation ausdrücklich als verwendbar. Der neue Test sichert dieses Legacy-Verhalten sogar ab. Eine später geänderte alte PM ist damit nicht über diesen Schutz abgesichert, solange noch kein neuer gültiger Audit vorliegt. Eine dokumentierte Übergangsregel muss unterscheiden zwischen unverändertem Altbestand und seitdem geändertem Inhalt.
**Testlücke:** Die neuen Tests ändern den Inhalt während des gemockten Provider-Aufrufs. Sie beweisen den Vergleich **vor** dem späteren Schreibfenster, nicht das atomare Verhalten zwischen Hashvergleich und Commit beziehungsweise zwischen Scheduler und Publish-Lock.
**Empfehlung:** Den tatsächlichen Analyse-Snapshot eindeutig versionieren. Ergebnisübernahme und gültige Versionszuordnung mit bedingtem Update oder kurzer Transaktion absichern. Vor automatischer Veröffentlichung dieselbe Version unter dem bestehenden PM-Lock zwingend prüfen. Eine bewusste manuelle Übersteuerung separat und nachvollziehbar modellieren. Den langsamen KI-Aufruf nicht innerhalb der Datenbanksperre ausführen.
**Abnahme:** Eine Änderung unmittelbar nach dem bisherigen Hash-SELECT verhindert die Speicherung eines gültigen alten Ergebnisses und die automatische Veröffentlichung der neuen Version. Score und Audit bleiben konsistent. Derselbe Fall muss über den Scheduler abgesichert sein.
### U03 — P2: Roter Re-Check einer terminierten PM kann weiterhin still in `review` hängen bleiben
**Bezug:** F07, Plan 4.2.
**Belege:** `app/Services/PressRelease/PressReleaseService.php:136–147`, `:169–178`; `app/Jobs/ClassifyPressRelease.php:118–123`; `app/Console/Commands/PublishScheduledPressReleases.php:59–65`.
Inhaltsänderungen einer bereits klassifizierten PM planen über `reclassifyIfClassified()` einen Job mit `route: false` ein. Wird eine zukünftige grüne PM bearbeitet und dieser Re-Check rot, speichert der Job Rot und kehrt ohne Statusrouting oder Benachrichtigung zurück. Der Scheduler wählt später ausschließlich Grün/Gelb. Die PM bleibt deshalb als rote terminierte Meldung in Review stehen.
Der neue Scheduler-Recheck mit `route: true` hilft nur, wenn die PM noch als Grün/Gelb ausgewählt wird und ihr gespeicherter Audit-Hash nicht passt. Ein bereits vollständig ausgeführter roter Editor-Recheck erreicht diesen Zweig nicht.
**Empfehlung:** Für noch nicht veröffentlichte terminierte PMs eine ausdrücklich definierte Recheck-Folge einführen: Ablehnung oder manuelle Prüfung mit nachvollziehbarer Information an Kunde und Admin. Bei bereits veröffentlichten PMs kann eine andere Regel gelten.
**Abnahme:** Grün und terminiert → reguläre Editoränderung → rotes Ergebnis des regulären `route:false`-Rechecks → erwarteter Status und Benachrichtigungen. Nicht nur einen isoliert vom Scheduler gestarteten Job testen.
### U04 — P2: Neue Kontingentperiode behandelt `NULL`-Altbestand widersprüchlich
**Bezug:** F18, Plan 4.3.
**Belege:** `database/migrations/2026_09_18_170100_add_quota_period_to_users_table.php:23–28`, `app/Models/User.php:293–299`, `app/Services/PressRelease/PressReleaseService.php:365–374`, `app/Console/Commands/ResetMonthlyPressReleaseQuota.php:40–48`.
Die Migration ergänzt eine nullable Monatsspalte ohne Bestandsinitialisierung. Beim Lesen gilt ein positiver Zähler mit `period = NULL` weiterhin als verbraucht. Beim nächsten erfolgreichen Planverbrauch wird derselbe Zähler dagegen auf **1** gesetzt, weil `NULL` nicht dem aktuellen Monat entspricht.
**Konkretes Beispiel:** Tarif mit 3 Slots, bisher 2 verbraucht, neue Spalte NULL. Anzeige zunächst 1 verbleibend. Nach einer weiteren Veröffentlichung stehen Verbrauch 1 und Rest 2 statt Verbrauch 3 und Rest 0. Umgekehrt bleibt ein ausgeschöpfter NULL-Zähler nach einem Monatswechsel ohne Cron ausgeschöpft, weil der Leseweg NULL nicht als Vorperiode erkennt.
Das ist kein bloß hypothetischer Datentyp: In der über Boost erreichbaren Datenbank bestehen zwei Nutzer mit positivem Verbrauch, maximal 2; die neue Monatsspalte fehlt dort noch. Ob diese Konten aktuell einen begrenzten Tarif haben, wurde nicht festgestellt.
**Testlücke:** Die neuen Monatswechseltests verwenden explizit `2026-08` und `2026-09`. Ein positiver NULL-Altzähler mit noch freiem Slot wird nicht bis zum nächsten Publish durchgespielt.
**Empfehlung:** Eine einmalige, fachlich begründete Bestandszuordnung zum Migrationsmonat festlegen und Lese-, Verbrauchs- und Resetlogik auf dieselbe NULL-Semantik bringen. Falls unbekannter Altverbrauch bewusst verworfen werden soll, muss auch Anzeige und Entscheidung vor dem Verbrauch dazu passen.
**Abnahme:** Migration mit positivem Altverbrauch, erste Veröffentlichung im selben Monat sowie erster Monatswechsel ohne Reset erhalten das vereinbarte Kontingent korrekt.
### U05 — P2: Vollständig fehlgeschlagene Newsletter-Kampagne lässt sich nicht mit `--failed` wiederaufnehmen
**Bezug:** F15, Plan 2.6.
**Belege:** `app/Jobs/SendNewsletterCampaignMail.php:180–191`, `app/Console/Commands/ResumeNewsletterCampaign.php:42–45`; `tests/Feature/Admin/NewsletterCampaignResilienceTest.php:268`, `:338`.
Sobald kein Empfänger mehr Pending oder Sending ist, setzt `conclude()` die Kampagne auf Sent. Das gilt auch, wenn alle Empfänger Failed sind. `newsletter:resume --failed` akzeptiert dagegen ausschließlich Kampagnen im Status Sending.
**Ablauf:** Kampagne läuft während einer Relay-Störung oder Circuit-Breaker-Pause → alle Jobs scheitern endgültig → Empfänger Failed, Kampagne Sent → Störung behoben → der angekündigte Wiederaufnahmebefehl verweigert die Arbeit.
**Testlücke:** Der Transportfehlertest erwartet ausdrücklich Sent nach endgültigem Fehlschlag. Der Resume-Test legt separat eine noch Sending genannte Kampagne an. Die Verkettung beider regulärer Abläufe fehlt.
**Empfehlung:** Kampagnen mit wiederholbaren Fehlzuständen kontrolliert und atomar wieder öffnen oder Abschlusszustände differenzieren. Bereits gesendete Empfänger müssen unverändert bleiben; `failed_count`, Abschlusszeit und Kampagnenstatus gemeinsam konsistent behandeln.
**Abnahme:** Alle Empfänger zunächst endgültig fehlschlagen lassen, anschließend den dokumentierten Befehl ausführen und ausschließlich die fehlgeschlagenen Empfänger nachholen.
### U06 — P2: Harter Abbruch während des Empfängeraufbaus lässt die Kampagne unvollständig zurück
**Bezug:** F14, Plan 2.3/2.6.
**Belege:** `app/Services/Newsletter/CampaignSender.php:38–62`, `:99–113`, `:134–150`; `app/Console/Commands/ResumeNewsletterCampaign.php:82`.
Die Kampagne wird vor dem batchweisen Einfrieren auf Sending gesetzt. Nur ein abgefangener `Throwable` setzt sie zurück auf Draft. Ein Prozessabbruch, etwa SIGKILL oder ein harter FPM-Neustart, überspringt diesen Catch.
Nach einem Abbruch hinter dem ersten 500er-Batch existieren nur diese Empfänger. `recipients_total` kann noch 0 sein. Resume dispatcht ausschließlich vorhandene Zeilen; es vervollständigt die Auswahl nicht. So kann später eine formal abgeschlossene Kampagne entstehen, deren übrige Zielgruppe nie im Versandprotokoll stand.
**Testlücke:** Der Test für einen abgebrochenen Start erzeugt einen Draft mit Teilbestand. Der reale harte Abbruch hinterlässt Sending. Das sind unterschiedliche Zustände.
**Empfehlung:** Einen persistenten Vorbereitungszustand mit wiederaufnehmbarem Auswahlfortschritt beziehungsweise eindeutiger Vollständigkeitsmarke einführen. Versand erst nach vollständig eingefrorener Zielgruppe freigeben. Dabei festlegen, welcher Auswahlzeitpunkt für später hinzugekommene Nutzer gilt.
**Abnahme:** Prozess nach dem ersten Batch hart beenden, Wiederaufnahme durchführen, anschließend exakt die vollständige ursprüngliche Zielgruppe einmal im Empfängerprotokoll und korrekten Gesamtzähler nachweisen.
### U07 — P2: Fehler nach erfolgreicher SMTP-Übergabe kann weiterhin automatischen Doppelversand auslösen
**Bezug:** F15, Plan 2.4.
**Belege:** `app/Jobs/SendNewsletterCampaignMail.php:82–87`, `:137–145`; `app/Listeners/RecordMailActivity.php:38`; `vendor/laravel/framework/src/Illuminate/Mail/Mailer.php:330–338`.
Der Catch um `Mail::send()` gibt den Empfänger bei jedem Throwable als Pending frei. `Mail::send()` umfasst aber nicht nur die Transportübergabe, sondern auch anschließende `MessageSent`-Listener. Im vorhandenen `RecordMailActivity` findet nach erfolgreicher Übergabe eine nicht abgefangene User-Datenbankabfrage statt.
**Ablauf:** SMTP nimmt die Nachricht an → User-Abfrage im Eventlistener scheitert → äußerer Catch setzt Pending → Queue-Retry sendet die bereits übergebene Nachricht nochmals. Dass ein späterer Datenbankzugriff wieder gelingt, ist bei einer vorübergehenden Verbindungsstörung möglich; der Callbackfehler beweist keine Nichtzustellung.
Der neue Claim verhindert parallele Übernahme. Er beseitigt diese zeitliche Lücke nach der externen Wirkung nicht. Auch die Kommentaraussage, ein Fehler nach Mailserverübergabe verbleibe als unklarer Sending-Zustand, trifft auf diesen vorhandenen Pfad nicht zu.
**Empfehlung:** Nachgelagerte Protokollierung darf eine erfolgreiche Übergabe nicht in einen automatisch wiederholbaren Transportfehler umdeuten. Zustellfehler und unklare Übergaben getrennt behandeln. SMTP und Datenbank können durch eine lokale Transaktion allein keine allgemeine Exactly-once-Garantie erhalten.
**Abnahme:** Echter Mailer-Eventfluss mit kontrolliert erfolgreichem Testtransport und anschließend scheiterndem `MessageSent`-Listener. Der Retry darf keine zweite Übergabe erzeugen. `Mail::fake()` allein ist dafür ungeeignet.
### U08 — P2: Newsletter-Deduplizierung kann den einzigen Zustellnachweis löschen
**Bezug:** F05, Plan 2.2; Migration vor Deployment prüfen.
**Beleg:** `database/migrations/2026_09_18_110000_add_unique_user_to_newsletter_campaign_recipients_table.php:25–35`.
Die Migration behält für jedes Kampagne/User-Paar ausschließlich `MIN(id)`, unabhängig von Status und `sent_at`. Bei ältester Zeile Pending/Failed und späterer Zeile Sent wird der Zustellnachweis entfernt. Die erhaltene Zeile kann anschließend erneut versendet werden; Kampagnenzähler werden nicht mit den verbleibenden Protokollzeilen abgeglichen.
**Einordnung anhand der lesenden Datenprüfung:** In der hier erreichbaren Datenbank bestehen 470 Dublettengruppen mit 470 zusätzlichen Zeilen. Für die konkrete Kombination „eine Zeile Sent, älteste Zeile nicht Sent“ ergab die Abfrage **0 Gruppen**. Der gefährliche Zweig ist dort somit nicht nachgewiesen; die Migration ist für andere Zustandsmischungen dennoch nicht robust. Eine Produktionsdatenprüfung wurde damit nicht ersetzt.
**Empfehlung:** Status und Zustellzeit fachlich zusammenführen, vorhandene Sent-Nachweise erhalten und die Interpretation historischer Versandzähler festlegen. Die Bereinigung nicht gleichzeitig mit weiterlaufenden alten Versandjobs ausführen. Bei bereits versandten Dubletten zwischen historischen SMTP-Übergaben und eindeutigen Empfängern unterscheiden.
**Abnahme:** Mindestens Pending→Sent, Failed→Sent und Sent→Sent als Dublettenbestand migrieren. Keine anschließend versendbare Zeile für einen bereits nachweislich versandten Empfänger erzeugen.
### U09 — P2: Alternative Fortify-Endpunkte umgehen die neue TOTP-/Recovery-Pflicht
**Bezug:** F03, Plan 1.7, Entscheidung E3.
**Belege:** `config/fortify.php:151`, `config/auth.php:115`; `vendor/laravel/fortify/routes/routes.php:147–176`; `vendor/laravel/framework/src/Illuminate/Auth/Middleware/RequirePassword.php:94–98`.
Die neuen Volt-Actions verlangen bei bestätigter 2FA den zweiten Faktor. Fortify registriert daneben weiterhin unter anderem `DELETE /user/two-factor-authentication`, `POST /user/two-factor-recovery-codes` und `GET /user/two-factor-recovery-codes`. Diese Wege verwenden `auth` plus `password.confirm`, nicht die neue TOTP-/Recovery-Prüfung.
Eine Session mit noch gültigem `auth.password_confirmed_at` kann daher ohne aktuellen zweiten Faktor 2FA abschalten beziehungsweise Recovery-Codes abrufen oder erneuern. Der konfigurierte Standard für die Passwortbestätigung beträgt 10.800 Sekunden. `views = false` schaltet diese Aktionsrouten nicht ab. Eine eigene Abschaltung der Fortify-Routen ist im Projekt nicht vorhanden.
**Wichtige Grenze:** Das ist kein Zugang ohne Session und auch kein pauschaler Bypass von `password.confirm`. Voraussetzung ist eine noch gültige Passwortbestätigung oder die Möglichkeit, sie mit dem Passwort herzustellen. Es ist aber ein konkreter Nebenweg zur im Plan beschlossenen E3-Regel und zur Beschränkung der Recovery-Code-Ausgabe.
**Empfehlung:** Alle erreichbaren 2FA-Verwaltungswege auf dieselbe Sicherheitsregel bringen oder ungenutzte Alternativrouten abschalten. Soll die Passwortbestätigung alternativ genügen, muss die Entscheidung E3 entsprechend präzisiert werden.
**Abnahme:** Native Fortify-Routen und Volt-Actions mit unbestätigter sowie noch passwortbestätigter Session testen. Eine gültige alte Passwortbestätigung allein darf bei vereinbarter E3-Regel keinen aktuellen TOTP-/Recovery-Nachweis ersetzen.
### U10 — P2: API-Portalwechsel prüft die bestehende Kategorie nicht erneut
**Bezug:** F11, Plan 3.6.
**Belege:** `app/Http/Controllers/Api/V1/PressReleaseController.php:130–145`, `:259–274`.
Beim Anlegen wird die Kategorie gegen das Zielportal geprüft. Beim Update geschieht das nur, wenn `category_id` im Request steht. `portal` kann separat geändert werden; die bisherige Kategorie bleibt dann ohne Prüfung bestehen.
**Ablauf:** Draft auf Presseecho mit einer nur dort gültigen Kategorie anlegen → Update ausschließlich mit `portal = businessportal24` → Kategorie und Zielportal passen anschließend nicht mehr zusammen. Eine inzwischen deaktivierte Kategorie kann bei einem Update ohne Kategorieparameter ebenfalls erhalten bleiben.
**Empfehlung:** Den resultierenden Zustand validieren: neue Kategorie oder vorhandene Kategorie gegen neues oder vorhandenes Portal. Zumindest ein Portalwechsel muss die aktuelle Kategorie zwingend neu prüfen.
**Abnahme:** Portal-only-Update mit portalfremder bestehender Kategorie ergibt 422; ein Wechsel mit gültiger neuer Kategorie gelingt; gemeinsame aktive Kategorien bleiben zulässig.
### U11 — P2: Vertragsablauf kann zwischenzeitliche Verlängerung übergehen und doppelte Ablaufmails erzeugen
**Bezug:** neuer Planpunkt 5.2.
**Belege:** `app/Console/Commands/ExpireGrandfathering.php:37–43`, `:65–91`.
Der Command selektiert abgelaufene Grandfathered-Verträge. Beim späteren Update prüft er nur IDs und `cancelled_at IS NULL`, nicht erneut Status und Enddatum. Die Transaktion allein macht die zuvor gelesene Auswahl nicht aktuell.
**Fall A:** Nach der Auswahl verlängert ein Admin das Vertragsende. Der Ablauf-Command beendet die inzwischen verlängerte Vereinbarung trotzdem.
**Fall B:** Scheduler und manueller Aufruf lesen dieselben Kandidaten. Einer beendet sie, der andere ändert null Zeilen, schreibt aber trotzdem das Ablaufereignis und plant erneut eine Mail ein, weil die Benachrichtigung nicht an den tatsächlich gewonnenen Zustandswechsel gebunden ist.
**Testlücke:** Zwei vollständig nacheinander ausgeführte Commands belegen Idempotenz nach Abschluss, nicht überlappende Auswahlsnapshots.
**Empfehlung:** Bedingungen im Schreibzugriff beziehungsweise unter Zeilensperre erneut prüfen. Nur tatsächlich von diesem Lauf beendete Posten für Ereignis und Kundeninformation verwenden.
**Abnahme:** Verlängerung zwischen Auswahl und Update bleibt erhalten. Zwei überlappende Läufe erzeugen genau einen fachlichen Ablauf und einen Benachrichtigungsauftrag je betroffener Kundengruppe.
### U12 — P2: Ablaufbenachrichtigung kann nach erfolgreicher Vertragsbeendigung dauerhaft ausfallen
**Bezug:** neuer Planpunkt 5.2.
**Belege:** `app/Console/Commands/ExpireGrandfathering.php:65–73`, `:90–91`.
Die Statusänderung wird committed, bevor die Mail eingeplant wird. Scheitert die Queue-Verbindung oder stirbt der Prozess dazwischen, ist der Vertrag bereits Cancelled. Der nächste Lauf nimmt ihn nicht mehr auf und versucht die Kundeninformation nicht erneut.
Die fachliche Beendigung selbst bleibt erhalten. Betroffen ist die ausdrücklich zugesagte Benachrichtigung; ein tatsächlicher Verlust wurde nicht beobachtet.
**Empfehlung:** Den Benachrichtigungsauftrag zusammen mit dem Zustandswechsel dauerhaft speichern und unabhängig wiederholbar zustellen, etwa über eine Outbox oder einen separaten Versandmarker. Ein bloßes erneutes Ausführen des bestehenden Commands schließt die Lücke nicht.
**Abnahme:** Fehler beim Enqueue nach Vertragsbeendigung erzwingen, Prozess wieder aufnehmen und genau einen nachholbaren Benachrichtigungsauftrag nachweisen, ohne den Vertrag erneut zu ändern.
### U13 — P2, bedingt: Aggregationslock läuft vor einem langen Command ab
**Bezug:** F19, Plan 4.6.
**Belege:** `app/Console/Commands/AggregatePressReleaseViews.php:51–65`, `:93–139`.
Der gemeinsame Lock ist eine sinnvolle Verbesserung: Er liegt direkt im Command und schützt dadurch auch manuelle Aufrufe. Seine TTL beträgt jedoch fest 600 Sekunden, während die Batchschleife weder Laufzeitgrenze noch Lock-Erneuerung besitzt.
Bei großem Rückstand oder langsamer Datenbank kann die erste Instanz nach Ablauf der TTL noch arbeiten. Eine zweite Instanz kann dann denselben Lock erwerben und denselben noch nicht fortgeschriebenen Wasserstand lesen. Das ursprüngliche Risiko doppelter Aggregation besteht in diesem Zeitfenster erneut.
**Evidenzgrenze:** Ein tatsächlicher Lauf über zehn Minuten wurde nicht gemessen. Der Befund betrifft die nachweislich fehlende Absicherung dieser Betriebsbedingung.
**Empfehlung:** Verarbeitung mit Zeitbudget sicher vor Lockablauf beenden oder einen erneuerbaren Lock samt Verlustprüfung verwenden. Alternativ den Wasserstandsübergang zusätzlich datenbankseitig serialisieren. Nur die TTL ohne Laufzeitannahme weiter zu erhöhen verschiebt die Grenze.
**Abnahme:** Zeit über die Lock-TTL vorziehen, während ein Lauf noch einen Batch verarbeitet; ein zweiter Lauf darf keine bereits beanspruchten Ereignisse nochmals verbuchen.
## 5. Was sich an der Umsetzung bewährt
Die Restbefunde erfordern gezielte Ergänzungen, keine Rücknahme der gesamten Arbeit.
- **Identität:** Der ursprüngliche ungeprüfte Fortify-E-Mail-Wechsel ist abgeschaltet. Die Verifikations-/Aktivitätsprüfung sitzt im Sync-Service und wirkt damit unabhängig vom konkreten Login-Einstieg. Google setzt bei 2FA `login.id`/`login.remember`, regeneriert die Session und bleibt bis zur Challenge Gast.
- **Admin-Actions:** Neue Permission-Prüfungen stehen direkt in beiden Grant-Actions. Der Schutz hängt damit nicht mehr allein an `mount()` und einem früher geöffneten Formular.
- **Newsletter-Auswahl:** `reorder('id')` behebt die Ursache des Cursorfehlers. Der Vergleich aller Empfänger-IDs über 1.001 unsortiert angelegte Nutzer ist deutlich aussagekräftiger als ein reiner Count-Test unterhalb der Batchgrenze.
- **Newsletter-Normalbetrieb:** Start-CAS, Unique-Constraint, `insertOrIgnore` und Pending→Sending-Claim sichern unterschiedliche Ebenen sinnvoll ab. Abschluss und Zähler werden gemeinsam geschrieben; der Kampagnenlock serialisiert konkurrierende Abschlüsse. Der Suppression-Test nutzt bewusst den realen Mail-Gate-Pfad.
- **Sichtbarkeit:** Die geänderten öffentlichen, Kunden- und API-Abfragen entfernen gezielt den PortalScope. Der bisher irreführende Vorschautest verwendet jetzt `delete()` statt `forceDelete()`. Gelöschte Kontakte fallen aus dem öffentlichen Firmenkontakt-Fallback und der Pressefachliste heraus.
- **Cache:** Die Newsroom-Sichtbarkeitsprüfung liegt vor dem Seitencache. Der Observer deckt normale Firmenlöschung, Wiederherstellung und Aktivitätswechsel ab; der gesonderte 410-Ablauf vermeidet dabei bewusst einen redundanten globalen Flush.
- **URL-Auflösung:** Host, Sprache und Portal werden zentral ausgewertet; fremde Hosts können nicht mehr allein über einen passenden Slug einer PM zugeordnet werden. Tests decken identische Slugs in mehreren Portalen und Ausgaben ab.
- **Storage:** Das explizite Prüfen von `put()` passt zu Disks mit `throw=false`. Die neue Reihenfolge beim Logoersatz schützt das bisherige Logo vor einem fehlgeschlagenen Upload; bei einem Datenbankfehler wird die neu gespeicherte Datei aufgeräumt.
- **Abrechnung:** Vertragsende wird auch unter der bestehenden Rechnungssperre geprüft. Nicht abrechenbare Fälle verbrauchen nicht mehr das erfolgreiche Rechnungslimit. Hold-Gründe sind sichtbar und werden bei erfolgreicher Abrechnung entfernt.
- **Entwicklungswerkzeuge:** `pint --test` kann einen unformatierten Commit jetzt tatsächlich ablehnen. Der Composer-Start ruft die vorgesehenen Vite-Prozesse auf. Die Doku unterscheidet den historischen Konzeptstand vom aktuellen Betriebseinstieg.
## 6. Testbewertung und konkrete nächste Regressionen
### Vorhandene Tests mit brauchbarem Aussagewert
| Bereich | Gelesene Testdateien, Auswahl | Was sie belegen sollen | Verbleibende Grenze |
|---|---|---|---|
| Rechte/Identität | `ContactAccessTest`, `CompanyMembershipAuditTest`, `GoogleLoginTest`, `UserAccessTest`, `AdminGrantTest` | Verifikation, Pivot-Herkunft, Rückstufung der Firmenrechte, Google-Challenge, Panelsperre, erneute Grant-Autorisierung | PM-Rechte nach Rückstufung und zusammenhängender Google-Challenge-Abschluss fehlen. |
| Konto-Sicherheit | `CustomerProfileSecurityTest` | Locked-Property, Nachweise in den Volt-Actions, bestätigter E-Mail-Wechsel | Native Fortify-Alternativrouten nicht mit derselben Regel geprüft. |
| Newsletter | `NewsletterCampaignResilienceTest` | Batchgrenze, Stale-Model-Doppelstart, Unique-Index, Claim, Suppression, ausgewählte Resume-Zustände | Kein harter Abbruch bei Sending mit Teilbestand; kein Sent+Failed-Wiederanlauf; kein Fehler nach Transportübergabe. |
| API/Sichtbarkeit | `PressReleaseApiTest`, `PressReleasePreviewTest`, `ReleaseDetailTest`, `CustomerCompanyContextTest` | Validierung und Soft-Delete-Ausschluss | Portal-only-Update nicht bis zum resultierenden Kategoriezustand geprüft. |
| Öffentliche Seiten | `CompanyNewsroomCacheObserverTest`, `NewsroomPageTest`, `GoneTombstoneTest`, `ReportedUrlResolverTest` | Cacheinvalidierung, vorgelagerte Sichtbarkeit, Host-/Sprachauflösung | Kein neuer Browserlauf; Kollisionen von `both` und portalbezogenen Einträgen bleiben gesonderter Randfall. |
| KI | `PressReleaseClassificationJobTest`, `PressReleaseContentScoreTest`, `PressReleaseSchedulingTest` | Änderung während Provideraufruf, Audit-Hash, Requeue und Scheduler-Fehlercode | Kein Edit zwischen Hashvergleich und Commit; kein roter regulärer Editor-Recheck einer terminierten PM. |
| Kontingent | `PressReleaseQuotaTest` | Expliziter Periodenwechsel, Resetwiederholung, gesperrter Verbrauch | Positiver NULL-Zähler nach Migration fehlt. |
| Abrechnung | `ManualInvoiceGenerationTest`, `ExpireGrandfatheringTest` | Vertragsgrenze, 50 Blocker, sequentieller Wiederanlauf | Parallelität, Verlängerung im Verarbeitungsfenster und Fehler vor Mail-Enqueue fehlen. |
| Architektur/Betrieb | `GlobalScopeGuardTest`, `PressReleaseTrackingTest` | Bekannte Scope-Ausnahmen und bestehender Aggregationslock | Keine semantische Analyse innerhalb erlaubter Dateien; kein Lockablauf während Verarbeitung. |
**Wichtig:** „Test vorhanden“ und „Test in diesem Audit bestanden“ sind unterschiedliche Aussagen. Für Letzteres liegt hier kein Nachweis vor.
### Kleine, abarbeitbare Nacharbeitsfolge
| Reihenfolge | Paket | Minimaler zusätzlicher Nachweis | Umfang |
|---|---|---|---|
| 1 | U01: Rechteentzug vollständig | Firmenfremder Autor; nach Downgrade PM-Edit und Live-Korrektur verweigert | M |
| 2 | U02/U03: KI-Version und Routing | Deterministischer Eingriff vor Commit; roter Recheck über regulären Editorpfad | M |
| 3 | U04: Kontingentübergang | Positiver NULL-Altbestand mit freiem Slot; Monatswechsel ohne Cron | S–M |
| 4 | U05/U06: Newsletter-Wiederanlauf | Vollständig Failed→Resume; harter Abbruch nach erster Auswahlseite | M |
| 5 | U07/U08: Versandnachweis erhalten | Fehler im MessageSent-Listener; Migration mit späterem Sent-Datensatz | M |
| 6 | U09/U10: Nebenwege schließen | Native 2FA-Routen; API-Portal-only-Update | S–M |
| 7 | U11/U12: Vertragsablauf robust | Überlappender Ablauf/Verlängerung; Zustellauftrag nach Enqueue-Fehler | M |
| 8 | U13 und Betriebsnachweise | Lock-TTL überschreiten; wirksame Migrationen/Workerwerte nachweisen | S–M |
S entspricht hier ungefähr einer Stunde bis einem halben Tag, M ungefähr einem bis drei Tagen, jeweils einschließlich sinnvoller Regressionen. Die Angaben sind Planungshilfen und keine Aufwandsgarantie.
Für einfache Logikfehler reichen deterministische Featuretests. Für U02, U06, U11 und U13 sollte mindestens ein gezielter MySQL-/Mehrprozessnachweis hinzukommen. Ein Test mit zwei nacheinander verwendeten veralteten Modelinstanzen bleibt wertvoll, darf aber nicht als tatsächlich parallel ausgeführter Worker-Test bezeichnet werden.
## 7. Weitere Verbesserungen und Abweichungen vom Plan
Diese Punkte sind von den 13 priorisierten Fehlern getrennt. Sie enthalten teilweise bewusste Produktentscheidungen und kleinere Betriebs- oder Wartbarkeitsgrenzen.
### 7.1 Herkunftsklassifizierung ist eine Heuristik, kein Herkunftsnachweis
`AuditCompanyMemberships.php:80–85` markiert jede erklärbare Responsible-Zuordnung mit `granted_via != auto` als Auto. Das umfasst auch eine bewusst manuell vergebene Zuordnung, deren E-Mail zufällig passt. Die fehlende historische Herkunft lässt sich allein aus einer heutigen Übereinstimmung nicht rekonstruieren.
Der Plan sieht eine einmalige Klassifizierung vor; diese ist daher nicht pauschal als Implementierungsverstoß zu bewerten. Vor Ausführung sollte die Kandidatenmenge aber nachvollziehbar geprüft werden. Der aktuelle Bericht listet hauptsächlich **nicht** erklärbare Zeilen, während `--classify` die erklärbaren umschreibt. Ein wiederholter Lauf nach neuen manuellen Vergaben kann deren Herkunft nachträglich verlieren. Eine Vorschau der tatsächlich umzustufenden Zeilen und eine begrenzte Bestandsmenge wären besser.
Die Rückstufung findet vereinbarungsgemäß erst beim nächsten Login statt. Bestehende Sessions erhalten dadurch keine sofortige Neuberechnung. Auch ein bestätigter E-Mail-Wechsel stößt in `ConfirmEmailChangeController` nicht selbst den Sync an. Das ist eine Grenze der gewählten Regel, kein entgegen der Entscheidung verlangter sofortiger Komplettentzug.
### 7.2 Inaktive Superadmins werden nicht in jedem Policy-Kurzschluss ausgeschlossen
Der neue globale `Gate::before` liefert für inaktive Superadmins `null`. `PressReleasePolicy::before()` und `CompanyPolicy::before()` liefern weiterhin allein aufgrund von `is_super_admin` `true`. Die regulären Panel-Gates und API-Aktivitätsmiddleware schützen die geprüften Einstiege; ein vollständiger globaler Deny ist das trotzdem nicht.
Empfehlung: Inaktivität als durchgängige Berechtigungsinvariante formulieren und direkte Policy-Prüfungen zusätzlich testen. Dieser Bericht behauptet daraus keinen bereits nachgewiesenen Weg an sämtlicher Routenmiddleware vorbei. Das Zusammenspiel von globalen Gates und Policy-Filtern ist in der [Laravel-12-Autorisierungsdokumentation](https://laravel.com/docs/12.x/authorization#policy-filters) beschrieben.
### 7.3 Score-Fingerprint bildet nicht alle bewerteten Eingaben ab
`ContentFingerprint.php:15–25` normalisiert Titel und Text für die Duplikaterkennung. Der Score verwendet daneben unter anderem Quellenlink, Untertitel, Bilder, Keywords und Kontakte; siehe `ContentScore/Drivers/DeterministicContentScoreDriver.php:35–57` sowie `OpenAiContentScoreDriver.php:121–127`. Solche Änderungen verändern den aktuellen Hash nicht. Auch Editor-Reprocessing wird teilweise nur bei Titel-/Textänderung ausgelöst.
Die neue Bindung schützt daher nur den definierten Textfingerprint, nicht jede Einflussgröße des Scores. Ein eigener Bewertungsfingerprint beziehungsweise eine Revisionsnummer sollte die tatsächlichen Eingaben umfassen. Duplikatgleichheit und exakte Analyseversion erfüllen unterschiedliche Zwecke. Diese Erweiterung ist bei der Nacharbeit zu U02 sinnvoll.
Außerdem wird bei einem schon vor Jobstart geänderten Text zunächst die neue Version analysiert, danach aber gegen den alten Dispatch-Hash verglichen und erneut eingeplant. Ein früher Versionsvergleich kann diesen unnötigen Provideraufruf vermeiden. Entscheidend bleibt, den Audit-Hash an die tatsächlich analysierte Fassung zu binden.
### 7.4 Architekturtest friert Dateien ein, nicht sichere Verwendungszwecke
Die Whitelist in `GlobalScopeGuardTest.php` enthält aktuell 40 Dateipfade; das Protokoll nennt 39. Relevanter als diese Zähldifferenz: Ein neuer unsicherer Aufruf innerhalb einer bereits erlaubten Datei bleibt unsichtbar. Auch eine anders formatierte Schreibweise kann die einfache Textsuche umgehen.
Unter den Ausnahmen befinden sich neben Admin-/Importpfaden auch öffentliche Legacy-Medienauflösung und weitere Services. Die Abnahmeaussage „nur Admin und Importer“ ist deshalb enger als die tatsächliche Regel. Die Whitelist ist ein brauchbarer Warnmechanismus, aber kein Beweis, dass sämtliche öffentlichen Soft-Delete-Pfade sicher sind. Ausnahmen sollten je Fundstelle fachlich begründet werden.
### 7.5 Resolver und tatsächliche Seitenauswahl sollten dieselbe Priorität verwenden
Der neue `ReportedUrlResolver` priorisiert kanonische Einträge und ID. `ReleaseDetailPageData` lädt passende Portal-/Both-Treffer dagegen mit `first()` ohne entsprechende Sortierung. Die normalen Tests mit eindeutigem Portal/Sprachpaar sind schlüssig. Bei gleichzeitig passendem `both`- und portalspezifischem Datensatz sollte zusätzlich geprüft werden, dass sichtbare Seite und gemeldeter Datensatz dieselbe PM meinen. Ohne einen solchen Kollisionsnachweis wird dies hier als Prüfergänzung und nicht als sicher eingetretener Fehlbefund geführt.
### 7.6 Fokusführung braucht noch einen Browsernachweis
Der neue Alpine-Code setzt den Fokus, behandelt Tab/Shift+Tab an den Grenzen und gibt den Fokus zurück. Die globale Cmd/Ctrl+K-Suche kann aber weiterhin bei offenem Login-Dialog den Fokus zur Suche verschieben; der Tab-Handler hängt am Dialog. Zudem ersetzt die eigene Tab-Schleife keine vollständige Prüfung von Hintergrundfokus und Screenreader-Navigation.
Gezielt prüfen: Öffnen per Tastatur, Vorwärts-/Rückwärtstabs, Escape, Fokus nach Schließen, Cmd/Ctrl+K während des Dialogs und schmale Ansicht. Aus dem statischen Code allein wird keine vollständige Barrierefreiheitsabnahme abgeleitet.
### 7.7 Dokumentierte Umsetzung ist stellenweise weiter gefasst als der Code
| Plan-/Protokollaussage | Tatsächlicher Stand | Bewertung |
|---|---|---|
| 1.4: Befehl mit `--dry-run` | Standardaufruf ändert keine Pivots, kann aber eine CSV schreiben; es gibt `--classify`, kein `--dry-run`. | Sinnvolle sichere Voreinstellung, CLI-Abweichung dokumentieren. |
| 1.8: drei Login-Abschlüsse in einen Service ziehen | Google-Zweig ergänzt; Abschlusslogik weiterhin auf mehrere Wege verteilt. | Funktionaler Fix vorhanden, Refactoringteil offen. |
| 4.2: `publish()` und Scheduler prüfen Inhaltsbindung | Schedulerprüfung vorhanden; `publish()` fehlt die Versionsprüfung. | Wesentliche unerfüllte Anforderung, U02. |
| 4.6: `Isolatable` und `--isolated` | Gemeinsamer Cache-Lock direkt im Command. | Grundsätzlich gute gleichwertige beziehungsweise breiter greifende Lösung; TTL-Grenze U13. |
| 5.2: `GrandfatheringTerminated` senden | Eigene `GrandfatheringExpired`-Mail eingeführt. | Fachlich nachvollziehbare Trennung, keine Beanstandung allein wegen anderer Klasse. |
| Rechnungslauf „ohne Tageskappe“ / gesamter Bestand | Default `--limit=500` erstellte Rechnungen pro Lauf. | Starvation behoben, Durchsatz weiterhin begrenzt; Beschreibung präzisieren. |
| E6: keine letzte MAN-Periode mehr | Vergleich erlaubt Rechnung, wenn `current_period_end == grandfathered_until`. | Entspricht der detaillierten `<`/`>=`-Regel, Kurzentscheidung bleibt auslegungsbedürftig. Gleichheitsfall fachlich ausdrücklich bestätigen. |
| Nur 6.6 bleibt offen | Weitere Abnahmefälle und Teile von 1.8/4.2 fehlen. | Abschlussformulierung nach den Restbefunden korrigieren. |
Die E-Mail-Änderung ohne 2FA verwendet für Passwortkonten das Passwort, bei reinen Magic-Link-Konten keinen zusätzlichen Nachweis. Diese Ausnahme ist im Umsetzungsprotokoll ausdrücklich erläutert. Sie wird hier nicht als versehentlich fehlende TOTP-Prüfung gewertet; ohne eingerichtete 2FA gibt es keinen solchen Faktor. Ein gewünschter frischer Postfachnachweis wäre eine zusätzliche Produktentscheidung.
## 8. Betriebs- und Migrationsnachweise
### 8.1 Lesend festgestellter Stand der angebundenen Datenbank
Boost konnte Datenbankabfragen ausführen, obwohl die lokale Sail-CLI nicht erreichbar war. Diese Zugänge sind daher als unterschiedliche Nachweisquellen zu behandeln. Die hier erreichbare Datenbank ist **nicht ohne weiteren Nachweis mit Produktion gleichzusetzen**.
Die Abfrage der Migrationshistorie für den 18.09.2026 lieferte nur die früheren Migrationen für Crawler-Hits, Index-Checks und Content-Hash/Canonical. Die sechs im Entwicklungsplan neu genannten Audit-Migrationen fehlten. Eine zusätzliche Prüfung von `information_schema` bestätigte:
| Erwartete Änderung | Beobachtung |
|---|---|
| `company_user.granted_via` | Spalte fehlt. |
| `ki_audits.content_hash` und `discarded_at` | Beide Spalten fehlen. |
| `users.press_release_quota_period` | Spalte fehlt. |
| `user_payment_options.billing_hold_reason` und `billing_hold_at` | Beide Spalten fehlen. |
| B-Tree-Index auf `contacts.email` | Neuer Index fehlt; vorhandener Mehrspalten-Fulltextindex ersetzt ihn nicht. |
| Unique `(newsletter_campaign_id, user_id)` | Neuer Unique-Index fehlt. |
Zusätzlich wurden ausschließlich aggregierte Werte gelesen: **470** Newsletter-Dublettengruppen, **470** zusätzliche Zeilen, **0** Gruppen mit späterem Sent-Nachweis bei nicht gesendeter ältester Zeile; **2** Nutzer mit positivem bisherigem Monatsverbrauch. Es wurden keine E-Mail-Adressen, Kundendaten oder Zugangsdaten in diesen Bericht übernommen.
**Folgerung:** Für diese Datenbank ist der neue Schemaumfang noch nicht nachgewiesen, sondern konkret nicht vorhanden. Wird der neue Code dagegen aktiviert, bevor die Migrationen erfolgen, sind unter anderem fehlende Spalten bei Firmenrechten, KI-Audits und Rechnungsauswahl zu erwarten. Aus den Commitnamen „Deploy A–E“ folgt kein Nachweis über einen tatsächlich erfolgten Produktivdeploy.
### 8.2 Vor einer Betriebsabnahme erforderliche Nachweise
1. Zielumgebung und veröffentlichte Codeversion eindeutig benennen; Migrationsergebnis derselben Umgebung dokumentieren.
2. U04 und U08 vor der Übernahme entsprechender Bestandsdaten klären. Newsletter-Altjobs während der Bereinigung kontrolliert berücksichtigen.
3. `company-memberships:audit` dort auswerten. Ungeklärte Bestandsrechte menschlich entscheiden; sie bleiben durch das Default `manual` zunächst wirksam. Eine abgelegte und gesichtete Ergebnisliste ist im Entwicklungsplan nicht nachgewiesen.
4. Erklärbare Herkunftskandidaten vor `--classify` prüfen und die Begrenzung aus Abschnitt 7.1 beachten.
5. Konfigurationscache aktualisieren, laufende Worker auf neuen Code bringen und die **effektiven** Queuewerte prüfen. ENV-Overrides können den Default 150 überschreiben. Jobtimeout muss unter `retry_after` liegen; siehe [Laravel-12-Queue-Dokumentation](https://laravel.com/docs/12.x/queues#job-expirations-and-timeouts).
6. Scheduler-Eintrag für `billing:expire-grandfathering` und den eigentlichen Schedulerbetrieb prüfen. Ein Eintrag in `routes/console.php` allein belegt keinen laufenden Cron.
7. Gezielt Rechteentzug, Google-2FA, Soft-Delete-Sichtbarkeit, Newsletterfehler/Wiederaufnahme und Terminveröffentlichung in einer isolierten Umgebung abnehmen.
8. Erst danach die entsprechenden fachlichen Blöcke als abgeschlossen markieren. Große Newsletter-Kampagnen insbesondere erst nach U05/U06 erneut freigeben.
Diese Schritte wurden als Prüfbedarf dokumentiert, **nicht ausgeführt**. Es wurden weder Migrationen gestartet noch Pivots klassifiziert, Newsletter versandt, Rechnungen erzeugt oder Verträge beendet.
## 9. Nachweisübersicht und Schutz bestehender Arbeit
| Nachweis | Ergebnis |
|---|---|
| Git-Diff und Commitzuordnung | 124 geänderte Dateien im Vergleich `24c1bbf..fb03fb1`; diese Zahl umfasst auch Dokumentation. |
| Plan-/Code-/Testabgleich | Alle sechs Blöcke und alle 28 Altbefunde eingeordnet; F21 und die ausdrücklich ausgeschlossenen Themen nicht erneut als Pflichtumfang eingeführt. |
| Sail-Testversuch | Vor Teststart gescheitert; kein eigener grüner Testlauf. |
| PHPStan / Build / Browser | In diesem Audit nicht ausgeführt. Dokumentierter früherer PHPStan-Erfolg bleibt Fremdnachweis aus dem Plan. |
| Datenbank | Nur SELECT-Abfragen, einschließlich Metadaten und aggregierter Bestandszahlen. Keine Änderungen. |
| Framework-Dokumentation | Boost-Suche lieferte „Invalid Request“; ergänzend offizielle Laravel-12-Dokumentation und lokale Vendor-Implementierung verwendet. |
| Lieferumfang | Ausschließlich diese neue Markdown-Datei. |
Zu Beginn waren bereits folgende fremde Arbeitsverzeichnisänderungen vorhanden: die Löschung von `docs/weiteres/Decision-Update Duplicate-Content & Duplicate-Checking.md` sowie die neue Datei `docs/weiteres/Decision-Update Verlinkung & Backlinks in Pressemitteilungen.md`. Beide gehören nicht zu diesem Audit und wurden unverändert belassen.
Die Priorität der weiteren Entwicklung sollte auf **wirksamem Rechteentzug, atomarer KI-Freigabe, korrektem Bestandsübergang und nachweisbarer Wiederaufnahme nach Fehlern** liegen. Der bisherige Plan hat die richtigen Problemfelder erfasst; die nächste Iteration sollte die beschriebenen Übergänge schließen und ihre Abnahmefälle ausdrücklich mitführen.

View file

@ -0,0 +1,499 @@
# Umsetzungs-Audit zur Entwicklung nach dem Audit vom 18.09.2026
**Prüfdatum:** 25.09.2026
**Geprüfter Code:** `d99f55e5556d601373a1bd251051c015cc9eda91`
**Vergleich:** `fb03fb166d08fe891ae5ab3a221c8a232b7c8366..d99f55e5556d601373a1bd251051c015cc9eda91` — 28 Commits, 271 geänderte Dateien, 16.950 hinzugefügte und 644 entfernte Zeilen einschließlich Dokumentation und weiterer Assets.
**Grundlagen:** [letzter Umsetzungs-Audit](UMSETZUNGSAUDIT-2026-09-18.md), [Entwicklungsplan mit Block 7 und Entscheidungen E7–E9](ENTWICKLUNGSPLAN.md), [Bildkennzeichnungsplan, Fassung 2 einschließlich Fortschreibung vom 25.09.](../../bildrechte/bildkennzeichnung-entwicklungsplan-2026-09-21.md), [Decision-Update vom 22.09.](../../bildrechte/Decision-Update_Bildrechte_Beweisarchiv_2026-09-22.md), [ursprünglicher Audit](AUDIT.md).
**Auftrag:** Änderungen seit dem letzten Audit, deren dokumentierte Umsetzung und die aktuelle Entwicklung prüfen. Ausschließlich diesen neuen Audit erstellen. Keine Änderungen an Anwendung, Tests, Konfiguration, Datenbestand oder vorhandenen Dokumenten.
## 1. Ergebnis und Einordnung
Die Entwicklung hat erhebliche Fortschritte gemacht. Mehrere Restfehler des letzten Audits sind gezielt korrigiert: automatisch erworbene Firmenrechte werden gemäß der revidierten Entscheidung entzogen, ungenutzte Fortify-Verwaltungsrouten sind gesperrt, Kontingent-Altwerte werden konsistent übernommen und API-Portalwechsel berücksichtigen vorhandene Kategorien. Die neuen Bildfunktionen haben gemeinsame Services, private Originale, versionierte Bestätigungen, Vorprüfungen, Korrekturprotokolle und ein separates verschlüsseltes Archiv erhalten. Die Aufteilung in dokumentierte Entwicklungsblöcke hilft bei der Nachverfolgung.
**Eine vollständige fachliche Abnahme der neuen Abläufe ist dennoch nicht gerechtfertigt.** Die meisten verbleibenden Probleme liegen zwischen einzelnen, für sich plausiblen Schritten:
- Die öffentliche Bildauslieferung berücksichtigt die neu eingeführten Bildfreigaben nicht durchgängig. Ein Admin-Upload an einer bereits veröffentlichten Meldung kann vor Kundenbestätigung erscheinen; ein zurückgehaltenes Bild bleibt über die Medienroute abrufbar.
- Die Archivlöschung kann einen inzwischen gesetzten Legal Hold übergehen. Fehlgeschlagene Dateilöschungen können außerdem als erfolgreiche Löschung protokolliert werden.
- KI-Ergebnis und zugehöriger Audit werden getrennt gespeichert. Dadurch bleibt trotz der neuen Hashprüfungen ein enges Fenster für eine automatische Veröffentlichung eines ungeprüften Inhaltsstands.
- Die Kundenbestätigung, Veröffentlichungssnapshots und einzelne Anonymisierungswege erfüllen die dokumentierten Anforderungen noch nicht vollständig.
- Neue beziehungsweise weiterentwickelte Auswertungen verwenden teilweise unterschiedliche Grundgesamtheiten oder lückenhafte Zeitintervalle.
- Die vorhandene Schutzprüfung gegen eine falsche Testdatenbank greift erst nach dem Laravel-Testaufbau und damit für `RefreshDatabase` zu spät.
Dieser Bericht enthält **22 priorisierte Befunde: 4 × P1 und 18 × P2**. Die Anzahl bezeichnet weder 22 neue Regressionen noch 22 beobachtete Produktionsvorfälle. Enthalten sind neue Fehler, präzisierte Restbefunde und im aktuellen Zusammenhang erstmals erkannte Bestandsfehler. Parallelitäts- und Storage-Fehlerfälle sind anhand ihrer konkreten Ausführungsreihenfolge belegt, in diesem Audit aber nicht dynamisch reproduziert worden. N21 setzt ausdrücklich einen veralteten Cachewert voraus; N22 setzt eine tatsächlich falsch aufgelöste Datenbankkonfiguration voraus.
### Bewertungsmaßstab
| Kennzeichnung | Bedeutung |
|---|---|
| P1 | Vor Abnahme beziehungsweise Aktivierung des betroffenen Sicherheits-, Veröffentlichungs-, Lösch- oder Testablaufs beheben. |
| P2 | Konkreter Fehler bei einem nachvollziehbaren Eingabe-, Zustands-, Fehler- oder Parallelitätsfall; gezielt nacharbeiten. |
| Im Code umgesetzt | Änderung und vorhandene Absicherung sind nachvollziehbar; kein unabhängig bestätigter grüner Testlauf dieses Audits. |
| Teilweise | Wesentliche Umsetzung vorhanden, aber ein fachlicher Endzustand oder Fehlerfall bleibt offen. |
| Bewusst aufgeschoben | Nach der aktuellen Entscheidung nicht Bestandteil der jetzt geschuldeten Umsetzung. |
| Betriebsnachweis offen | Code, lokale Dokumentation oder die angebundene Datenbank beweisen den Produktivzustand nicht. |
Die Bewertung des Beweisarchivs und der Bestätigungen ist ein technischer Abgleich mit den projektintern beschlossenen Regeln. Sie ist keine Prüfung der rechtlichen Wirksamkeit von AGB, Aufbewahrungsfristen oder Einwilligungen.
## 2. Vorgehen in abgeschlossenen Prüfschritten
1. **Vergleichsbasis und Entscheidungen:** letzten Audit, Block 7, Commitfolge, aktuelle Bildplanung und Decision-Update abgeglichen; ältere Entscheidungen von späteren Änderungen getrennt.
2. **Restbefunde U01–U13:** betroffene Services, Jobs, Policies, Middleware, Migrationen und Regressionstests erneut gelesen.
3. **Bildaufnahme:** Formular, API, Admin-Upload, Dateiprüfung, Originalspeicherung, Metadatenauswertung, Vision-Vorprüfung und Rechtebestätigung verfolgt.
4. **Bildausgabe und Lebenszyklus:** Einreichung, Veröffentlichung, direkte Medienroute, Credits, OpenGraph, Snapshot, Korrektur, Entfernung und Entwurfspurge als zusammenhängende Abläufe geprüft.
5. **Archiv und Bestätigungen:** Verschlüsselung, Zugriffswege, Legal Hold, Fristlöschung, Tombstones, Restore-Abgleich und Anbindung an Anonymisierung/Korrektur geprüft.
6. **Feeds, Canonical und Berichte:** Feed-Auswahl, Sichtbarkeitsangaben, Reader-Gruppierung, Canonical-Lebenszyklus, Alterskohorten und Indexverteilung geprüft.
7. **Entwicklungsqualität und Betrieb:** dokumentierte Teststände mit ihrem jeweiligen Zeitpunkt abgeglichen; Testaufbau und installierten Laravel-Code gelesen; ausschließlich lesende Umgebungs- und Datenbanknachweise eingeholt.
8. **Konsolidierung:** Befunde zusammengeführt, Doppelzählungen gleicher Ursachen reduziert und konkrete Abnahmetests je Befund formuliert.
Die Laravel-Review-Regeln aus `.agents/skills/laravel-best-practices/SKILL.md` wurden angewendet. Restbefunde im Backend, Archiv und Feed-/Berichtsbereich wurden zusätzlich getrennt gegengelesen. Framework-Verhalten wurde anhand der installierten Quellen und der versionsbezogenen Boost-Dokumentation geprüft.
### 2.1 Maßgebliche Entscheidungen
- **E7 ersetzt die frühere Entzugsentscheidung E2:** Eine rein automatisch erzeugte Firmenzuordnung wird beim Wegfall der Grundlage entfernt. Eine automatisch hochgestufte manuelle Mitgliedschaft darf auf ihre Teamrolle zurückfallen. `member` muss deshalb nicht nachträglich zur Leserolle umgebaut werden.
- **E8:** Nicht verwendete native Fortify-2FA-Verwaltungsrouten werden abgeschaltet; die Challenge bleibt erhalten.
- **E9:** U05–U07 sind bis zur Nutzung des Newsletter-Kampagnenmoduls ausdrücklich aufgeschoben. Das ist keine neue Regression und keine pauschale Sperre des gesamten Projekts.
- **Bildplan Fassung 2 und Decision-Update:** Unsigned Metadaten liefern in Phase 1 nur eine Vorbelegung. Eine vertrauensgebundene C2PA-Sperre gehört in Block I. `algorithmicallyEnhanced` soll keinen öffentlichen KI-Hinweis erzeugen.
- **O1 im fortgeschriebenen Bildplan:** Bilder nie veröffentlichter, abgelehnter Einreichungen bleiben zunächst für die Überarbeitung erhalten. Sie sollen später beim Purge beziehungsweise beim Löschen entfernt werden. Die ältere Forderung nach sofortiger Löschung bei jeder Ablehnung wird hier nicht als aktuelle Sollvorgabe verwendet.
- **O2:** Zustimmung je Konto und Dokumentversion mit gesondertem API-Endpunkt und HTTP 428 vor einer Einreichung ohne erforderliche Annahme.
- **Block I und J:** C2PA-Verifikation und Newsroom-Galerien sind spätere Arbeitspakete. Ihr Fehlen ist kein Umsetzungsfehler dieser Phase.
- **Block K:** Unbrauchbare Modellantworten sollen als Fehler protokolliert werden und den Upload passieren lassen. Das Modell soll keine verbindliche Bildrechteprüfung und keine automatische Änderung der KI-Kennzeichnung vornehmen.
### 2.2 Grenzen der Verifikation
Der alte Entwicklungsplan dokumentiert **1.930 grüne Tests und einen übersprungenen Test nach Block 7**, PHPStan ohne Meldung und saubere Formatierung. Der Bildplan dokumentiert **2.037 grüne Tests nach Block E** sowie spätere gezielte Testzahlen für F, G/H, K und die Adminansicht. Daraus folgt kein bestätigter Gesamtlauf auf dem hier geprüften HEAD vom 25.09.
`vendor/bin/sail ps` meldete, dass Docker oder Podman nicht läuft. Der gezielte Versuch
```text
vendor/bin/sail artisan test --compact tests/Feature/Images/PressReleaseImageUploaderTest.php tests/Feature/Images/ImageLifecycleTest.php tests/Feature/Archive/EvidenceArchiveTest.php
```
endete ebenfalls vor dem Teststart mit Exit-Code 1 und `Docker or Podman is not running.` Es wurden keine Tests als bestanden gewertet und keine Umgehung über direktes PHP verwendet. PHPStan, Build, Formatter, Browserbedienung, reale parallele MySQL-Prozesse und Queue-Abbrüche wurden nicht neu ausgeführt. Es wurden auch keine zusätzlichen Testdateien oder Prüfscripts angelegt.
Die reguläre Testkonfiguration setzt SQLite `:memory:` und eine synchrone Queue. Sie bildet weder MySQL-Sperrverhalten noch tatsächliche Queue-Zustellung vollständig ab. Zudem korrigiert **N22** die zu positive Aussage des letzten Audits, der zusätzliche Guard schütze zuverlässig vor einer gecachten Anwendungsdatenbank: Seine Position im Testaufbau erfüllt dieses Ziel nicht.
Boost war für Anwendungsinformationen, Dokumentationssuche und lesende Datenbankabfragen erreichbar, obwohl Sail-Befehle nicht ausführbar waren. Diese beiden Nachweiskanäle sind getrennt zu bewerten.
## 3. Abgleich des letzten Audits und der neuen Entwicklungsblöcke
### 3.1 Sämtliche Restbefunde U01–U13
| Alt-ID | Ergebnis dieser Nachprüfung | Begründung / verbleibender Punkt |
|---|---|---|
| U01 | Im Code behoben gemäß E7 | `ContactAccessService` entfernt alte `auto`-Zuordnungen und unterscheidet `auto-upgrade`. Tests prüfen Entzug, eigene Autorenschaft und erhaltene manuelle Rechte. |
| U02 | Teilweise behoben | Ergebnis-UPDATE ist an den Hash gebunden; automatisches Publish prüft unter Sperre. Ergebnis und Audit entstehen aber getrennt, siehe N03. |
| U03 | Ursprünglicher Ablauf behoben; Parallelitätsrest | Rote Review-Rechecks werden jetzt abgelehnt. Ändert sich der Inhalt nach dem Ergebnis-UPDATE, kann die neue Ablehnung den falschen Stand treffen, siehe N04. |
| U04 | Im Code behoben | Positive NULL-Altverbräuche werden in der Migration an den Monat gebunden; der Service zählt NULL im laufenden Monat korrekt weiter. Migration in der angebundenen Datenbank eingetragen. |
| U05 | Offen, bewusst aufgeschoben nach E9 | Vollständig fehlgeschlagene Kampagne endet weiterhin als Sent; Resume verlangt Sending. Vor Nutzung des Kampagnenmoduls bearbeiten. |
| U06 | Offen, bewusst aufgeschoben nach E9 | Harter Abbruch während des Empfängeraufbaus kann weiterhin unvollständigen Sending-Bestand hinterlassen. |
| U07 | Offen, bewusst aufgeschoben nach E9 | Fehler nach Transportübergabe können weiterhin den Empfängerclaim freigeben und Wiederholung auslösen. |
| U08 | Im Migrationscode behoben | Dublettenbereinigung bevorzugt Sent statt ausschließlich kleinster ID; Zustandskombinationen sind getestet. Migration in der angebundenen Datenbank eingetragen. |
| U09 | Im Code behoben gemäß E8 | Middleware sperrt sieben native Fortify-Verwaltungsrouten auch bei passwortbestätigter Session. Challenge bleibt erreichbar. |
| U10 | Ursprünglicher Fehler im Code behoben | Tatsächlicher Portalwechsel prüft auch die unverändert mitgeführte Kategorie; Tests decken unzulässige, passende und gemeinsame Kategorien ab. |
| U11 | Teilweise behoben | Vertragsbeendigung und Activity-Events sind unter Sperre erneut geprüft. Benachrichtigungsphase bleibt mehrfach einplanbar, siehe N14. |
| U12 | Ursprünglicher Verlust vor Einplanung behoben | Persistente offene Marker erlauben Wiederaufnahme nach Queue-Fehler. Einmalige Einplanung ist dadurch noch nicht garantiert, siehe N14. |
| U13 | Doppelzählung behoben; bedingte Wiederanlauflücke | Zeitbudget, DB-Markenvergleich und transaktionale Fortschreibung verhindern den alten Doppelzählungsfall. Ein veralteter Cachewert kann Folgeläufe stoppen, siehe N21. |
Die ursprünglichen F01–F28 sind im Audit vom 18.09. vollständig zugeordnet. Dieser Bericht schreibt deren relevante Restpunkte über U01–U13 fort; unveränderte Bereiche werden nicht allein wegen ihres Alters erneut als fehlerhaft eingestuft.
### 3.2 Neue Entwicklung und Umsetzungsqualität
| Bereich | Nachvollziehbar umgesetzt | Abnahmegrenze |
|---|---|---|
| A: Datenmodell und Uploader | Gemeinsamer v2-Uploader, privates Original mit Hash, unveränderliche Bestätigungstexte, getrennte Herkunfts- und Kundenangabe, Upload-Grenzen. | Zustandsprüfung im letzten Lock und Fehlerbehandlung nach Commit fehlen, N07/N08. |
| B: Kunden-/Adminoberfläche | v2-Manager, explizite Angaben, Magic-Link-Anforderung, Nachweisdetails und Admin-Korrekturwege. | Bestätiger und zulässiger PM-Status nicht passend autorisiert, N05; angezeigte Textversion nicht gebunden, N06. |
| C: API | v2-Pflichtfelder, gemeinsamer Service, explizite Textversion, Legacy-Bildupload abgeschaltet. | Statuswechsel während Upload/Prüfung, N07. |
| D: Öffentliche Kennzeichnung | Gemeinsame Credit-Erzeugung, Herkunft der Angaben, sichtbare Nachweiszeilen und Ausgabeanpassungen. | Öffentliche Bildfreigabe nicht einheitlich, N01. |
| E: Lebenszyklus | Publikationsmarker, Snapshot-Service, normaler Entfernungsweg, Backfill/Bereinigungsbefehle, private Nachweisroute. | Snapshot nicht vollständig konsistent, N09; Anonymisierung und Purge nicht vollständig angebunden, N12/N13. |
| F: Beweisarchiv | Separate Disk und Verschlüsselung, Hashprüfung, Zugriffsprotokoll, Zweckangabe, Fristen, Legal Hold, Tombstones und Restore-Command. | Hold-Race, stille Storage-Fehler und nicht atomare fachliche Anbindung, N02/N10/N11/N12. |
| G: Dokumentannahme | Versionierte Annahmen, gemeinsamer Submit-Trait, API 428 und Annahme-Endpunkt. | Browser bestätigt bei Versionswechsel unter Umständen ungesehenen neuen Text, N06. |
| H: OG und Teaser | Eigene OG-Variante mit eingebranntem Nachweis, Neuaufbau bei Korrektur, Teaser-Nachweise, lokale Schriftdateien. | Snapshot-/Metadatenübereinstimmung nicht gesichert, N09; Cache- und Ressourcenverhalten zusätzlich prüfen. |
| K: Bildvorprüfung | Verkleinerte, bereinigte Prüffassung, Stufenmodell, Hashbindung des Ergebnisses, Queue/Reject/Hint/Error, keine Modell-Sperre der KI-Angabe. | Antwortschema unvollständig validiert, N20; Laufzeit unter realen Uploads ungemessen. |
| Admin-Bilderliste vom 25.09. | Liste und zugehörige Tests vorhanden; gezielte UI-Fortschreibung. | Browser-/Tastaturabnahme nicht wiederholt; gemeinsame Bild- und Archivfehler wirken auch hier. |
| RSS und Feed-Protokoll | Feed-Auswahl begrenzt, Soft Deletes/Sichtbarkeit berücksichtigt, Newsroom-Prüfung vor Cache, Reader-Erkennung und Aufbewahrung ergänzt. | Reader-Zusammenfassung vermischt Feed-Identitäten, N16. |
| Canonical und Sichtbarkeit | Gemeinsamer Resolver, Zuordnung, Cache-Invalidierung/Warmup und Tests erweitert. | Lebenszyklus nach Wegfall der Erstquelle und Kundenanzeige inkonsistent, N17/N19. |
| Auswertungen | Altersreferenz korrigiert, Indexverteilung und Quellenbefund erweitert. | Kohortenlücken und unterschiedliche Grundgesamtheiten, N15/N18. |
## 4. Priorisierte Rest- und Folgebefunde
### N01 — P1: Öffentliche Bildauslieferung umgeht den neuen Freigabestatus
**Belege:** `app/Models/PressReleaseImage.php:281`, `app/Http/Controllers/Web/PressReleaseImageMediaController.php:18`, `app/Http/Controllers/Web/Concerns/AuthorizesPressReleaseMediaAccess.php`, `app/Services/PressRelease/Images/PressReleaseImageUploader.php:174`, `resources/views/livewire/components/press-release-images-manager-v2.blade.php:453`.
**Befund:** `publiclyVisible()` prüft nur `withheld_at`, nicht eine ausstehende Rechtebestätigung. Die direkte Medienroute prüft wiederum ausschließlich die Öffentlichkeit der zugehörigen PM und verwendet den Bildfilter überhaupt nicht. Admins dürfen Bilder an bereits veröffentlichten Meldungen ergänzen; ihr Upload setzt bewusst noch keine Kundenbestätigung.
**Konkrete Fälle:** Ein Admin lädt ein unauffälliges Bild an einer öffentlichen PM hoch. `rights_confirmed_at` bleibt NULL, `withheld_at` ebenfalls. Das Bild kann bereits in der öffentlichen Ausgabe erscheinen. Wird ein Bild dagegen zur Prüfung zurückgehalten, verschwindet es aus der gefilterten Seitenansicht, bleibt aber unter seiner bekannten direkten Medienadresse öffentlich abrufbar. Der bestehende öffentliche Cache-Header kann eine bereits ausgelieferte Fassung zusätzlich weiter verfügbar halten.
**Auswirkung:** Die im Plan zugesagte Sperre bis zur Kundenbestätigung beziehungsweise Freigabe wird nicht über alle Ausgabewege durchgesetzt. Der Befund betrifft ausgelieferte Bildvarianten; er behauptet keinen öffentlichen Zugriff auf das separat gesicherte Original.
**Empfehlung:** Einen gemeinsamen fachlichen Zustand für öffentlich auslieferbare Bilder definieren und in Seitenabfragen, OG-Auswahl und Mediencontroller anwenden. Für v2 müssen Bestätigung und fehlende Zurückhaltung zusammenpassen; der bewusst anders behandelte Altbestand muss explizit bleiben. Den Fall „Bild zu bereits öffentlicher PM hinzufügen“ als eigene Freigabe behandeln.
**Abnahmetest:** Admin-Upload an Published-PM ist vor Kundenbestätigung auf Seite, OG und direkter Medienroute gesperrt. Ein zuvor öffentliches, danach zurückgehaltenes Bild ist bei neuen Serverabrufen gesperrt. Berechtigte interne Vorschau bleibt gesondert testbar; bestätigte freigegebene Bilder funktionieren weiter.
### N02 — P1: Archiv-Purge kann einen inzwischen gesetzten Legal Hold übergehen
**Belege:** `app/Services/Archive/EvidenceArchive.php:164`, `:195`, `:269`; vorhandene Hold-Tests in `tests/Feature/Archive/EvidenceArchiveTest.php`.
**Befund:** Der Purge lädt fällige Einträge vorab. `tombstone()` verwendet anschließend die geladenen Instanzen ohne `lockForUpdate()` und ohne erneute Prüfung von Hold, Frist oder Löschzustand. `setLegalHold()` schreibt unabhängig davon.
**Ablauf:** Purge selektiert einen fälligen Eintrag → Admin setzt erfolgreich Legal Hold → Purge arbeitet mit dem alten Objekt weiter, löscht die Payload und setzt `deleted_at`. Die Zeile kann anschließend zugleich Legal Hold und eine bereits gelöschte Payload ausweisen.
**Auswirkung:** Irreversibler Verlust eines Nachweises trotz inzwischen aktivierter Aufbewahrungssperre. Ein vor dem Purge gesetzter Hold funktioniert; genau der konkurrierende Übergang fehlt in der Absicherung.
**Empfehlung:** Hold-Änderung und Löschentscheidung über dieselbe Zeilensperre koordinieren. Vor der Dateilöschung den aktuellen Datensatz und sämtliche Löschbedingungen erneut prüfen. Eine Transaktion um ein unverändert altes Objekt genügt nicht.
**Abnahmetest:** Zwei getrennte DB-Verbindungen: Auswahl durch Purge, danach bestätigter Hold, anschließend Fortsetzung des Purge. Payload und aktiver Eintrag müssen erhalten bleiben. Auch Fristverlängerung und zwei Purge-Läufe gegeneinander testen.
### N03 — P1: Erste KI-Freigabe ist zwischen Ergebnis-UPDATE und Audit nicht atomar
**Belege:** `app/Jobs/ClassifyPressRelease.php:103`, `:145`; `app/Services/PressRelease/PressReleaseService.php:307`, `:393`; `app/Console/Commands/PublishScheduledPressReleases.php:66`.
**Bezug:** Präzisierter Rest von U02; die neu eingebauten Hashprüfungen sind grundsätzlich eine Verbesserung.
**Befund:** Das bedingte UPDATE speichert die grüne Klassifikation für H1. Der Audit mit dem zugehörigen Hash wird danach separat angelegt. Die Veröffentlichung akzeptiert einen fehlenden Audit-Hash aus Gründen der Legacy-Kompatibilität.
**Ablauf:** Bei einer fälligen, erstmals klassifizierten Review-PM schreibt der Job Grün für H1. Vor dem Audit-INSERT speichert ein Editor H2. Der Scheduler findet die grüne PM. Mangels Audit-Hash passiert H2 die Prüfung und kann veröffentlicht werden, sofern die übrigen Gates erfüllt sind. Erst danach entsteht der H1-Audit.
**Auswirkung:** Ein neuer Zwischenzustand wird wie ausdrücklich erlaubter auditloser Altbestand behandelt. Das Problem ist damit nicht nur eine Kritik an der dokumentierten Legacy-Ausnahme.
**Empfehlung:** Ergebnis und zugehörigen Prüfnachweis gemeinsam atomar speichern. Automatische Freigabe an einen eindeutig aktuellen, persistenten Nachweis binden; Legacy-Fälle separat kennzeichnen. Dasselbe Schreibmuster beim Content-Score mitprüfen.
**Abnahmetest:** Änderung nach erfolgreichem Ergebnis-UPDATE, aber vor Audit-INSERT; zeitgleich Scheduler-Publish ausführen. H2 darf nicht mit H1-Freigabe erscheinen. Der bestehende Test für eine Änderung vor dem UPDATE deckt dieses Fenster nicht ab.
### N04 — P2: Roter Recheck kann inzwischen korrigierten Inhalt ablehnen
**Belege:** `app/Jobs/ClassifyPressRelease.php:157`, `:187`; `app/Services/PressRelease/PressReleaseService.php:206`, `:519`.
**Bezug:** Ursprünglicher Stillstand aus U03 behoben; der neue automatische Ablehnungsweg hat eine zusätzliche Parallelitätslücke.
**Befund und Ablauf:** Rot für H1 wird erfolgreich gespeichert. Danach korrigiert ein Editor den Text zu H2. Der Recheck lädt die PM neu, prüft aber nur Review und Manual Hold. `reject()` prüft unter seiner Sperre ebenfalls nur den Status, nicht den erwarteten Inhalts-Hash. H2 wird mit der Begründung für H1 abgelehnt. Ein später grüner `route:false`-Recheck hebt diese Ablehnung nicht automatisch auf.
**Auswirkung:** Korrigierte Einreichungen landen fälschlich im Ablehnungszustand; Status und Prüfgrund beziehen sich auf verschiedene Fassungen.
**Empfehlung:** Den erwarteten Hash bis in die automatische Ablehnungs-/Hold-Entscheidung mitführen und dort unter Sperre prüfen. Überholte Entscheidungen verwerfen und den aktuellen Stand neu bewerten.
**Abnahmetest:** Editoränderung nach rotem Ergebnis-UPDATE, vor Reject-Lock. Anschließend grünen Recheck ausführen und den endgültigen PM-Status sowie die zugehörige Begründung prüfen.
### N05 — P2: Nachträgliche Bildbestätigung prüft die falsche Berechtigung
**Belege:** `resources/views/livewire/customer/press-releases/show.blade.php:117`, `app/Policies/PressReleasePolicy.php:39`, `:98`, `app/Services/PressRelease/Images/CustomerImageConfirmation.php:33`, `:74`.
**Befund:** Der Magic-Link wird an den Kontoinhaber der Meldung geschickt. Die eigentliche Aktion verlangt jedoch die allgemeine PM-Bearbeitungsberechtigung. Diese erlaubt Teammitgliedern die Bearbeitung von Draft/Rejected/Review, verweigert normalen Kunden aber Published. Der Bestätigungsservice selbst vergleicht den Kunden nicht mit dem vorgesehenen Kontoinhaber.
**Konkrete Fälle:** Der Kontoinhaber folgt dem angeforderten Bestätigungslink für ein Admin-Bild an seiner bereits veröffentlichten PM und erhält beim Bestätigen 403. Ein anderes berechtigtes Firmenmitglied kann dagegen bei einer nicht veröffentlichten PM die Aktion ausführen, obwohl die dokumentierte Bestätigung an den Kontoinhaber adressiert ist.
**Auswirkung:** Der erlaubte Admin-Bildtausch kann vom richtigen Kunden nicht abgeschlossen werden; zugleich ist die Identität des vorgesehenen Bestätigers nicht durchgängig abgesichert.
**Empfehlung:** Eine eigene Berechtigung für die konkrete Bildbestätigung einführen: erwarteter Kunde, konkrete PM/Bildzuordnung, ausstehende Bestätigung und geeigneter Zustand. Falls vertretungsberechtigte Teammitglieder fachlich erwünscht sind, diese Regel ausdrücklich ändern und protokollieren; nicht zufällig von `update` erben.
**Abnahmetest:** Richtiger Kontoinhaber bestätigt einen Admin-Upload an Published-PM erfolgreich; fremdes Firmenmitglied und anderer Kunde scheitern nach der geltenden Regel; bereits bestätigtes oder inzwischen ersetztes Bild lässt sich nicht erneut als aktuelles Bild bestätigen.
### N06 — P2: Browserbestätigungen sind nicht an die angezeigte Textversion gebunden
**Belege:** `resources/views/livewire/components/press-release-images-manager-v2.blade.php:230`, `:333`; `resources/views/livewire/customer/press-releases/show.blade.php:137`; `app/Livewire/Concerns/InteractsWithAiDeclaration.php:63`; `app/Services/Legal/LegalAcceptanceGate.php`.
**Befund:** Die Oberfläche zeigt den beim Rendern aktuellen Bestätigungstext. Beim Speichern liest der Server jedoch erneut `current()` beziehungsweise die dann erforderlichen Dokumente und verbindet diese mit einem bloßen Bestätigungsboolean. Die vorher angezeigte Version wird nicht als verbindlicher Zustand mitgeführt.
**Ablauf:** Kunde öffnet den Dialog mit Version A. Zwischenzeitlich wird B aktiviert. Kunde bestätigt den noch sichtbaren Text A. Der Server dokumentiert B. Das betrifft Bild-Upload, nachträgliche Bildbestätigung und das allgemeine Submit-Zustimmungsgate. Die API mit ausdrücklich angegebener Version ist hier die bessere vorhandene Vorlage.
**Auswirkung:** Der gespeicherte Annahmenachweis kann einen Wortlaut ausweisen, den die Person nicht gesehen hat. Dies ist ein Fehler in der technischen Nachweisbindung unabhängig von einer rechtlichen Bewertung.
**Empfehlung:** Angezeigte IDs/Versionen gegen Manipulation geschützt mitführen, beim Submit auf Aktualität vergleichen und bei Versionswechsel den neuen Wortlaut erneut bestätigen lassen. Zusätzlich fehlende verpflichtende Dokumente als Konfigurationsfehler behandeln: Das aktuelle `filter()` entfernt nicht auflösbare Dokumenttypen sonst aus der Anforderung.
**Abnahmetest:** Dialog mit A rendern, B aktivieren, alten Dialog absenden. Es darf keine Annahme von B entstehen. Nach erneutem Anzeigen und Bestätigen muss B korrekt gespeichert werden. Fehlendes Pflichtdokument darf bei aktivem Gate nicht still passieren.
### N07 — P2: Langer Upload kann den erlaubten PM-Status überholen
**Belege:** `app/Http/Controllers/Api/V1/PressReleaseImageController.php:47`, `resources/views/livewire/components/press-release-images-manager-v2.blade.php:197`, `app/Services/PressRelease/Images/PressReleaseImageUploader.php:110`, `:153`.
**Befund:** Formular/API prüfen den erlaubten Bearbeitungsstatus vor Vorprüfung, Originalspeicherung und Variantenerzeugung. Unter der abschließenden PM-Sperre prüft der gemeinsame Uploader nur, ob bereits ein Bild existiert. Der inzwischen gültige Status und die aktorspezifische Erlaubnis werden nicht erneut geprüft.
**Ablauf:** Kunde startet einen Upload an einer Draft-PM ohne Bild. Während der Bildprüfung wird die PM über einen zweiten Request eingereicht und gegebenenfalls veröffentlicht. Danach hängt der Upload das Bild an den inzwischen anderen Zustand. Ein ausstehender Bildprüfungsfall kann so erst nach Durchlaufen des Publish-Gates entstehen. Beim Anhängen an eine bereits veröffentlichte Meldung wird im Uploader auch kein Veröffentlichungssnapshot erzeugt.
**Auswirkung:** Die Regel „Kunden dürfen Bilder nur bei Draft/Rejected ändern“ ist unter Parallelität nicht zuverlässig. Veröffentlichungsnachweis und Bildfreigabe können dem tatsächlichen Bildbestand hinterherlaufen.
**Empfehlung:** Unter dem vorhandenen Lock den aktuellen Status und die passende Actor-Regel erneut validieren. Admin-Änderungen an Published explizit von Kundenänderungen trennen und den erforderlichen Publikations-/Bestätigungsablauf auslösen.
**Abnahmetest:** PM während der simulierten Vorprüfung auf Review/Published wechseln lassen. Der Kunden-Upload muss ohne verbleibende Dateien/Zeilen abbrechen. Ein erlaubter Admin-Fall benötigt dagegen den vorgesehenen vollständigen Folgeablauf.
### N08 — P2: Cachefehler nach Commit löscht ein bereits gespeichertes Bildoriginal
**Belege:** `app/Services/PressRelease/Images/PressReleaseImageUploader.php:153`, `:214`, `:221`; `app/Support/WebCaches.php`.
**Befund:** Der Uploader beendet die Datenbanktransaktion und invalidiert anschließend innerhalb desselben äußeren `try` öffentliche Caches. Jeder dort auftretende `Throwable` löst die allgemeine Dateibereinigung aus. Diese entfernt Derivate und das Original, obwohl Bildzeile und gegebenenfalls Bestätigung bereits committed sind.
**Ablauf:** Upload an einer veröffentlichten PM wird gespeichert → Cache-Backend schlägt bei Invalidierung fehl → Catch löscht die zugehörigen Dateien → DB verweist weiter auf diese Dateien.
**Auswirkung:** Ein vorübergehender Cachefehler wird zu dauerhaft fehlenden Medien und verlorenem Beweisoriginal. Ein normaler Request-Retry kann zudem am bereits vorhandenen Bild scheitern.
**Empfehlung:** Bereinigung vor einem erfolgreichen Commit von nachgelagerten Aktionen trennen. Nach Commit keine Dateien des erfolgreich gespeicherten Bildes durch allgemeines Fehlerhandling löschen; Cache-Invalidierung separat wiederholbar behandeln.
**Abnahmetest:** Cache-Invalidierung nach erfolgreichem DB-Commit gezielt werfen lassen. Persistierter Bilddatensatz und Original müssen konsistent erhalten bleiben; ein erneuter Invalidierungsversuch muss möglich sein.
### N09 — P2: Veröffentlichungssnapshot kann veraltet oder nur scheinbar vorhanden sein
**Belege:** `app/Services/PressRelease/Images/PublishedImageSnapshot.php:43`, `:61`, `:79`; `app/Services/PressRelease/Images/ImageCorrectionService.php:103`; `config/filesystems.php:33`.
**Befund:** Die Deduplizierung verwendet ausschließlich den Hash der Cover-Bildbytes. Nach einer Credit-/KI-Korrektur bleiben diese Bytes gleich; `capture()` kehrt früh zurück, obwohl der JSON-Nachweis eine andere Kennzeichnung enthalten müsste. Der neu aufgebaute OG-Nachweis und der gespeicherte Snapshot können auseinanderlaufen. Zusätzlich werden beide `put()`-Rückgaben ignoriert, bevor Pfad und Hash als erfolgreich gespeichert markiert werden.
**Konkrete Fälle:** Nur den Credit eines veröffentlichten Bildes ändern: öffentliche Ausgabe/OG ändern sich, Snapshot-JSON bleibt alt. Oder die lokale Disk liefert bei Bild/JSON-Schreiben `false`: Der Datensatz behauptet einen Snapshot; ein späterer Aufruf kann wegen identischem Hash ohne Reparatur abbrechen.
**Auswirkung:** Der Nachweis beschreibt nicht zuverlässig die aktuelle veröffentlichte Fassung beziehungsweise verweist auf unvollständige Dateien.
**Empfehlung:** Bildbytes und nachweisrelevante Metadaten gemeinsam versionieren. Beide Dateien erfolgreich schreiben und verifizieren, bevor die DB-Marke gesetzt wird. Fehlende oder unvollständige Snapshots beim erneuten Aufruf reparieren; historische Fassungen bei Bedarf getrennt erhalten.
**Abnahmetest:** Reine Credit- und KI-Korrektur muss passenden neuen Nachweis erzeugen. Jeweils `put(false)` für Bild und Sidecar simulieren; kein falscher Erfolgsmarker, anschließend erfolgreicher Wiederholungsversuch.
### N10 — P2: Archiv meldet fehlgeschlagene Löschung und Tombstone-Export als Erfolg
**Belege:** `app/Services/Archive/EvidenceArchive.php:225`, `:253`, `:269`; `config/filesystems.php:52`; `app/Console/Commands/ArchiveExportTombstones.php`, `app/Console/Commands/ArchivePurge.php`.
**Befund:** Die Archive-Disk arbeitet mit `throw=false`. Beim Purge wird `delete()` nicht ausgewertet, anschließend aber `deleted_at` gesetzt. Beim Restore-Abgleich steigen Erfolgszähler ebenfalls unabhängig vom Löschresultat. Der Export des Tombstone-Stands ignoriert `put()` und meldet trotzdem die Anzahl exportierter Einträge.
**Auswirkung:** Eine Datei kann physisch weiter existieren, während das Archiv ihre Löschung behauptet und sie künftig vom regulären Purge ausschließt. Ein fehlender aktueller Tombstone-Export kann beim Restore unbemerkt bleiben. Scheduler-Fehlerbehandlung auf Basis des Exit-Codes greift bei solchen scheinbaren Erfolgen nicht.
**Empfehlung:** Dateioperationen ausdrücklich auswerten und Fehler als Fehler behandeln. Tombstone und Erfolgszähler erst nach bestätigter Löschung setzen; Teilfehler wiederholbar halten. Export atomar ersetzen und bei Fehlschlag einen Fehlerstatus liefern.
**Abnahmetest:** `delete(false)` und `put(false)` getrennt für Purge, Restore und Export simulieren. Keine falsche Erfolgsmeldung, kein endgültiger Ausschluss einer noch vorhandenen Payload vom Wiederholungsversuch.
### N11 — P2: Fachliche Änderung und Archivnachweis können dauerhaft auseinanderfallen
**Belege:** `app/Services/PressRelease/AnonymizationService.php:152`, `:160`; `app/Services/PressRelease/CorrectionService.php:155`, `:185`, `:239`; `app/Services/PressRelease/PressReleaseService.php:549`, `:585`; `app/Services/Archive/EvidenceArchive.php:54`, `:78`, `:82`.
**Befund:** Mehrere fachliche Änderungen werden committed, bevor ihr Archiveintrag erzeugt wird. Scheitert die Archivierung, ist die Änderung bereits wirksam; eine Wiederholung kann wegen des neuen Zustands nichts mehr ändern oder den alten Vorher-Stand nicht mehr rekonstruieren. Umgekehrt schreibt `record()` die Payload vor der Archivzeile. Ein anschließender DB-Fehler oder äußerer Rollback kann eine nicht registrierte Datei hinterlassen.
**Auswirkung:** Änderungsereignisse ohne vollständigen Nachweis oder Payloads ohne verwaltete Frist. Eine bloße SQL-Transaktion um sämtliche Schritte würde die Dateisystemoperationen nicht rückgängig machen.
**Einordnung:** Bei fehlendem Archivschlüssel ist das derzeitige Weiterlaufen ausdrücklich implementiert und getestet. In diesem Audit wurde kein fehlender Schlüssel der Umgebung festgestellt. Auch bei korrekt konfiguriertem Schlüssel bleiben jedoch Storage-/DB-Ausfälle und fehlende Wiederholbarkeit relevant.
**Empfehlung:** Einen persistenten, idempotenten Archivierungsauftrag mit dem benötigten Vorher-Zustand in der fachlichen Transaktion anlegen. Payload-Erzeugung, Registrierung und Bereinigung verwaister Dateien wiederholbar gestalten. Fachliche Löschung und Archivverfügbarkeit benötigen eine ausdrücklich definierte Fehlerstrategie.
**Abnahmetest:** Archivdisk beim ersten Versuch ausfallen lassen, danach Wiederholung durchführen: genau ein vollständiger Nachweis mit ursprünglichem Vorher-Stand. Separat DB-Fehler nach erfolgreichem Payload-Schreiben und äußeren Rollback simulieren; keine dauerhaft unverwaltete Datei.
### N12 — P2: Anonymisierung entfernt Bilder am gemeinsamen Archiv-Lebenszyklus vorbei
**Belege:** `app/Services/PressRelease/AnonymizationService.php:135`, `:160`; `app/Services/PressRelease/Images/PressReleaseImageRemover.php:34`; `app/Services/Archive/PressReleaseArchiver.php`; `app/Http/Controllers/Admin/PressReleaseImageEvidenceController.php:42`; `routes/admin.php:49`, `:137`.
**Befund:** Die Anonymisierung löscht Bildderivate und setzt direkt Soft Delete. Sie verwendet nicht den neuen Remover, der Original, Snapshot und Bestätigung in das Bildarchiv überführt. Der anschließend erzeugte Anonymisierungsnachweis enthält den Textänderungsstand, ersetzt aber keinen vollständigen Bildnachweis. Lokale Originale und Snapshots bleiben bestehen.
**Auswirkung:** Für diesen Entfernungspfad fehlen regulärer Bildarchiveintrag und dessen Fristverwaltung. Die zurückbleibenden Nachweise sind bei weiterhin vorhandener PM über die allgemeine Admin-Nachweisroute mit `withTrashed()` abrufbar, statt ausschließlich über den engeren Archivzugang. Eine öffentliche Auslieferung dieser Originale wird damit nicht behauptet.
**Empfehlung:** Die Bildentfernung im Anonymisierungsablauf an denselben zustandsabhängigen Lebenszyklus anbinden, mit korrektem Anlass und Bezug zum Antrag. Nach erfolgreicher Überführung lokale Beweisdateien entfernen und Zugriffsregeln konsistent halten.
**Abnahmetest:** Veröffentlichtes v2-Bild über Anonymisierung entfernen: vollständiger Bildarchiveintrag, passende Frist/Anlass, kein lokales Original mehr, Zugriff ausschließlich über vorgesehenen Archivweg. Nie veröffentlichtes Bild entsprechend der Sofortlöschregel prüfen.
### N13 — P2: Aufgeschobene Löschung abgelehnter Uploads hat keinen automatischen Endpunkt
**Belege:** O1 und E.3 im aktuellen Bildplan; `app/Console/Commands/PurgeExpiredPressReleaseDrafts.php:28`, `:45`.
**Befund:** O1 erlaubt, Bilder abgelehnter, nie veröffentlichter Einreichungen zur Überarbeitung zunächst zu behalten. Der als späterer Bereinigungsweg genannte Command selektiert jedoch ausschließlich `Draft`, nicht `Rejected`.
**Ablauf:** Eine neue Einreichung mit v2-Bild wird abgelehnt und nie wieder bearbeitet. Auch nach Ablauf der regulären Entwurfsfrist bleibt sie im Status Rejected außerhalb der Auswahl. Nur ein zusätzliches manuelles Löschen beendet die Aufbewahrung.
**Auswirkung:** Die erlaubte vorübergehende Speicherung hat für diesen Zustand keine automatische zeitliche Begrenzung. Das ist nicht die Forderung, die bewusst geänderte Sofortlöschung bei Ablehnung wieder einzuführen.
**Empfehlung:** Abgelehnte, nie veröffentlichte Einreichungen in eine ausdrücklich definierte Bereinigung aufnehmen; Ausgangsdatum und Frist festlegen. Historische `rejected`-Datensätze mit früherer Veröffentlichung getrennt behandeln.
**Abnahmetest:** Alter Rejected-v2-Datensatz ohne frühere Veröffentlichung wird samt Dateien und Bestätigung bereinigt; frischer Rejected-Datensatz bleibt erhalten; ehemals veröffentlichter Altbestand wird nicht fälschlich hart gelöscht.
### N14 — P2: Vertragsablauf kann weiterhin doppelte Kundenmails einplanen
**Belege:** `app/Console/Commands/ExpireGrandfathering.php:151`, insbesondere Queue-Einplanung vor Marker-UPDATE; `tests/Feature/Billing/ExpireGrandfatheringTest.php`.
**Bezug:** Rest von U11 bei grundsätzlich verbesserter Wiederaufnahme aus U12.
**Befund:** Zwei Läufe können dieselben noch offenen Benachrichtigungsmarker laden. Beide planen die Mail ein und setzen den Marker erst danach. Ein zweiter UPDATE mit null betroffenen Zeilen verhindert die bereits erfolgte Einplanung nicht. Ein Abbruch nach Queue-Insert vor Marker führt ebenfalls zur erneuten Einplanung.
**Auswirkung:** Doppelte Ablaufbenachrichtigungen, obwohl Vertragsbeendigung und Activity-Event inzwischen korrekt gegen Überlappung abgesichert sind.
**Empfehlung:** Eindeutig beanspruchbaren persistenten Benachrichtigungsauftrag verwenden und Queue-Einplanung/Zustellung mit einer Idempotenzstrategie verbinden. Ein nachträgliches `whereNull()` allein löst den Übergang nicht.
**Abnahmetest:** Zwei Läufe mit gleichzeitig offenen Markern sowie Abbruch unmittelbar nach erfolgreicher Einplanung. Der bestehende Überlappungstest mit bereits gesetztem Benachrichtigungsmarker deckt diese Situationen nicht ab.
### N15 — P2: Alterskohorten lassen zwischen ihren Intervallen ganze Tage aus
**Belege:** `app/Services/PressRelease/ViewDistributionReport.php:123`; [gespeicherte Kohortenauswertung](../2026-09-nutzergewinnung/basiswert-0.1-kohorten.json).
**Befund:** Die untere Datumsgrenze einer Kohorte wird mit `reference - (upperDays - 1)` gebildet, während die nächste Kohorte erst bei `reference - upperDays` anschließt. Beispielsweise gehört eine 6,5 Tage alte Meldung weder zu „0–6“ noch zu „7–13“. Dasselbe gilt an den weiteren Übergängen 13/14, 20/21, 27/28, 55/56 und 90/91.
**Vorhandener Datenbeleg:** Die gespeicherte Auswertung enthält für Presseecho 1.804 Meldungen seit Relaunch, aber nur 1.616 in der Summe der Kohorten: 188 beziehungsweise rund 10,4 % fehlen. Für Businessportal24 stehen 1.847 Meldungen nur 1.651 Kohorteneinträgen gegenüber: 196 beziehungsweise rund 10,6 %. Diese Werte stammen aus dem Repository-Export, nicht aus einer neuen Produktionsmessung.
**Auswirkung:** Kohortenmittelwerte und Mediane beruhen auf unvollständigen Teilpopulationen. Die neue Korrektur des Referenzdatums gegenüber `--to` ist sinnvoll, schließt die Intervalllücken aber nicht.
**Empfehlung:** Lückenlose, nicht überlappende Intervalle verwenden, beispielsweise Alter `[0,7)`, `[7,14)` usw. Die Summe über alle Kohorten gegen die zugehörige Grundgesamtheit prüfen und gespeicherte Basiswerte danach neu erheben.
**Abnahmetest:** Werte unmittelbar vor, auf und nach jeder Grenze sowie mittig im bislang fehlenden Tag. Jede zulässige Meldung muss genau einer Kohorte angehören; Kohortensumme muss zur Segmentanzahl passen.
### N16 — P2: Reader-Bericht fasst unterschiedliche Portal-/Sprachfeeds zusammen
**Belege:** `app/Services/PressRelease/FeedFetchReport.php:35`, zum Vergleich `:69`.
**Befund:** `byReader()` gruppiert nach Reader, Feedtyp und Feed-Schlüssel, aber nicht nach Portal und Sprache. `topFeeds()` berücksichtigt diese beiden Dimensionen dagegen.
**Beispiel:** Derselbe Reader meldet 12 Abonnenten für den deutschen Portalfeed von Portal A und 20 für den entsprechenden Feed von Portal B. Bei sonst identischen Schlüsseln entsteht ein Feed mit 20 Abonnenten statt zwei Feeds mit einer Summe von 32 nach der dokumentierten Berechnungsregel.
**Auswirkung:** Feedanzahl und gemeldete Abonnentensummen sind zu klein und zwischen Berichten widersprüchlich. Auch eine korrekt berechnete Summe wäre keine Zahl eindeutig verschiedener Menschen; Reader-Abonnenten können sich überschneiden.
**Empfehlung:** Eine einheitliche Feed-Identität aus Typ, Schlüssel, Portal und Sprache für alle Berichte verwenden. Die Kennzahl weiterhin als gemeldete Feed-Abonnements ausweisen.
**Abnahmetest:** Gleicher Reader/Feed-Schlüssel über zwei Portale und zwei Sprachen; Abrufe summieren sich, Feeds bleiben getrennt, Abonnenten-Maximum gilt nur innerhalb desselben Feeds.
### N17 — P2: Kundenansicht behauptet Sitemap-Aufnahme für ausgeschlossene Kopien
**Belege:** `app/Services/PressRelease/VisibilityStats.php:159`, `app/Http/Controllers/Web/SitemapController.php:149`, `resources/views/livewire/customer/press-releases/show.blade.php:1644`.
**Befund:** `in_sitemap_since` wird für jede sichtbare Meldung mit öffentlichem Link auf das Veröffentlichungsdatum gesetzt. Die tatsächliche Sitemap berücksichtigt dagegen die Canonical-Rolle und schließt Kopien aus.
**Auswirkung:** Der Kunde erhält für eine öffentliche Kopie eine konkrete Aufnahmebehauptung, obwohl die Sitemap sie bewusst nicht enthält. Bei Mehrportal-Veröffentlichungen ist zudem eine einzelne globale Angabe weniger präzise als die tatsächliche portalbezogene Auswahl.
**Empfehlung:** Die Anzeige aus derselben portalbezogenen Sitemap-Eignung ableiten. Zwischen öffentlich erreichbar, kanonische Erstquelle, sitemapfähig und tatsächlich beobachtetem Indexstatus unterscheiden. Kein historisches Aufnahmedatum behaupten, wenn nur die aktuelle Eignung bekannt ist.
**Abnahmetest:** Erstquelle, Kopie, Both-Meldung mit bevorzugtem Portal, entfernte Meldung und deaktivierte Canonical-Funktion jeweils mit tatsächlicher Sitemap-Ausgabe vergleichen.
### N18 — P2: Indexverteilung verwendet unterschiedliche Grundgesamtheiten und nicht eindeutige letzte Checks
**Belege:** `app/Services/SearchConsole/IndexStatusReport.php:71`, `:84`, `:113`, `:215`.
**Befund:** `total` zählt aktuell sichtbare Meldungen des jeweiligen Portals. Die gezählten Prüfzustände filtern nach dem Join dagegen nur Soft Deletes, nicht dieselben Status-, Termin-, Gone- und Portalbedingungen. Zusätzlich bestimmt `latestChecks()` den jüngsten Datensatz ausschließlich über `MAX(checked_at)`; zwei Checks mit gleichem Zeitstempel werden beide gejoint.
**Konkrete Fälle:** Eine geprüfte PM wird anschließend depubliziert: Gesamtzahl kann 0, Indexed-Zahl weiterhin 1 sein. Zwei Checks derselben PM in derselben gespeicherten Sekunde können die Zustandszählung verdoppeln. `max(0, total - checked)` verdeckt negative Restwerte, beseitigt aber keine Inkonsistenz.
**Auswirkung:** Die Verteilung ist kein verlässlicher Anteil der ausgewiesenen Grundgesamtheit und kann in sich widersprüchlich werden.
**Empfehlung:** Für Total und Zustände exakt dieselbe PM-Menge verwenden oder historische Prüfergebnisse ausdrücklich getrennt ausweisen. Den letzten Check mit einer eindeutigen sekundären Sortierung, etwa ID, bestimmen.
**Abnahmetest:** Prüfung vor Depublikation, Gone-Markierung und Portalwechsel; dazu zwei Checks mit identischem `checked_at`. Je Segment muss die Summe der Zustände einschließlich Unchecked genau Total ergeben.
### N19 — P2: Canonical-Kopien werden nach Wegfall der Erstquelle nicht konsistent neu bewertet
**Belege:** `app/Services/PressRelease/CanonicalAssigner.php:58`, `:87`; `app/Services/PressRelease/PressReleaseService.php:549`; `app/Models/PressRelease.php:639`.
**Befund:** Ist die referenzierte Erstquelle nicht mehr sichtbar, fällt `canonicalUrlFor()` auf die eigene URL zurück. `isCanonicalOn()` behandelt denselben Datensatz wegen des weiterhin gesetzten Fremdschlüssels aber unverändert als Kopie. Der Wegfall einer Erstquelle stößt keine entsprechende Neubewertung aller Gegenstücke an.
**Ablauf:** A ist Erstquelle, B Kopie. A wird depubliziert oder Gone. B bleibt öffentlich und erhält Self-Canonical, wird aber weiter aus Sitemap/News-Sitemap ausgeschlossen. Ein nur auf veröffentlichte Paare gerichteter Zuordnungslauf repariert diesen Zustand nicht zwingend. Zusätzlich lädt die Quellauflösung ohne alle Global Scopes, während `isVisibleInArchive()` Soft Delete nicht selbst prüft; ein soft-gelöschtes Gegenstück ist deshalb separat abzusichern.
**Auswirkung:** Canonical-Aussage und Veröffentlichungs-/Sitemap-Rolle widersprechen sich nach regulären Lebenszyklusänderungen. Die dokumentierte größere Bestandszuordnung erhöht die praktische Reichweite dieses schon strukturell angelegten Problems.
**Empfehlung:** Canonical-Rolle als aktuellen Zustand einer Gruppe behandeln und bei Depublikation, Gone, Löschen sowie relevanten Inhaltsänderungen neu bestimmen. Sichtbarkeitsprüfung inklusive Soft Delete und Sitemap-Eignung aus derselben Regel ableiten.
**Abnahmetest:** A/B-Paar zuordnen, A nacheinander depublizieren, entfernen und soft-löschen. B muss eine konsistente URL und Sitemap-Rolle erhalten; kein Canonical-Ziel darf nur aufgrund einer Scope-Umgehung als öffentlich gelten.
### N20 — P2: Formal unbrauchbare Vision-Antwort kann als erfolgreicher Pass erscheinen
**Belege:** `app/Services/Image/Precheck/Drivers/OpenAiImagePrecheckDriver.php:79`, `app/Services/Image/Precheck/ImagePrecheckResult.php:115`.
**Befund:** Der Treiber prüft, ob alle Kategorienamen vorhanden sind, nicht aber deren Wertstruktur. Eine Antwort mit sämtlichen Kategorien auf `null` besteht die Vollständigkeitsprüfung. `normalize()` lässt diese Werte weg und erzeugt ein `ok`-Ergebnis ohne Treffer. Strings werden bei `hit` zu Boolean umgewandelt; beispielsweise ist der String `"false"` in PHP wahr.
**Auswirkung:** Ein unbrauchbarer Modellbefund kann als erfolgreich geprüft beziehungsweise mit falschen Treffern protokolliert werden. Gemäß Block K wäre bei einer ungültigen Antwort zwar ebenfalls kein harter Upload-Stopp vorgesehen, aber ausdrücklich der Zustand Error statt eines scheinbar belastbaren Pass.
**Empfehlung:** Das gesamte Antwortschema vor Normalisierung strikt prüfen: Kategorieobjekte, echte Booleans, endliche numerische Konfidenz im erlaubten Bereich, optional zulässige Notiz. Schemafehler durch den vorhandenen Error-Pfad behandeln.
**Abnahmetest:** Alle Kategorien `null`, leere Kategorieobjekte, String-Booleans, nichtnumerische und außerhalb des Bereichs liegende Konfidenzen. Ergebnis muss nachvollziehbar Error sein; gültige Antworten behalten die dokumentierten Schwellenwerte.
### N21 — P2, bedingt: Veraltete gecachte Fortschrittsmarke kann die Aggregation dauerhaft stoppen
**Belege:** `app/Console/Commands/AggregatePressReleaseViews.php:86`, `:147`, `:169`; `app/Models/AppSetting.php:30`; `app/Console/Commands/PurgePressReleaseEvents.php:50`.
**Bezug:** Die ursprüngliche Doppelzählung aus U13 wird durch DB-Sperre und gemeinsamen Commit korrekt verhindert.
**Befund:** Der Startwert kommt aus einem dauerhaft gecachten Setting. Unter Sperre vergleicht die Aggregation ihn strikt mit dem DB-Wert und bricht bei Abweichung ab. Dieser Abbruch korrigiert den Cache nicht. Ein paralleler Leser kann nach einer Invalidierung noch einen alten DB-Wert wieder in den Cache schreiben; der Purge-Command ist ein tatsächlicher weiterer Leser dieser Marke.
**Auswirkung:** Sobald ein solcher veralteter Cachewert vorliegt, starten auch spätere Läufe wieder mit derselben falschen Marke und brechen erneut ab. Neue Aufrufe bleiben bis zu einer externen Cachekorrektur unverbucht. Ein solcher Zustand wurde in der angebundenen Umgebung nicht nachgewiesen.
**Empfehlung:** Operative Fortschrittsmarken ungecacht aus der DB lesen oder bei Abweichung den Cache verwerfen und kontrolliert mit der aktuellen DB-Marke neu beginnen. Konflikt als wiederanlauffähigen Zustand behandeln.
**Abnahmetest:** Absichtlich alten Cachewert bei neuer DB-Marke vorgeben und zwei aufeinanderfolgende Aggregationsläufe ausführen. Spätestens der Folgelauf muss Fortschritt machen, ohne eine Zeile doppelt zu zählen.
### N22 — P1: Testdatenbank-Guard läuft erst nach potenziell destruktivem RefreshDatabase
**Belege:** `tests/TestCase.php:16`; installierter Laravel-Code in `vendor/laravel/framework/src/Illuminate/Foundation/Testing/TestCase.php:63`, `Concerns/InteractsWithTestCaseLifecycle.php:101`, `:212`, `RefreshDatabase.php:17`, `:81`.
**Einordnung:** Der Guard existierte bereits vor diesem Änderungszeitraum. Dies ist ein neu erkannter Bestandsfehler und eine Korrektur der positiven Sicherheitsbewertung des letzten Audits, keine durch die neuen Bildfunktionen eingeführte Regression.
**Befund:** `Tests\TestCase::setUp()` ruft zuerst `parent::setUp()` auf. Laravel führt darin `setUpTraits()` aus. Ist `RefreshDatabase` aktiv, wird die Testdatenbank aufgefrischt, bevor der nachfolgende Projekt-Guard `database.default` und `:memory:` kontrolliert. Eine gecachte Konfiguration kann die PHPunit-ENV-Werte überlagern — gerade den im Kommentar beschriebenen Fall soll dieser Guard verhindern.
**Auswirkung:** Bei falsch aufgelöster DB-Konfiguration kann der Guard erst nach einem Migrations-/Datenbankreset werfen. Seine Existenz rechtfertigt daher keinen Testlauf gegen eine ungeprüfte Umgebung. Es wurde in diesem Audit kein Reset ausgeführt und kein Datenverlust beobachtet.
**Empfehlung:** Die effektive Verbindung nach dem Anwendungsbootstrap, aber vor jedem Datenbank-Testtrait prüfen, zum Beispiel in einem geeigneten gemeinsamen Bootstrap-/`beforeRefreshingDatabase`-Punkt. Weitere destruktive Traits und zusätzliche Verbindungen mit berücksichtigen. Prüfung nicht durch einen bloßen ENV-Vergleich ersetzen.
**Abnahmetest:** Mit isolierter, wegwerfbarer Umgebung oder einem instrumentierten Refresh-Hook nachweisen, dass eine unerlaubte Konfiguration vor jedem Migrations-/Truncate-Aufruf abbricht. Diesen Fehler ausdrücklich nicht gegen eine bestehende Entwicklungsdatenbank reproduzieren.
## 5. Weitere Optimierungen und offene Entscheidungen
Die folgenden Punkte sind getrennt von den 22 priorisierten Befunden aufgeführt. Sie benötigen teilweise eine fachliche Entscheidung oder Messung und werden nicht als bereits eingetretener Fehler ausgegeben.
### 5.1 Archivvollständigkeit und Aufbewahrung
- **Kopien außerhalb des Archivs:** Der normale Remover überführt Dateien ins Archiv, lässt Bestätigungs-/Korrekturzeilen in der Anwendungsdatenbank aber bestehen. Archiv-Purge löscht diese Kopien nicht. Festlegen, welche Daten nach Fristende außerhalb des Archivs noch bestehen dürfen, und die Löschregel über alle Speicherorte nachweisen. Der frühere Entwurf mit dauerhafter Aufbewahrung darf nicht still als aktuelle Regel weitergelten.
- **Korrektur eines nie veröffentlichten Bildes:** `ImageCorrectionService::apply()` erzeugt `label_corrected` auch für nie veröffentlichte Bilder. Damit kann eine spätere Sofortlöschung des Entwurfs einen bereits angelegten mehrjährigen Archiveintrag zurücklassen. Die allgemeine Archivierung jeder Korrektur und die Regel „nie veröffentlicht: kein Archiv“ müssen für diesen Randfall ausdrücklich zusammengeführt werden.
- **Tombstones bei vollständigem Restore:** Der Export liegt auf derselben Archivdisk. Wird nicht nur die DB, sondern auch diese Disk auf einen älteren Stand zurückgesetzt, kann der aktuelle Löschstand selbst verloren gehen. Der Restore-Ablauf braucht einen nachweislich aktuellen, vom zurückgesetzten Bestand unabhängigen Tombstone-Stand.
- **Verknüpfung zum Meldeverfahren:** Der fachliche Antrag sollte den zugehörigen Archiveintrag eindeutig referenzieren, damit Zugriffsprüfung, Auskunft und Wiederholung ohne Suche über Freitext funktionieren. Die geplante Verbindung ist nicht durchgängig erkennbar.
- **Fehlerbehandlung weiterer Dateilöschungen:** Auch lokale Bereinigung und Remover enthalten nicht ausgewertete Löschresultate. Nach N10 dieselbe Fehlerstrategie für Original, Snapshot, Sidecar und Derivate anwenden.
### 5.2 Leistung und Betriebsrobustheit
- **Synchroner Bildcheck:** Drei HTTP-Versuche mit jeweils 20 Sekunden plus Retry-Wartezeiten und Bildvorbereitung können den Request bereits auf über eine Minute bringen. Ein gemeinsames Ende-zu-Ende-Zeitbudget sowie die tatsächlichen Webserver-/PHP-/Proxy-Grenzen fehlen als Abnahmenachweis. Keine konkrete Timeout-Häufigkeit gemessen.
- **Dekodieraufwand:** 8.000 × 8.000 Pixel entsprechen bereits ungefähr 256 MB für einen einzelnen unkomprimierten RGBA-Puffer, ohne weitere Verarbeitungskopien. Dateigröße allein begrenzt den RAM-Verbrauch nicht. Animierte/mehrseitige Eingaben und Imagick-Ressourcenlimits mit realistischen Grenzfällen messen. Eine erst nach der Verarbeitung geprüfte Laufzeitgrenze unterbricht einen laufenden Decoder nicht.
- **Report-Skalierung:** `topFeeds()` lädt und sortiert die gesamte Gruppierung in PHP; der Tombstone-Export lädt alle gelöschten Einträge. Bei wachsendem Bestand Aggregation, Top-N-Auswahl beziehungsweise Streaming möglichst in DB/Dateipipeline verlagern. Erst messen, dann optimieren; hier liegt kein Lasttest vor.
- **Warm gecachter Kategorienfeed:** Die Aktivitätsprüfung liegt im Cache-Aufbau. Eine danach deaktivierte Kategorie kann bis zur nächsten Erneuerung weiterhin einen zuvor gefüllten Feed liefern. Auswahlbedingungen vor dem Cache prüfen oder passende Cache-Invalidierung bei Kategorienänderung ergänzen.
- **Dry-Run-Aussage:** `PurgePressReleaseEvents` zählt im Dry Run nicht alle im echten Lauf zusätzlich bereinigten Feed-/Crawlerdaten. Den Umfang ausdrücklich ausweisen, damit Betreiber aus dem Probelauf keine falsche Größenordnung ableiten.
- **Korrigierte OG-Datei unter gleicher Adresse:** Der Neuaufbau löst nicht automatisch bereits gespeicherte Browser-/Plattformcaches auf. Bei Nachweiskorrekturen prüfen, ob eine geänderte URL/Version erforderlich ist. Die weitergehende Metadaten-/Versionierungsphase bleibt bewusst später vorgesehen.
### 5.3 Entwicklungsorganisation und Dokumentation
- Die thematischen Commits und Umsetzungsprotokolle sind hilfreich. Für Abnahmen sollte jeder Block zusätzlich den getesteten Commit, den konkret ausgeführten Befehl und noch offene Randfälle tragen. Eine historische Gesamtsuite nach Block E deckt die späteren Archiv-, Gate- und Vorprüfungsänderungen nicht automatisch ab.
- Die drei für diesen Audit vorgefundenen unversionierten Dokumente sind fachlich wichtige Quellen: Decision-Update, aktuelle Fassung 2 und ältere Fassung 1 mit Dateiendung `...2026-09-211.md`. Eine eindeutige kanonische Fassung mit versionierter Entscheidungshistorie würde verhindern, dass spätere Reviews oder Deployments nach widersprüchlichen Regeln arbeiten. In diesem Audit wurden diese Dateien nicht verändert oder zur Versionsverwaltung hinzugefügt.
- Der Plan formuliert bei U10 die Kategorienprüfung breiter als der Code: Der Code prüft beim tatsächlichen Portalwechsel; ein ausdrücklich erneut gesendetes, unverändertes Portal löst allein keine Prüfung aus. Der ursprüngliche Wechsel-Fehler ist behoben. Die Formulierung bei Gelegenheit präzisieren.
- Die manuelle Fokus-/Tastaturabnahme bleibt offen. Viele neue Volt-Pfade liegen außerdem außerhalb des bisherigen PHPStan-Scope; ein grüner Backend-Analyselauf ersetzt deren Interaktionstests nicht.
- Die breitere MySQL-/Worker-CI wurde bewusst verschoben. Die konkreten Parallelitätsbefunde N02–N04, N07, N14 und N21 liefern jetzt einen eng begrenzten sinnvollen Einstieg statt einer pauschalen Forderung nach zusätzlicher Infrastruktur.
## 6. Nachweise aus der angebundenen Umgebung
Es wurden nur lesende Anwendungsinformationen, Metadaten und aggregierte Zähler betrachtet. Keine Nutzerdatenexporte, Schlüssel oder Zugangsdaten sind Bestandteil dieses Berichts.
| Prüfung | Beobachtung | Aussagegrenze |
|---|---|---|
| Laufzeit/Pakete | Boost meldet PHP 8.4, Laravel 12.69.2, Fortify 1.39.0 und Livewire 4.4.5; Paketstand mit Lockfile abgeglichen. | Keine Aussage über jede produktive Instanz oder alte Workerprozesse. |
| Migrationsregister | 19 Einträge ab `2026_09_18_110000` vorhanden, einschließlich der zuvor fehlenden Audit-Migrationen, Kontingent-Backfill, Feed- und Bild-/Archiv-Erweiterungen. | Belegt Einträge in dieser Datenbank; weder erfolgreicher Produktivdeploy noch vollständige fachliche Datenkorrektur folgt allein daraus. |
| Aktiver Bildbestand | 14.602 nicht soft-gelöschte Bildzeilen, davon eine mit v2-Regelversion. | Momentaufnahme der angebundenen Datenbank, keine Produktionsidentität bestätigt. |
| V2-Ausnahmefälle | Keine zurückgehaltenen Bilder, keine unbestätigten v2-Bilder und keine v2-Bilder mit Publikationsmarker ohne Snapshot in der gelesenen Aggregation. | Die Codefehler N01/N09 wurden damit nicht als bereits vorhandener Datenvorfall nachgewiesen; die sehr kleine v2-Stichprobe entkräftet sie nicht. |
| Archivbestand | Keine Archiveinträge in der gelesenen Datenbank. | Kein End-to-End-Nachweis für realen Archivbetrieb, Hold, Purge oder Restore. Bei leerer Tabelle sind SUM-Werte NULL, nicht zusätzliche Ereignisse. |
| Lokale ENV-Datei | `IMAGE_RIGHTS_V2=true`, `IMAGE_PRECHECK=true`, `IMAGE_PRECHECK_STAGE=2`, `LEGAL_ACCEPTANCE_GATE=true`. | Dateiinhalte, nicht unabhängig aufgelöste Konfiguration aller Requests/Worker; Flags sind hier nicht generell ausgeschaltet. |
| Sail | Docker/Podman nicht verfügbar; gezielter Teststart scheitert vor Ausführung. | Keine neu bestätigten Tests, Builds oder Workerabläufe. |
Die dokumentierte Dev-Bereinigung von 10.768 Legacy-Bildern lässt sich aus der heutigen Gesamtzählung nicht als durchgeführt für genau diese Datenbank bestätigen. Sie kann auf einem anderen Stand beziehungsweise einer anderen Umgebung erfolgt sein. Für einen belastbaren Deploy-Nachweis müssen Zielumgebung, Zeitpunkt, Command, Vorher-/Nachher-Zähler und Commit zusammenpassen.
Offen bleiben insbesondere Produktivmigrationen, tatsächliche Feature-Aktivierung, Archivschlüssel-/Backup-/Restore-Verfügbarkeit ohne Offenlegung der Schlüssel, Worker-Neustarts, wirksame Queue-Zeitgrenzen und die im Plan für den Bildcheck vorgesehenen betrieblichen Entscheidungen. Der Bericht behauptet weder fehlende Schlüssel noch eine unzulässige Aktivierung des Bildchecks.
## 7. Empfohlene Reihenfolge der Nacharbeit und Abnahme
Die folgenden Pakete sind Empfehlungen für die nächste Umsetzung. In diesem Auftrag wurde keines davon geändert.
| Schritt | Inhalt | Abschlusskriterium |
|---|---|---|
| 1 | N22: Testumgebung vor Datenbankoperationen absichern. | Fehlkonfiguration bricht vor jedem destruktiven Test-Hook ab; danach gezielte Tests sicher ausführbar. |
| 2 | N01, N03, N04, N05, N07: Freigabe und Akteur/Status/Hash zusammenführen. | Öffentliches Bild hat gültige Freigabe; Kundenbestätigung funktioniert für den vorgesehenen Kunden; keine veraltete KI-Entscheidung ändert den aktuellen Stand. |
| 3 | N02, N10, N11, N12, N13: Archiv- und Löschabläufe schließen. | Hold schützt auch unter Parallelität; Dateioperationen sind geprüft und wiederholbar; alle Entfernungspfade besitzen den vorgesehenen Endzustand. |
| 4 | N06, N08, N09, N20: Nachweisbindung und Fehlerpfade verbessern. | Angezeigter Text entspricht protokollierter Annahme; Cachefehler vernichtet kein Original; Snapshot passt zur veröffentlichten Kennzeichnung; ungültige Modellantwort ist Error. |
| 5 | N14, N21: Hintergrundabläufe wiederanlauffähig machen. | Benachrichtigung wird eindeutig eingeplant; Aggregation erholt sich von veralteter Marke ohne Doppelzählung. |
| 6 | N15–N19: Berichts- und Canonical-Invarianten korrigieren. | Mengen addieren sich; Feed-Identitäten bleiben getrennt; Kundenanzeige, Sitemap und Canonical sind konsistent. |
| 7 | Betriebs- und Browserabnahme auf konkretem Commit dokumentieren. | Reproduzierbarer Teststand, geprüfte Zielumgebung, vollständiger Upload-/Korrektur-/Archiv-/Restore-Durchlauf. |
### 7.1 Gezielte Testpakete
- **Identität und alte Restfehler:** vorhandene ContactAccess-, FortifyTwoFactorRoutes-, PressReleaseQuota-, PressReleaseApi-, Classification- und ContentScore-Tests ausführen; die in N03/N04 beschriebenen Zwischenfenster ergänzen.
- **Bilder:** `tests/Feature/Images/`, `tests/Feature/Api/V1/PressReleaseImageRightsV2ApiTest.php` und die öffentliche Medienausgabe gemeinsam prüfen. Nicht nur DB-Zeilen, sondern echte Auslieferung, Dateivollständigkeit und gespeicherten Nachweis vergleichen.
- **Archiv:** `tests/Feature/Archive/EvidenceArchiveTest.php`, Anonymisierungs- und Korrekturtests um konkurrierenden Hold, Storage-Fehler, Wiederholung und alle Entfernungspfade erweitern. Ein Restore-Test benötigt einen älteren Datenbestand und einen davon unabhängigen aktuellen Tombstone-Stand.
- **Dokumentannahmen:** `tests/Feature/Legal/LegalAcceptanceGateTest.php` und Bildbestätigung mit Versionswechsel zwischen Rendern und Submit prüfen.
- **Reports:** Kohorten-Grenzfälle, Feed-Gruppierung, Indexverteilung, Canonical-Änderungen und Sichtbarkeitsanzeige mit gemeinsamen Datenfällen prüfen. Mengeninvarianten sind aussagekräftiger als ausschließlich einzelne erwartete Zeilen.
- **Hintergrundprozesse:** ExpireGrandfathering und View-Aggregation zusätzlich mit zwei echten DB-Verbindungen beziehungsweise einem kontrollierten Queue-Abbruch prüfen. Ein synchroner SQLite-Test allein bestätigt diese Garantien nicht.
Erst nach diesen gezielten Korrekturen und Nachweisen ist eine Gesamtfreigabe der betroffenen neuen Abläufe belastbar. Die ausdrücklich verschobenen Newsletter-Arbeiten bleiben vor der ersten Nutzung des Kampagnenmoduls ein eigenes Abnahmepaket.
## 8. Änderungsumfang dieses Audits
Neu erstellt wurde ausschließlich `dev/2026-09-11-audit/UMSETZUNGSAUDIT-2026-09-25.md`. Vorhandene unversionierte Planungsdateien, Code, Tests, Konfiguration und Datenbestand wurden nicht verändert. Es wurden keine Mails verschickt, Migrationen oder Bereinigungen ausgeführt, Dienste gestartet oder Deployments vorgenommen.

View file

@ -0,0 +1,354 @@
# Umsetzungs-Audit und Bestandsaufnahme vom 29.09.2026
**Prüfdatum:** 29.09.2026
**Geprüfter Code:** `98c465b` auf `technik/betriebsfixes-2026-09` (entspricht dem Live-Stand nach Deploy 1/1b/2/2b vom 28.09.2026)
**Vergleich:** Schwerpunkt `d99f55e..98c465b` (Nacharbeit Block 8: `4e34c3d`, `48129e9`), dazu ein neuer Durchgang über die seit 01.09. gebauten Roadmap- und Bildfunktionen
**Grundlagen:** [Entwicklungsplan Blöcke 1–8](ENTWICKLUNGSPLAN.md), [Umsetzungsaudit 25.09.](UMSETZUNGSAUDIT-2026-09-25.md), [Bildkennzeichnungsplan Fassung 2](../../bildrechte/bildkennzeichnung-entwicklungsplan-2026-09-21.md), [Decision-Update 22.09.](../../bildrechte/Decision-Update_Bildrechte_Beweisarchiv_2026-09-22.md), [Roadmap](../2026-09-nutzergewinnung/roadmap-2026.md) mit Anhängen 1 und 2, [Umsetzungsprotokoll](../2026-09-nutzergewinnung/umsetzung-protokoll.md)
**Umsetzung:** Am selben Tag als Block 9 umgesetzt — Stand, Entscheidungen E19–E34 und Live-Schritte im [Entwicklungsplan](ENTWICKLUNGSPLAN.md#block-9--umsetzung-des-umsetzungsaudits-vom-29092026). Der Bericht selbst bleibt als Befundstand erhalten; Korrektur: Q-07 war ein Fehlalarm.
**Zweck:** Stand der Dinge nach dem Live-Gang aufnehmen, die Block-8-Nacharbeit gegenprüfen, neue Fehler und Optimierungen belegen und alles Offene an einer Stelle sammeln, damit die Entwicklung hier weitergehen kann.
Nichts an Code, Tests, Konfiguration oder Daten wurde geändert. Datenbankabfragen liefen nur lesend gegen die Dev-Kopie.
---
## 1. Ergebnis in Kürze
- **Block 8 hält im Kern.** Von den 22 Befunden des 25.09. sind 15 vollständig behoben. N01, N06, N11, N17 und N19 sind teilweise behoben, N09 ist wie geplant offen. N13 ist technisch umgesetzt, beruht aber auf einer Annahme, die nicht stimmt (A-05).
- **Qualitätsgates:** Die Gesamtsuite läuft seriell grün mit **2.122 bestandenen und 1 übersprungenen Test** (393 s). Im Parallelmodus scheitern 9 Tests an einem Testhelfer, der in einer anderen Datei definiert ist (Q-01). Larastan Level 4 meldet über den ganzen Scope 1 Fehler in Altcode (Q-02). Pint ist sauber.
- **Neu gefunden: 4 × P1, 22 × P2** sowie eine Reihe von P3-Punkten und Optimierungen. Die P1-Befunde sind klein im Fix und groß in der Wirkung:
1. **R-01:** Über die Legacy-API wird der Einlieferer als `member` einer *fremden* Firma angelegt. Damit bekommt er Schreibrecht auf alle Meldungen dieser Firma.
2. **K-04:** Über den Google-Login lässt sich ein Konto vorab übernehmen. Ein fremd angelegtes, unverifiziertes Konto wird aktiviert, und das Passwort des Angreifers bleibt gültig.
3. **K-01:** Der Scheduler veröffentlicht Meldungen, die nach E3 auf eine manuelle Prüfung warten.
4. **B-01:** Der Startseiten-Feed beider Portale ist eine Lücke im N01-Fix. Er zeigt Bilder, die noch nicht freigegeben sind, und cacht sie.
- **Seit dem 28.09. live und deshalb dringlich:** B-03 (das Formular lehnt Dateien über 12 MiB ab, zugesagt sind 16 MiB), N09 (der Veröffentlichungsnachweis stimmt nach einer Korrektur nicht mehr) und B-04, B-05 und B-07 an der Bildfreigabe.
- **SEO:** R-02/K-03 und R-03 wirken sich schon heute auf die Suche aus. Der Aufrufzähler setzt `updated_at` jeder aufgerufenen Meldung neu, das verfälscht Sitemap-`lastmod` und `dateModified`. Die Sitemap führt englische Meldungen unter `/de/`, auf der Dev-Kopie sind das 303 URLs mit 404.
### Bewertungsmaßstab
| Kennzeichnung | Bedeutung |
|---|---|
| P1 | Vor weiterem Betrieb bzw. vor Aktivierung des betroffenen Ablaufs beheben |
| P2 | Konkreter Fehler bei einem nachvollziehbaren Eingabe-, Zustands-, Fehler- oder Parallelitätsfall |
| P3 | Klein, latent oder Hygiene |
| OPT | Optimierung ohne Fehlerfolge |
| Nachweis „geprüft" | Am Code in dieser Sitzung zweimal unabhängig gelesen oder per Abfrage belegt |
| Nachweis „nachgestellt" | Mit einem temporären Test auf SQLite reproduziert (Test wieder entfernt) |
| Nachweis „gelesen" | Einmal am Code belegt, nicht dynamisch reproduziert |
---
## 2. Vorgehen
1. Dokumentation gesichtet: Roadmap samt Anhängen, Protokoll Teil 1–11, AUDIT, Entwicklungsplan Blöcke 1–8, beide Umsetzungsaudits, Bildplan Fassung 2 mit Protokoll bis zum Live-Gang am 28.09., Runbook.
2. Qualitätsgates ausgeführt: Pest seriell und parallel, Larastan über den vollen Scope, `pint --test`, `schedule:list`, Migrationsliste, Abgleich `.env.example` mit den Config-Schlüsseln.
3. Code-Prüfung in vier parallelen, nur lesenden Durchgängen:
- Bild-Pipeline
- Archiv, Löschung und Anonymisierung
- KI, Veröffentlichung, Rechte und Hintergrundjobs
- Berichte, Feeds, Canonical und die Roadmap-Funktionen
Jeder Durchgang hat die Block-8-Fixes gegengeprüft und neue Befunde gesucht.
4. Die P1-Befunde und die wichtigsten P2-Befunde habe ich danach selbst am Code nachgelesen, dazu gezielte Zählungen auf der Dev-Kopie.
**Grenzen:** Es wurde nur auf SQLite getestet. MySQL-Sperrverhalten mit zwei Verbindungen wurde nicht dynamisch geprüft. Den Live-Server habe ich nicht gesehen; Betriebsangaben stammen aus dem Protokoll. Die Mengen stammen aus der Dev-Kopie und nicht aus Live.
---
## 3. Qualitätsgates
| Prüfung | Ergebnis | Bemerkung |
|---|---|---|
| `php artisan test --compact` (seriell) | 2.122 grün, 1 übersprungen, 9.766 Assertions, 393 s | Deckt sich mit dem Protokoll nach Block 8 |
| `php artisan test --parallel --processes=6` | 9 Fehlschläge | Q-01 |
| Larastan Level 4, voller Scope | 1 Fehler | Q-02; das Protokoll sprach nur von „geänderten Dateien" |
| `pint --test` | bestanden | |
| Scheduler | 18 Tasks, alle vier Archiv-Tasks vorhanden | K-09, A-13 |
| `.env.example` | alle neuen Schalter vorhanden außer `IMAGE_PRECHECK_MODEL` | P3 |
- **Q-01 (P3):** `tests/Feature/PressReleaseWithdrawalWaiverTest.php` und `PressReleasePublishModalPhase8iTest.php` rufen `makeCustomerForShowPhase8a()` auf. Die Funktion ist in `tests/Feature/PressReleaseShowPhase8aTest.php:24` definiert. Einzeln oder parallel ausgeführt scheitern die beiden Dateien, seriell laufen sie nur zufällig in der richtigen Reihenfolge. **Fix:** Helfer nach `tests/Pest.php` oder in einen Support-Helfer verschieben. Danach die Suite parallel fahren (etwa 80 s statt 390 s).
- **Q-02 (P3):** `app/Models/Concerns/HasUniqueSlug.php:90`: `@var Model $self` ist kein Subtyp von `$this`. **Fix:** Die Annotation entfernen und `static::query()` verwenden.
- **Q-03 (P3):** Volt-Komponenten (`resources/views/livewire/**`) liegen außerhalb des PHPStan-Scopes. Genau dort sitzen mehrere der neuen Befunde (B-04, K-06, K-07). Die Baseline hat 143 Einträge.
- **Q-04 (Hinweis):** `Storage::fake` teilt `storage/framework/testing/disks`. Laufen mehrere Testprozesse gleichzeitig, scheitern Bildtests sporadisch mit „No such file". Das ist kein Codefehler, erklärt aber sporadisch rote Läufe.
---
## 4. Verifikation der Block-8-Nacharbeit
| ID | Stand | Beleg | Rest |
|---|---|---|---|
| N01 | **teilweise** | `PressReleaseImage.php:283-302`, `PressReleaseImageMediaController.php:33`, `ReleaseDetailPageData.php:43,110`, `HomePageData.php:62`, `CategoryPageData.php:100`, `NewsroomDetailPageData.php:153` | Livewire-Startseiten-Feed (B-01) und Nachweis-PDF (B-02) umgehen den Freigabezustand |
| N02 | behoben | `EvidenceArchive.php:205-247, 410-437` | Nur mit einer Verbindung getestet |
| N03 | behoben | `ClassifyPressRelease.php:111-173`, `ScorePressRelease` analog | – |
| N04 | behoben | `PressReleaseService.php:264-310, 605-635` | Folgefall K-05 |
| N05 | behoben | `PressReleasePolicy.php:15-24, 90-98`, `PressReleaseImageUploader.php:261-280` | Die Bestätigung ist nicht an das angezeigte Bild gebunden (B-04) |
| N06 | **teilweise** | `InteractsWithAiDeclaration.php:43, 96-98` | `legalAcceptanceVersions` ist nicht `#[Locked]`; bei Versionswechsel wird still übersprungen (K-07) |
| N07 | behoben (Upload) | `PressReleaseImageUploader.php:174` | Der Entfernen-Pfad hat dieselbe Lücke (B-05) |
| N08 | behoben | `PressReleaseImageUploader.php:231-249, 314-330` | Das Muster fehlt in Korrektur und Sichtung (B-06) |
| N09 | **offen** (geplant) | `PublishedImageSnapshot::capture()` unverändert | Siehe Abschnitt 5.1 – seit dem 28.09. live relevant |
| N10 | behoben | `EvidenceArchive.php:289-319`, `FileDeletion.php` | Reconcile setzt den Tombstone auch bei gescheiterter Dateilöschung; der FAILURE-Exit erzwingt die Wiederholung |
| N11 | **teilweise** | `EvidenceArchive.php:97-130`, `PendingArchiveRetry.php` | A-01, A-02, A-04, A-09 |
| N12 | behoben | `AnonymizationService.php:161-217` | Restfälle A-10, A-12 |
| N13 | technisch behoben | `PurgeExpiredPressReleaseDrafts.php:43-48` | Die Annahme im Protokoll ist falsch (A-05) |
| N14 | behoben (unter E18) | `ExpireGrandfathering.php:174-200` | Atomar nur mit DB-Queue; ein Assert fehlt (OPT) |
| N15 | behoben | `ViewDistributionReport.php:137-146, 176` | – |
| N16 | behoben | `FeedFetchReport.php:37-43` | – |
| N17 | **teilweise** | `VisibilityStats.php:170-186` | „Gelistet seit" zeigt `published_at` statt des Rollenwechsels; die EN-URL in der Sitemap führt ins Leere (R-03) |
| N18 | behoben | `IndexStatusReport.php:73, 87-89, 217-245` | Eine Meldung auf beiden Portalen zählt auf dem nicht bevorzugten Portal als `source` |
| N19 | **teilweise** | `CanonicalAssigner.php:143-159` | `archive()` (`PressReleaseService.php:647`) ruft `reassignCopiesOf()` nicht auf – **geprüft**; Merge, Unmerge und Textänderung bewerten ebenfalls nicht neu (R-06) |
| N20 | behoben | `OpenAiImagePrecheckDriver::assertSchema()`, `ImagePrecheckResult::normalize()` | – |
| N21 | behoben | `AggregatePressReleaseViews.php:89, 177-186`, `AppSetting.php:50-68` | – |
| N22 | behoben | `tests/TestCase.php:31-54` | – |
| 5.1 a | teilweise | `EvidenceArchive.php:444-469` | Nur beim regulären Purge, nicht beim Restore-Abgleich (A-06) |
| 5.1 b | behoben | `ImageCorrectionService.php:110-115` | – |
| 5.1 c | teilweise (E16) | Runbook `:291-308` | Nach einem DB-Restore gibt es eine neue Lücke (A-03) |
| 5.1 d | teilweise | `PressReleaseArchiver.php:84-127` | Eine Depublikation aus dem Meldeverfahren trägt keine `legal_request_id` |
| 5.1 e | weitgehend | `PressReleaseImageRemover.php:57-66, 93-111` | A-12 |
| 5.2 Kategorienfeed, Dry-Run | behoben | `categories/edit`, `PurgePressReleaseEvents.php:80-85` | `create` leert die Navigation nicht (bis zu 1 h) |
Die älteren Punkte U02, U03, U04 und U11–U13 sind am aktuellen Code weiterhin behoben.
---
## 5. Neue Befunde
### 5.1 P1 — vor weiterem Betrieb beheben
#### R-01 · Legacy-API macht Einlieferer zu Mitgliedern fremder Firmen · geprüft
`app/Services/Company/ApiNewsroomAssignment.php:65-94`, aufgerufen aus `LegacyCompanyController::store()` (`POST /service/users/me/companies`).
- **Ablauf:** Stimmen die Firmen-E-Mail und die Namensregel überein, wird der Token-Nutzer ohne weitere Prüfung mit `role = member` an die bestehende Firma gehängt (`:92`).
- **Folge:** Nach `PressReleasePolicy::canManage()` darf ein `member` alle Meldungen der Firma lesen, bearbeiten, einreichen und löschen, auch Entwürfe und Meldungen mit Sperrfrist.
- **Szenario:** Firmenname und Kontaktadresse stehen öffentlich im Newsroom bzw. auf der Meldungsseite. Jeder Kunde mit API-Schreibrecht kann damit einen fremden Newsroom übernehmen. Auf der Dev-Kopie haben **413 Nutzer** ein Token mit `press-releases:write`. Nicht geprüft ist, wie viele davon den Legacy-Pfad tatsächlich erreichen.
- **Fix:**
- Die Zuordnung nur für freigeschaltete Einlieferer erlauben (Allowlist in `config/newsroom.php`, heute nur connektar) oder nur, wenn der Nutzer schon Owner oder Mitglied dieser Firma ist.
- In allen anderen Fällen wie früher eine neue Firma anlegen.
- Zuordnungen seit dem 15.09. über `company_user.created_at` und das Activity-Log („API-Firma … zugeordnet") prüfen und fremde Mitglieder entfernen.
- **Test:** Ein Token eines fremden Nutzers mit passender Adresse und passendem Namen erzeugt keinen Pivot.
#### K-04 · Vorab-Übernahme eines Kontos über den Google-Login · geprüft
`app/Services/Auth/SocialAuthService.php:47-58`.
- **Ablauf:** Ein unverifiziertes Konto mit derselben E-Mail wird beim Google-Login auf verifiziert und aktiv gesetzt. Passwort, `remember_token` und laufende Sessions bleiben dabei unverändert.
- **Szenario:**
- Ein Angreifer registriert `pressestelle@firma.de` mit eigenem Passwort; die Registrierung loggt direkt ein.
- Die echte Pressestelle meldet sich später mit Google an. Das Konto wird aktiv.
- Der Angreifer kommt per Passwort oder mit seiner Session hinein.
- `SyncCompanyMembershipsOnLogin` vergibt dabei zusätzlich die Firmenrechte zu dieser Adresse.
- **Fix:**
- Bei der Verifikation über OAuth das Passwort auf `null` setzen, `remember_token` rotieren und fremde Sessions löschen.
- Alternativ unverifizierte Selbstregistrierungen verwerfen und neu anlegen.
- Zusätzlich unverifizierte Konten nach N Tagen löschen.
- **Test:** Ein vorregistriertes Konto ist nach dem Google-Login nicht mehr per altem Passwort erreichbar.
#### K-01 · Scheduler veröffentlicht Meldungen mit ausstehender manueller Prüfung · geprüft, nachgestellt
`PublishScheduledPressReleases.php:66-76`: Die Abfrage filtert nicht auf `manual_review_required_at`. Auch `publish()` und `autoPublishApproved()` prüfen die Marke nicht.
- **Szenario:**
- Eine Meldung ist nach dem dritten automatischen Lauf gesperrt (E3).
- Der Kunde darf sie im Status `review` weiter bearbeiten und `scheduled_at` setzen.
- Die Bearbeitung löst einen Recheck ohne Routing aus, der `auto_check_attempts` nicht erhöht.
- Bei Grün veröffentlicht der Scheduler, ohne dass ein Mensch entschieden hat. Der Kunde kann also so lange umformulieren, bis die KI Grün liefert.
- Dasselbe gilt bei Gelb, wenn `yellow_to_manual_queue` eingeschaltet ist.
- **Fix:**
- `whereNull('manual_review_required_at')` in der Scheduler-Abfrage.
- In `publish()` unter der Sperre bei den Quellen `ki` und `scheduler` `requiresManualReview()` prüfen und abbrechen.
#### B-01 · Startseiten-Feed umgeht den Bild-Freigabezustand (Lücke in N01) · geprüft
`resources/views/livewire/web/press-release-feed.blade.php:123-126` lädt `images` ohne `publiclyVisible()`. `PressRelease::cardImageUrl()` und `cardImageCredit()` (`:273-285`) nehmen das erste Bild.
- **Szenario:** An einer veröffentlichten Meldung wartet ein Admin-Upload auf die Kundenbestätigung, oder ein Bild ist zurückgehalten. Der Feed rendert trotzdem `<img>`, Nachweis, `alt` und `title`.
- Gäste sehen ein kaputtes Bild statt des Platzhalters.
- Eingeloggte Nutzer mit `view`-Recht sehen das nicht freigegebene Bild.
- Das Ergebnis liegt im `WebPageCache`.
- **Nebenbefund:** `cardImageUrl()` lädt ohne Vorladen alle Bilder lazy nach.
- **Fix:**
- `->publiclyVisible()` in `baseQuery()` ergänzen.
- Die Card-Helfer am Modell lesen nur vorgeladene Relationen oder filtern selbst.
- **Test:** Den Feed mit zurückgehaltenem und mit unbestätigtem Bild prüfen.
### 5.2 P2 — Bild-Pipeline (seit 28.09. live)
| ID | Befund | Beleg | Szenario | Fix | Nachweis |
|---|---|---|---|---|---|
| **N09** | Veröffentlichungssnapshot veraltet oder nur scheinbar vorhanden | `PublishedImageSnapshot::capture()` `:47-49, 62-63, 79-83` | Dedup nur über Bildbytes: Nach einer Korrektur von Credit oder KI-Kennzeichnung bleibt das Sidecar-JSON alt. Fester Pfad, also keine Historie. `put()` ungeprüft (`throw=false`), der Pfad wird trotzdem gesetzt (Phantom-Snapshot). Die OG-Variante mit eingebranntem Nachweis ist nicht Teil des Snapshots. Zurückgehaltene Bilder bekommen `published_ever_at`. Der Aufruf in `publish()` ist ungesichert: wirft er, entfallen `assignCanonical` und die Autorenmail. | Snapshot versioniert (Bytes + Metadaten-Hash im Pfad), `put()` prüfen, OG-Variante einschließen, nur `isPubliclyDeliverable()`-Bilder erfassen, Aufruf nach dem N08-Muster absichern | gelesen |
| B-02 | Nachweis-PDF nimmt das erste Bild ungefiltert | `ProofPdfRenderer.php:893-900` | Das gekaufte PDF zeigt ein zurückgehaltenes oder unbestätigtes Bild als veröffentlichtes Titelbild | `publiclyVisible()` | gelesen |
| B-03 | Formular-Upload bricht bei 12 MiB ab | `config/livewire.php:68` `rules => null`, Livewire-Standard `max:12288` | Das Formular verspricht 16 MiB (E-B6); Dateien zwischen 12 und 16 MiB scheitern schon beim temporären Upload, die API nimmt sie an | `temporary_file_upload.rules = ['required','file','max:16384']`; auf Live `upload_max_filesize`, `post_max_size` und nginx `client_max_body_size` prüfen | geprüft |
| B-04 | Kundenbestätigung nicht an das angezeigte Bild gebunden | `customer/press-releases/show.blade.php:124-160` | Der Kunde sieht Bild A, der Admin ersetzt es durch B, der Kunde klickt „bestätigen“: protokolliert wird B | `#[Locked]` Bild-ID und SHA beim Rendern setzen, beim Bestätigen vergleichen | gelesen |
| B-05 | Entfernen ohne Sperre gegen gleichzeitiges Veröffentlichen | v2-Manager `:328-343`, API `destroy` `:102-128`, `PressReleaseImageRemover.php:41-67` | Veraltetes `published_ever_at = NULL` führt zur Sofortlöschung eines öffentlich gewesenen Bildes, ohne Archiveintrag | Im Remover PM und Bild mit `lockForUpdate` neu laden und die Regeln erneut prüfen (wie N07) | gelesen |
| B-06 | Korrektur: Nacharbeiten nach dem Commit ungeschützt | `ImageCorrectionService.php:100-115` | Scheitert OG-Neubau, Snapshot oder Cache, fehlt der `label_corrected`-Eintrag dauerhaft; kein Nachholer | Archiv zuerst und mit Marker; OG, Snapshot und Cache wie N08 | gelesen |
| B-07 | Vorab-Sichtung und Korrektur teilen sich `withheld_at` | `ImagePrecheckReviewService.php:31`, `ImageCorrectionService.php:48,55`, Admin `toggleWithheld()` | Ein Admin hält ein Bild wegen einer Beschwerde zurück, ein anderer gibt die Sichtung frei, und das Bild ist öffentlich | Gründe getrennt führen; `isPubliclyDeliverable()` prüft beide | gelesen |
### 5.3 P2 — Archiv und Löschung
| ID | Befund | Beleg | Szenario | Fix | Nachweis |
|---|---|---|---|---|---|
| A-01 | Ohne `ARCHIVE_KEY` löscht der Nachholer die Marker und meldet SUCCESS | `PendingArchiveRetry.php:74-97`, `EvidenceArchive.php:71-78` | `record()` gibt `null` zurück, `attempt()` zählt das weder als Erfolg noch als Fehler, und bei `failed === 0` wird der Marker gelöscht. Nach einem `config:cache` ohne Schlüssel gehen alle offenen Nachweise still verloren. Der Systemstatus prüft den Schlüssel nicht. | Ohne `isConfigured()` mit FAILURE abbrechen; „übersprungen“ getrennt zählen; Systemstatus-Kachel für den Schlüssel | geprüft |
| A-02 | Ein späterer Vorgang überschreibt oder löscht einen offenen Marker | `AnonymizationService.php:138,173`, `CorrectionService.php:178,254,322`, `PressReleaseService.php:813`, `PendingArchiveRetry.php:74` | Die Depublikation scheitert beim Archiv, der Marker steht auf T1. Die Anonymisierung setzt T2 und löscht ihn nach Erfolg. Das `pm_depublished` mit dem ungeschwärzten Stand entsteht nie. | Marker nur setzen, wenn er NULL ist; bedingt löschen (`WHERE archive_pending_at = ?`); besser eine Auftragstabelle je Ereignis | gelesen |
| A-03 | Nach einem DB-Restore löscht `archive:sweep-orphans` echte Beweis-Payloads | `EvidenceArchive.php:380-403`, Scheduler 03:40, Runbook `:305-308` | Einträge nach dem Backup-Zeitpunkt verlieren ihre Zeile; ihre Payloads sind älter als 24 h und fallen in der nächsten Nacht. Bei Bildentfernungen ist das Original damit endgültig weg. | Runbook: Sweep bis nach dem Abgleich aussetzen; Wiederregistrierung aus `manifest.json` oder Quarantäne statt Löschung | gelesen |
| A-04 | Ein nachgeholtes `pm_depublished` bildet einen späteren Stand ab | `PendingArchiveRetry.php:103-128`, `PressReleaseArchiver.php:84` | Archiviert wird der aktuelle Text, nach einer Bearbeitung oder Anonymisierung also nicht mehr der zuletzt veröffentlichte Stand; jeder Marker erzeugt ein `pm_depublished`. | Vorher-Stand bei der Depublikation persistieren; Nachholer je Ereignistyp | gelesen |
| A-06 | Der Restore-Abgleich löscht die Anwendungskopien nicht erneut | `EvidenceArchive.php:345-350` | Bestätigungen, Korrekturen und weich gelöschte Bildzeilen kommen mit dem DB-Backup zurück und bleiben stehen (E13, DSGVO) | `purgeApplicationCopies()` auch im Abgleich | gelesen |
### 5.4 P2 — KI, Veröffentlichung und Jobs
| ID | Befund | Beleg | Szenario | Fix | Nachweis |
|---|---|---|---|---|---|
| K-02 | Kein fachlicher Ausgang für Kontingent- und Bildsperre | `PublishScheduledPressReleases.php:127`, `ClassifyPressRelease.php:179-197` | Eine geplante Meldung mit erschöpftem Kontingent erzeugt alle 5 Minuten Exit 1 und eine Alarm-Mail (288 am Tag), der Kunde erfährt nichts. Ein Admin-Bildtausch in `review` lässt den KI-Job dreimal wiederholen (drei OpenAI-Aufrufe) und in `failed_jobs` enden. Nach der Kundenbestätigung wird die Meldung nicht erneut geroutet. | Beide Ausnahmen als fachlichen Ausgang behandeln (Exit 0, Marke, einmalige Mail); im Job abfangen; nach der Bildbestätigung erneut routen | nachgestellt |
| R-02 / K-03 | Aufrufaggregation und Merge setzen `updated_at` | `AggregatePressReleaseViews.php:192-195` (Eloquent-`increment()` setzt `updated_at`), `CompanyMergeService.php:344` | Sitemap-`lastmod`, JSON-LD `dateModified` und `article:modified_time` melden bei jeder aufgerufenen Meldung eine Änderung. Auf der Dev-Kopie tragen 29.663 veröffentlichte Meldungen von vor 2026 ein `updated_at` nach dem 01.08. | `->toBase()->increment()`; für lastmod eine eigene Spalte `content_updated_at` bzw. `content_hash`-Zeitpunkt, weil `updated_at` nicht mehr rekonstruierbar ist | geprüft, nachgestellt |
| R-03 | `sitemap.xml` führt englische Meldungen unter `/de/` | `SitemapController.php:163` | Die Detailseite filtert nach Sprache, deshalb 404. Auf der Dev-Kopie 303 URLs (311 veröffentlichte EN-Meldungen). Die News-Sitemap macht es richtig (`NewsSitemapController.php:74`). | Edition aus `language` ableiten, Test mit `language=en` | geprüft |
| N19-Rest | `archive()` bewertet Kopien nicht neu | `PressReleaseService.php:647-661` | Der Admin archiviert die Erstquelle A. Kopie B hat Self-Canonical, bleibt aber aus der Sitemap. | `reassignCopiesOf()` wie in den anderen Pfaden, Test | geprüft |
### 5.5 P2 — Roadmap-Funktionen und Betrieb
| ID | Befund | Beleg | Szenario | Fix | Nachweis |
|---|---|---|---|---|---|
| R-04 | Gefälschte Zustellberichte sperren beliebige Adressen | `DsnParser.php:32-66`, `BounceMailProcessor.php:57-90`, `EmailSuppressionService.php:130-161` | Jede Mail ans Return-Path-Postfach mit `multipart/report` und `Action: failed` führt zur Hard-Sperre. `X-Pk-Ref` ist optional und nicht signiert. `GateOutgoingMail` verwirft danach Magic Links, Resets und Rechnungen. | Hard-Sperre nur mit HMAC-signiertem `X-Pk-Ref`, das zum Empfänger passt; alles andere `soft` bzw. zur Sichtung | gelesen |
| R-05 | Unmerge überschreibt spätere Kundenänderungen | `CompanyMergeService.php:432` | Der Merge füllt `description`, der Kunde beansprucht den Newsroom und schreibt neu, der Unmerge setzt NULL. Das widerspricht dem Docblock von `revert()`. | Nur zurücksetzen, wenn der aktuelle Wert noch der Füllwert ist | gelesen |
| E9-Rest | Newsletter-Versand ist technisch nicht gesperrt | `admin/newsletter/*` → `CampaignSender::start` | E9 schiebt U05–U07 auf, bis das Kampagnenmodul genutzt wird. Jeder mit `users:manage` kann aber heute schon starten. U05–U07 sind unverändert offen. | Konfigurationsschalter `newsletter.sending_enabled` (Standard aus), bis U05–U07 umgesetzt sind | gelesen |
### 5.6 P3 — klein, latent, Hygiene
**Bilder**
- B-08: `PressReleaseImageUploader.php:208` setzt `published_ever_at` schon bei vorhandenem `published_at`, auch an Meldungen in `rejected` oder `draft`. Richtig wäre nur bei `status = published`.
- B-09: Ein Admin, der selbst Eigentümer ist, lädt bei einer veröffentlichten Meldung als Kunde hoch; das Bild ist sofort öffentlich, ohne Snapshot.
- B-10: Der Bestätigungslink ist ein voller Login-Link mit 72 h Gültigkeit. Jeder spätere Login-Link entwertet ihn, und der Kunde sieht „Link ungültig“.
- B-11: Das Original wird vor der Verarbeitung geschrieben. Ein Fatal Error (memory_limit bei 8000², GD-Puffer ≈ 256 MB) hinterlässt eine Datei ohne Zeile. Für `press-release-originals` gibt es keinen Orphan-Sweep.
- B-12: Der v2-Manager fängt nur `ImageUploadException`. Die `RuntimeException` aus `ImageService` (z. B. animiertes WebP) gibt im Formular einen 500er, in der API ein 422.
- B-14: `confirmExisting()` läuft ohne Sperre; ein Doppelklick erzeugt zwei Bestätigungen.
**Archiv**
- A-05: Die Annahme „`published_ever_at` schützt den Altbestand“ trifft nicht zu. Auf der Dev-Kopie haben **126.383 abgelehnte Alt-Meldungen alle `published_ever_at = NULL`**; der Backfill kann „offline genommen“ nicht erkennen. `purge-drafts` greift heute nur deshalb nicht, weil diese Meldungen keine Bilder mehr haben (`whereHas('images')`). Die Protokollzeile vom 28.09. („Altbestand korrekt geschützt“) ist zu korrigieren. **Fix:** `whereNull('legacy_id')` im Zweig `rejected`; jede künftige Logik „nie veröffentlicht“ muss `legacy_id` berücksichtigen.
- A-07: Beim Aufheben eines Legal Hold wird die Hold-Historie überschrieben. `archive_access_log.admin_id` steht auf `nullOnDelete`.
- A-08: Der Zugriffszweck steht in der GET-URL und landet damit in den nginx-Logs.
- A-09: `label_corrected` hat keinen Marker und keinen Nachholer.
- A-10: Scheitert bei der Anonymisierung die Sofortlöschung eines nie veröffentlichten Bildes, bleibt das Bild dauerhaft liegen. Weder der Nachholer noch der Systemstatus erfassen das.
- A-11: Lokale Restnachweise (`routes/admin.php:49-52`) sind für jeden Admin abrufbar, nicht nur mit `compliance:manage`.
- A-12: Die Anonymisierung löscht Anhänge nur auf der Disk `public`, privatisierte Anhänge liegen aber auf `local`. Latent, weil es heute 0 Anhänge gibt.
- A-13: `snapshots:purge` (30 Tage) löscht Snapshots auch bei offenem Marker.
- A-14: Ein defektes Bild bricht den ganzen `purge-drafts`-Lauf ab.
- A-15: Für `ARCHIVE_KEY` gibt es keine Schlüsselrotation und keine Schlüssel-ID je Eintrag. Der HMAC des Exports nutzt denselben Schlüssel wie die Verschlüsselung.
- Verdacht: `PressReleaseArchiver.php:86` lädt `company` und `contacts` mit `PortalScope`. Aus einem Request mit Portal-Kontext könnten Firma oder Kontakte im Manifest fehlen.
**KI, Rechte, Jobs**
- K-05: Ein veralteter Status im KI-Job führt zu `LogicException` und einem Retry, also zweitem OpenAI-Aufruf und doppeltem Audit. **Fix:** vor dem Routing `refresh()`.
- K-06: In `admin/press-releases/show.blade.php:32-190` rufen `publish`, `reject`, `archive`, `correctImage`, Precheck und `toggleWithheld` kein `authorize()` auf; Index- und Bearbeiten-Seite verlangen `press-releases:publish`. `publish` fängt `QuotaExceededException` bzw. `LogicException` nicht ab, das gibt einen 500er. `runAiCheck` lehnt bei Rot ab, obwohl der Kommentar „ändert nie den Status“ sagt.
- K-07: N06-Rest, siehe Tabelle; zusätzlich wirft der Rendering-Hook bei aktivem Gate und fehlendem Dokument auf allen vier Seiten.
- K-08: Die drei Login-Abschlüsse sind uneinheitlich (Plan 1.8 offen):
- Nach der 2FA-Challenge wird kein `last_login_at` gesetzt.
- Beim Magic Link mit 2FA geht das Landeziel verloren.
- Google erzwingt `remember=true`.
- `magic-login` läuft hinter `guest`: Ein eingeloggter Kunde landet über den Bildbestätigungslink nicht auf der Meldung.
- K-09: `withoutOverlapping()` hält die Sperre standardmäßig 24 h. Nach einem Absturz wird `publish-scheduled` bzw. `aggregate-views` bis zu einen Tag still übersprungen. **Fix:** `withoutOverlapping(10)` bzw. `(15)`. `archive:purge` und `purge-events` starten beide um 03:20. Das Runbook nennt 6 von 18 Tasks.
**Berichte und Roadmap**
- R-06: Merge und Unmerge bewerten Canonicals nicht neu; `assignAll()` setzt veraltete Verweise nie zurück.
- R-07: Crawler zählen als „Aufrufe“ (`ReleaseDetailPageData.php:90-100`), Crawler-Hits vertrauen allein dem User-Agent.
- R-08: Der Mail-Klick wird synchron und ungedrosselt gezählt. Link-Scanner blähen Stufe 1 auf, und `?src=…&pr=…` ist frei manipulierbar.
- R-09: Der Claim-Tageszähler je Firma (3) zählt vor der Empfängerprüfung. Dritte können den echten Inhaber 24 h aussperren.
- R-10: `email_bounces` (Adressen, Diagnosetexte) hat keine Aufbewahrungsfrist.
- R-11:
- Die Canonical-Zuordnung hängt am DE-Slug der Kategorie; eine Slug-Änderung schaltet sie still ab.
- `llms.txt` wird von `WebCaches` nicht geleert.
- Der Feed-Teaser entschärft Entities doppelt (13 von 3.451 Meldungen).
- `MergedNewsroomLookup` folgt keinen Merge-Ketten.
**Repository**
- Q-05: Der ganze Ordner `docs/` (36 versionierte Dateien) ist lokal gelöscht, aber nicht committet. Laufzeit ist nicht betroffen, weil die API-Doku unter `public/api/v1.yml` liegt. Offen ist die Entscheidung: Löschung committen oder wiederherstellen. Der Entwicklungsplan 6.3 hatte `docs/README.md` noch gepflegt.
- Q-06: `dev/roadmap.md` (Stand 20.07.) und `dev/README.md` nennen die Ordner `dev/2026-09-*` nicht. Wer dort einsteigt, sieht einen veralteten Stand.
- Q-07: `IMAGE_PRECHECK_MODEL` fehlt in `.env.example`.
### 5.7 Optimierungen (belegt)
| Nr. | Stelle | Vorschlag |
|---|---|---|
| OPT-1 | `FeedFetchReport::topFeeds()`, `fetchesFor()` | In SQL gruppieren und limitieren, eine Abfrage statt einer je Feed |
| OPT-2 | `press_release_events` (Dev 2,24 Mio. Zeilen in 75 Tagen, bei 395 Tagen ~11 Mio.) | Index auf `created_at` oder Grenz-ID bestimmen und nach `id` löschen |
| OPT-3 | `ImageProvenanceInspector.php:156` | `pingImage()` statt `readImage()`; heute zweimal voll dekodiert (preflight + store) |
| OPT-4 | Uploader | `hash_file` läuft bis zu dreimal auf denselben Bytes; Hash durchreichen |
| OPT-5 | `PrecheckImagePreparer.php:37` | `readImage($path.'[0]')`, Imagick nach `mergeImageLayers` mit `clear()` freigeben |
| OPT-6 | Archiv | `exportTombstones()`/`sweepOrphans()` auf `cursor()` bzw. Streaming; Index auf `archive_entries.payload_path`; Export per `->then()` an den Purge hängen |
| OPT-7 | `InteractsWithAiDeclaration` | Rendering-Hook stellt 4 Abfragen je Render, auch bei ausgeschaltetem Gate; je Request zwischenspeichern |
| OPT-8 | KI-Jobs | `backoff` setzen, damit 429-Antworten nicht sofort dreimal wiederholt werden |
| OPT-9 | `ExpireGrandfathering` | E18 als Assert (`queue.default === 'database'`), statt nur als Kommentar |
Hot-Path-Prüfung ohne Befund: View-, Crawler- und Feed-Erfassung schreiben per `defer()` nur in Append-Tabellen mit passenden Indexen. Kein Request schreibt auf `press_releases`. Die Feeds nutzen `Cache::flexible`. Der Sitemap-Aufbau dauert etwa 0,25 s, eine Stampede ist derzeit kein Risiko. Die Admin-Bilderliste ist ohne N+1.
---
## 6. Was offen ist — Stand der Dinge
### 6.1 Aus dem Audit-Entwicklungsplan (Blöcke 1–8)
| Punkt | Stand |
|---|---|
| N09 Snapshot-Versionierung | offen, jetzt dringlicher (5.2) |
| 1.8 Login-Abschlüsse als ein Service | offen, siehe K-08 |
| 6.6 MySQL-/Worker-Integrationstests | aufgeschoben; sinnvoller Einstieg jetzt: N02, A-02, K-02 und ExpireGrandfathering mit zwei Verbindungen |
| 7.6 Fokusführung Login-Modal, Cmd/Ctrl+K | manuelle Browserabnahme offen |
| U05–U07 Newsletter | nach E9 aufgeschoben; Versand technisch nicht gesperrt (5.5) |
| 5.2 Messpunkte | Zeitbudget des synchronen Bildchecks, Dekodieraufwand bei 8000², OG-Cache bei Korrektur – ungemessen |
| F21 Legacy-Importer | Notiz bleibt; nur relevant bei erneutem Delta-Lauf |
### 6.2 Aus dem Bildkennzeichnungsplan
| Punkt | Stand | Wartet auf |
|---|---|---|
| Deploy 3: AGB 0.7, DSE 0.4, `img-confirm-v3`, `LEGAL_ACCEPTANCE_GATE=true` | gebaut, aus | Anwaltsantwort, F.5 |
| F.5 Interessenabwägung (DU 4.4) als Dokument | fehlt im Repo | Betreiber |
| K.6 Vorab-Check einschalten (Stufe 1, nach einer Woche Stufe 2) | gebaut, `IMAGE_PRECHECK=false` | AVV mit OpenAI, DSE-Absatz; vorher Zeitbudget messen (bis ca. 60 s synchron) |
| Block I C2PA (Deploy 4) | nicht begonnen | frühestens **26.10.2026**; `c2patool`, Testkorpus, Vertrauensliste |
| Block J Newsroom-Bilder (Deploy 5, Roadmap 1b.3) | Unterbau polymorph vorbereitet | Kontingententscheidung |
| E16 externes Tombstone-Ziel | nicht gebaut | Betreiber (Storage Box/S3) |
| O5 Pro-Tarif ohne Prüfversprechen | offen | Betreiber |
| S19 Löschanfragen-Handover | Dokument fehlt im Repo | Betreiber |
| S23 Bildklausel im Syndikationsvertrag | offen | Anwaltstermin (Roadmap 3.5) |
### 6.3 Aus der Roadmap
| Punkt | Stand | Vorarbeit im Code |
|---|---|---|
| 1c.5 / E8 Indexstatus für Kunden | wartet auf Daten; seit N18 ist die Verteilung belastbar auswertbar | `GSC_CUSTOMER_VISIBLE=false`, Bericht vorhanden |
| 2.2 Google News Publisher Center | Technik fertig | Betreiber meldet an |
| 2.6 Outreach | extern, in Arbeit | – |
| 3.2 Branchen-Digest mit Double-Opt-in | offen | Sperrliste, Abmeldung, Circuit Breaker und Branchenfeed-Abfrage wiederverwendbar; keine Digest-/DOI-Tabelle. Voraussetzung: R-04 behoben |
| 3.3 Agenturmodell | offen | Pivot mit Rollen, `CustomerCompanyContext`, Sammeladressen aus 1a.2; **erst R-01 beheben** |
| 3.4 Agenturansprache, 3.5 Syndikationsvertrag | offen, nicht technisch | – |
| 3.6 IMKIS | Treffen am **01.10.2026** | nur BALD-Partnerbereich (1d.6) |
| Abbruchbedingungen Blöcke 1–3 | Platzhalter „[Zeitraum]/[Schwellwert]“ | Messgrößen vorhanden (`conversion:funnel`, Feed-Bericht); Werte festlegen |
| 6 Slug-Kollisionen aus 1a.3 | offen | kein Werkzeug; nur von Hand per Slug-Wechsel und `companies:merge --email=` |
| connektar `ai_declaration` verpflichtend | angekündigt für ~Oktober 2026 | prüfen, ob die API das Feld schon erzwingt bzw. ab wann |
### 6.4 Betrieb — auf Live zu bestätigen
Diese Punkte stehen im Protokoll als offen oder sind aus dem Code nicht belegbar:
1. `.env`: `FEED_FETCHES_RECORDED_SINCE` und `CRAWLER_HITS_RECORDED_SINCE` auf `2026-09-21`, danach `config:cache` (Protokoll Teil 11). Ohne diese Werte nennt die Absenderansicht ein falsches Startdatum.
2. `livewire:publish --assets`, `queue:restart`, FPM-Neustart nach dem 21.09.
3. `company-memberships:audit --classify --before=…`: die Schlusszeile fehlte in der Ausgabe; bei Bedarf wiederholen.
4. User 19076: in Admin ▸ Zahlungen prüfen, ob die ersetzende MAN-Rechnung offen ist.
5. Bounce-Postfach `bounce@pressekonto.com`, `MAIL_RETURN_PATH` und `BOUNCE_IMAP_*` auf Live aktiv? Der Scheduler holt alle 10 Minuten ab.
6. `ARCHIVE_KEY` getrennt vom DB-Backup gesichert? Nach A-01 zusätzlich per Systemstatus sichtbar machen.
7. PHP-/nginx-Uploadgrenzen ≥ 16 MiB (B-03) und `memory_limit` gegen ein 8000×8000-Bild (B-11).
8. Restore-Übung (DB alt/Disk neu, Disk alt/DB neu, beide alt) wurde nie durchgespielt, siehe A-03 und A-06.
9. Nachlauf Go-Live aus `dev/roadmap.md` (20.07.), Stand unbekannt: Go-Live-Mails, API-Kunden-Mailing, Stripe-Testartefakte, `MONITORING_ALERT_EMAIL`, Umami, Rotation der Connektar-Keys, Abschaltung des KAS-Altsystems.
---
## 7. Empfohlene Reihenfolge — Block 9
| Schritt | Inhalt | Aufwand | Abnahme |
|---|---|---|---|
| 9.1 | **R-01, K-04** (Rechte und Konto), dazu Bestandsprüfung der API-Zuordnungen seit 15.09. | S | Kein fremder Pivot über die Legacy-API; ein vorregistriertes Konto ist nach Google-Login nicht mehr über das alte Passwort erreichbar |
| 9.2 | **K-01, B-01, B-02, B-03** | S | Keine Veröffentlichung mit `manual_review_required_at`; Feed und PDF nur mit freigegebenen Bildern; Upload mit 15 MiB geht durch |
| 9.3 | **R-02/K-03, R-03, N19-Rest** (SEO) | S–M | `updated_at` bleibt beim Aufruf unverändert; EN-Meldungen stehen unter `/en/`; `archive()` ordnet Kopien neu zu |
| 9.4 | **A-01, A-02, A-03** (Beweisarchiv, Runbook) | M | Nachholer ohne Schlüssel endet mit FAILURE; Marker gehen nicht verloren; Runbook setzt den Sweep nach Restore aus |
| 9.5 | **N09, B-04–B-07, A-04, A-06** (Bildnachweis vollständig) | M | Snapshot entspricht der veröffentlichten Kennzeichnung; Bestätigung an das angezeigte Bild gebunden; keine Sofortlöschung eines öffentlich gewesenen Bildes |
| 9.6 | **K-02, R-04, R-05, E9-Schalter** | M | Kein Alarm-Loop; gefälschte DSNs sperren nicht; Unmerge erhält Kundenänderungen; Newsletter ohne Freigabe nicht startbar |
| 9.7 | **Q-01, Q-02, Q-05, Q-06**, P3-Liste nach Gelegenheit | S | Suite parallel grün; Larastan voller Scope grün; `docs/` entschieden; `dev/README.md` und `dev/roadmap.md` verweisen auf den aktuellen Stand |
Danach geht es fachlich weiter mit: Deploy 3 (sobald der Anwalt antwortet), K.6 (nach AVV), Block I ab 26.10., Roadmap 3.2 bzw. 3.3 je nach Ergebnis des IMKIS-Treffens am 01.10. und der Festlegung der Abbruchbedingungen.
Arbeitsregeln wie bisher: zuerst der Test, der am heutigen Code rot ist, dann der Fix. Vor Testläufen `config:clear`. Am Blockende die Gesamtsuite, Larastan und Pint. Migrationen nie auf `press_release_search` und keine FULLTEXT-berührenden ALTERs auf großen Tabellen (CLAUDE.md).

View file

@ -0,0 +1,201 @@
# Umsetzungsaudit vom 02.10.2026
**Grundlagen:** [Entwicklungsplan](ENTWICKLUNGSPLAN.md), [Umsetzungsaudit vom 29.09.2026](UMSETZUNGSAUDIT-2026-09-29.md). Schwerpunkt ist die als erledigt dokumentierte Nacharbeit in Block 9, einschließlich ihrer Wechselwirkungen mit Block 8.
**Geprüfter Stand:** Arbeitskopie auf `technik/betriebsfixes-2026-09`, HEAD `98c465b691752d15a0e07eceb6f927ddda184e90`, einschließlich der bereits vorhandenen Änderungen und unversionierten Dateien. **HEAD allein enthält die geprüfte Umsetzung von Block 9 nicht.** Die im Plan beschriebenen neuen Services, Middleware, Migrationen und Tests liegen teilweise noch unversioniert vor.
**Auftrag:** Prüfung und Dokumentation. Es wurden keine Anwendungsdateien, Tests, Konfigurationen oder Bestandsdaten geändert, keine Migrationen und keine Betriebsbefehle ausgeführt. Einzige neu angelegte Datei ist dieses Audit. Empfehlungen sind nicht umgesetzt.
## 1. Ergebnis
**Die Umsetzung ist deutlich verbessert, aber noch nicht vollständig sachgerecht abgeschlossen.** Die wesentlichen Korrekturen gegen fremde Firmenzuordnung, Google-Vorabübernahme, automatische Veröffentlichung trotz manueller Prüfsperre und ungefilterte Bilder in Feed/PDF sind im Code vorhanden. Mehrere Fehlerpfade bleiben jedoch offen.
Diese Prüfung ergibt **acht konkrete Restbefunde: zwei P1 und sechs P2**. Die P1 betreffen unterschiedliche Bereiche: eine von außen erreichbare Mail-Sperrlücke und einen bedingten Datenverlust beim Entwicklertest. Hinzu kommen offene Betriebsnachweise; diese sind keine nachgewiesenen Produktionsvorfälle.
| ID | Priorität | Befund | Bezug zum letzten Audit/Plan |
|---|---|---|---|
| O01 | **P1 – Sicherheit** | Unsignierte weiche Rückläufer können fremde Mailadressen sperren | R-04, E25, Block 9.6 |
| O02 | **P1 – Testdaten-Sicherheit** | `DB_URL` kann den Schutz vor Tests gegen eine echte Datenbank umgehen | N22, Qualitätsgates |
| O03 | P2 | Nachträglich freigegebene Bilder können nach Snapshotfehler als „nie veröffentlicht“ gelöscht werden | N09, B-05, Block 9.5 |
| O04 | P2 | Restore-Abgleich kann bei wiederverwendeten IDs einen anderen Archiveintrag löschen | A-03/A-06, Block 9.4/9.5 |
| O05 | P2 | Tombstone-Signatur ist durch den Altformat-Rückfall umgehbar; Wiederregistrierung ignoriert ungültige Exporte | A-03/A-15, E31 |
| O06 | P2 | Blockierte Meldungen können alle späteren Terminveröffentlichungen verdrängen | K-02, Block 9.6 |
| O07 | P2 | Archivauftrag wird von Direktweg und Nachholer nicht exklusiv beansprucht | A-02, Block 9.4 |
| O08 | P2 | Nachgeholte Depublikation kann ohne die zugesagten Bilddateien erfolgreich abgeschlossen werden | A-04, Block 9.5 |
**Bewertungsmaßstab:** P1 vor Nutzung des betroffenen Ablaufs schließen; P2 vor fachlicher Abnahme des jeweiligen Ablaufs beheben. Alle acht Befunde sind durch den gelesenen Kontrollfluss belegt. Keiner wurde in dieser Sitzung durch einen schreibenden Test oder gegen Live ausgenutzt. Konkrete Bestandsbetroffenheit und Häufigkeiten wurden nicht erhoben.
## 2. Prüfumfang und Grenzen
Geprüft wurden insbesondere die Änderungen an Authentifizierung und Firmenrechten, Veröffentlichung und Scheduler, Bildbestätigung und Bildlöschung, Snapshot-Versionierung, Archivaufträgen und Restore, Bounce-Verarbeitung und Newsletter-Sperre. Ergänzend wurden die Änderungen an Canonicals, Sitemap/Änderungsdatum, Merge/Unmerge, Zustimmungsgate, Berichten und die zugehörigen Regressionstests gelesen. Bei den älteren Blöcken erfolgte eine gezielte Rückprüfung der berührten Schutzmechanismen, keine vollständige Wiederholung aller historischen Audits.
Die Laravel-Boost-Paketinformationen und die Dokumentationssuche wurden verwendet; Framework-Verhalten beim Datenbank-URL-Override wurde zusätzlich direkt am installierten Laravel-Code geprüft. Die Testkonfiguration und ihre Schutzmechanismen wurden gesondert gelesen.
| Prüfung | Ergebnis dieser Sitzung |
|---|---|
| Plan/Audit gegen Arbeitskopie, Änderungen und neue Dateien | durchgeführt |
| Quelltextprüfung der genannten Aufrufketten und vorhandenen Tests | durchgeführt |
| Sail-Verfügbarkeit: `vendor/bin/sail php -v` | Abbruch: `Docker or Podman is not running.` |
| Pest, Larastan, PHP-Lint, Browser- und MySQL-Parallelitätstests | **nicht ausgeführt** |
| Datenbankabfragen, Mailversand, Archiv-/Restore-Kommandos | **nicht ausgeführt** |
| Live-Konfiguration, Deployment, Schlüssel-/Passwortrotation | nicht verifiziert |
Die im Entwicklungsplan angegebenen **2.265 erfolgreichen Tests, ein übersprungener Test sowie grüne Larastan-/Pint-Läufe** sind historische Angaben vom 29.09.; sie wurden hier nicht erneut bestätigt. Tests würden zudem Testdaten, Logs, Storage-Fakes und gegebenenfalls kompilierte Views schreiben. Es wurden weder Container gestartet noch Caches gelöscht, um den reinen Auditauftrag einzuhalten.
Die folgenden Zeilenangaben beziehen sich auf die geprüfte Arbeitskopie; nach weiteren Änderungen können sie sich verschieben.
## 3. Was am Code nachvollziehbar umgesetzt ist
„Umgesetzt“ bedeutet in dieser Tabelle: Schutz beziehungsweise Korrektur ist im geprüften Code vorhanden. Es ist keine Bestätigung eines erfolgreichen Live-Deployments oder einer vollständig bestandenen Laufzeitprüfung.
| Bereich / alte IDs | Stand und Beleg |
|---|---|
| Fremde Firmenzuordnung, R-01 | `ApiNewsroomAssignment::assign()` prüft vor Pivot und Portaländerung bestehende Zugehörigkeit beziehungsweise Allowlist. Neue API-Pivots tragen `granted_via = api`; der Bestandsprüfbefehl ist vorhanden. Die Bereinigung bereits entstandener Fremdrechte bleibt ein Betriebsnachweis. |
| Google-Vorabübernahme, K-04 | `SocialAuthService::resolveUser()` verwirft bei Verifikation eines offenen Kontos Passwort, Remember-Token und Sanctum-Tokens; `SessionRevocation` und die Web-Middleware behandeln alte Sessions. Regressionstest vorhanden. Persistenz und Ausfallverhalten des Cache-basierten Widerrufs wurden nicht dynamisch geprüft. |
| Gemeinsamer Loginabschluss, K-08 / Plan 1.8 | `LoginCompletion` ist vorhanden und in Google-, Magic-Link-, Volt-Passwort- und Fortify-Abschluss eingebunden. Die pauschale spätere Plan-Notiz „1.8 nur als Google-Zweig“ ist für diese Arbeitskopie veraltet. |
| Manuelle Prüfung, K-01 | Scheduler filtert `manual_review_required_at`; `publish()` prüft bei automatischen Quellen zusätzlich unter der PM-Sperre. Der Adminweg bleibt die vorgesehene manuelle Entscheidung. |
| Öffentliche Bilder, B-01/B-02 | Startseiten-Feed nutzt `publiclyVisible()`, Card-Helfer und PDF prüfen die Auslieferbarkeit. Auch die Medienroute verwendet den gemeinsamen Freigabezustand. |
| Uploadgrenze, B-03 | Temporäre Livewire-Uploadregel ist auf 16 MiB angehoben. Die PHP-/Proxygrenzen sind damit noch nicht nachgewiesen. |
| Bestätigung, B-04/B-14 | Gesperrte Bild-ID und SHA in der Kundenkomponente; Service prüft Hash und aktuellen Bildzustand unter Bildsperre. |
| Entfernen und Rückhaltegründe, B-05/B-07 | Remover lädt PM und Bild unter Sperren neu. `precheck_withheld_at` und `withheld_at` sind getrennt. Rest: O03. |
| Veröffentlichungssnapshots, N09 | Dateifassung enthält Bytes-/Metadatenbezug, OG-Datei und JSON; Schreibfehler werden erkannt und Fassungen beim Bildarchivieren eingesammelt. Rest: O03 und O08; ein allgemeiner Snapshot-Nachholer fehlt. |
| Archivaufträge, A-01/A-02/A-04 | Auftragstabelle ersetzt den einzelnen Marker, Textstände werden im verschlüsselten Auftrag festgehalten; Nachholer ohne Schlüssel bricht ab. Rest: O07/O08. |
| Restore, A-03/A-06 | Quarantäne, Sweep-Pause, Wiederregistrierung und Bereinigung von Anwendungszeilen sind vorhanden. Rest: O04/O05. |
| Änderungsdatum/Sprache/Canonical, R-02/R-03/N19 | Aggregation und PM-Verschiebung beim Merge vermeiden `updated_at`; `content_changed_at` wird für Inhaltsänderungen verwendet; Sitemap berücksichtigt die Sprache; Archivierung stößt Canonical-Neuzuordnung an. |
| Merge/Unmerge, R-05 | Eingefüllte Feldwerte werden protokolliert; Unmerge prüft vor Rücksetzen, ob der Wert noch dem eingefüllten Wert entspricht. |
| Zustimmungsgate, K-07 | `legalAcceptanceVersions` ist `#[Locked]`; abweichende Fassungen und fehlende Pflichtdokumente verhindern die Einreichung. |
| Newsletter, E9 | Versand standardmäßig aus; Start, Resume-Befehl und Versandjob berücksichtigen die Sperre. U05–U07 sind weiterhin aufgeschoben und dadurch nicht behoben. |
| Tests/Qualitätsgates, Q-01/Q-02 | Gemeinsamer Testhelfer steht in `tests/Pest.php`; die beanstandete Slug-Annotation wurde entfernt. Aktueller Lauf nicht bestätigt; zusätzliche Guard-Lücke O02. |
## 4. Restbefunde mit Abhilfe
### O01 · P1 · Bounce-Signatur schützt nicht vor Sperren über weiche Rückläufer
**Fundstellen:** `app/Services/Mail/BounceMailProcessor.php:58–98, 119–147`; `app/Services/Mail/EmailSuppressionService.php:130–163, 221–257`; `config/bounces.php:45–57`.
**Befund:** Die Signatur wird berechnet, aber ein ungültiger Nachweis wird nur behandelt, wenn `$kind === Hard` ist. `delayed` ergibt sofort `Soft`; auch ein zunächst harter Rückläufer kann vorher aufgrund seines Status in `Soft` umklassifiziert werden. Beide Wege gelangen ungeprüft in die Sperrzählung. Drei weiche Ereignisse innerhalb von sieben Tagen genügen mit der Standardkonfiguration für eine aktive Sperre.
**Nachvollziehbarer Fall:** Drei unsignierte DSNs mit jeweils eigener Message-ID, derselben fremden Empfängeradresse und `Action: delayed` erreichen das verarbeitete Bounce-Postfach. Der Parser liefert weiche Rückläufer; die Signatur hat auf diesen Zweig keinen Einfluss. Die Sperrliste blockiert anschließend auch transaktionale Mails an diese Adresse. Voraussetzung ist, dass solche Nachrichten das konfigurierte Postfach und den Abhollauf erreichen; eine vorgelagerte Absenderauthentisierung ist in dieser Codekette nicht implementiert.
**Zusätzlich:** Im dokumentierten Übergang `BOUNCE_UNSIGNED_HARD_KIND=soft` ist derselbe Effekt auch mit drei unsignierten harten Rückläufern möglich. **Die spätere Umstellung auf `policy` schließt den direkten Soft-Pfad nicht.**
**Abhilfe:** Für jeden Rückläufer, der eine Empfängersperre beeinflussen kann, einen gültigen empfängergebundenen Nachweis verlangen. Ohne Nachweis ausschließlich zur Sichtung protokollieren, ohne Soft-/Hard-Sperrzähler. Eine bewusst unsichere Übergangsregel ist kein vollständiger R-04-Fix.
**Regressionstest:** Mehrere unsignierte beziehungsweise falsch signierte `delayed`- und `failed/4.x`-DSNs mit verschiedenen Message-IDs dürfen auch oberhalb des Soft-Schwellwerts keine Sperre erzeugen. Beide Werte von `unsigned_hard_kind` abdecken. Gültig signierte Soft-Bounces müssen weiterhin regulär zählen.
**Vorhandene Tests:** `BounceMailProcessingTest` prüft den einzelnen unsignierten Hard-Bounce und wiederholte falsch signierte Hard-Bounces, aber nicht diese wiederholten Soft-Wege.
### O02 · P1 · Testdatenbank-Guard ignoriert den URL-Override
**Fundstellen:** `tests/TestCase.php:31–54`; `phpunit.xml:30–33`; `config/database.php:58–61`; `vendor/laravel/framework/src/Illuminate/Database/DatabaseManager.php:224–225`; `vendor/laravel/framework/src/Illuminate/Support/ConfigurationUrlParser.php:36–51`; `tests/Pest.php:27–28`.
**Befund:** Der Guard kontrolliert den Verbindungsnamen und die rohen Konfigurationswerte `driver=sqlite`, `database=:memory:`. Die SQLite-Konfiguration erlaubt daneben `url=env('DB_URL')`. Laravel löst diese URL später auf und überschreibt damit unter anderem Treiber und Datenbank. PHPUnit erzwingt `DB_CONNECTION` und `DB_DATABASE`, neutralisiert `DB_URL` jedoch nicht.
**Nachvollziehbarer Fall:** Die Testumgebung erbt eine erreichbare reale Datenbank über `DB_URL`. Die rohen SQLite-/Memory-Werte passieren den Guard; die tatsächliche Verbindung wird anschließend aus der URL gebildet. `RefreshDatabase` kann dann `migrate:fresh` gegen die reale Datenbank starten und Tabellen löschen.
**Einordnung:** Das ist ein bedingtes Entwicklungs-/CI-Risiko, keine behauptete aktuelle Löschung und kein öffentlicher Webangriff. In den geprüften lokalen Env-Dateien wurde kein `DB_URL`-Eintrag festgestellt; die gesamte Prozess-/CI-Umgebung ist damit nicht ausgeschlossen.
**Abhilfe:** `DB_URL` für Tests ausdrücklich neutralisieren. Zusätzlich die vollständig aufgelöste Verbindungskonfiguration prüfen beziehungsweise jede nicht leere URL bereits vor den DB-Traits ablehnen. Der bestehende frühe Zeitpunkt in `setUpTraits()` ist richtig und muss erhalten bleiben.
**Regressionstest:** Nur den Konfigurationswert `database.connections.sqlite.url` auf eine Nicht-Test-URL setzen und verlangen, dass der Guard ohne Verbindungsaufbau abbricht. Keine echte Datenbank für diesen Negativtest verwenden. `TestDatabaseGuardTest` deckt bisher nur einen anderen Default-Verbindungsnamen ab.
### O03 · P2 · Veröffentlichungsmarke hängt bei späterer Bildfreigabe am Snapshot-Erfolg
**Fundstellen:** `app/Services/PressRelease/Images/PressReleaseImageUploader.php:324–364`; `app/Services/Image/Precheck/ImagePrecheckReviewService.php:31–55`; `app/Services/PressRelease/Images/ImageCorrectionService.php:100–145`; `app/Services/PressRelease/Images/PublishedImageSnapshot.php:128–145`; `app/Services/PressRelease/Images/PressReleaseImageRemover.php:86–119`.
**Befund:** Beim normalen PM-Publish wird die Veröffentlichungsmarke in der Transaktion gesetzt. Bei nachträglicher Kundenbestätigung an einer bereits veröffentlichten PM wird dagegen zunächst nur `rights_confirmed_at` gespeichert. Das Bild ist damit öffentlich auslieferbar. `published_ever_at` setzt erst der nachgelagerte Snapshot nach erfolgreichen Dateischreibvorgängen. Ähnlich arbeiten die Freigabe der Vorab-Sichtung und die erstmalige Freigabe im Korrekturweg. Snapshotfehler werden lediglich gemeldet.
**Nachvollziehbarer Fall:** Admin lädt an einer veröffentlichten PM ein neues Bild hoch; Kunde bestätigt; Snapshot-Schreiben scheitert. Die Medienroute kann das Bild ausliefern, `published_ever_at` bleibt aber NULL. Beim späteren Entfernen nimmt der Remover den Zweig „nie veröffentlicht“ und löscht Original, Bestätigung und Bildzeile ohne `image_removed`-Archivauftrag. Ein konkurrierendes Entfernen im Zeitfenster vor dem Snapshot kann denselben Fehler auslösen.
**Abhilfe:** Bei jedem Übergang zur öffentlichen Auslieferbarkeit die Veröffentlichungsmarke innerhalb einer mit dem Remover koordinierten PM-/Bildtransaktion setzen. Snapshot-Erstellung als separat nachholbare Arbeit führen. Ein fehlgeschlagener Nachweis darf aus einem tatsächlich freigegebenen Bild kein „nie veröffentlichtes“ machen.
**Regressionstest:** Nachträgliche Kundenbestätigung, Precheck-Freigabe und Korrektur-Freigabe an veröffentlichter PM jeweils mit fehlgeschlagenem Snapshot-Schreiben prüfen. Danach entfernen: Beweisdateien müssen archiviert oder für einen offenen Archivauftrag erhalten werden. Ergänzend MySQL-Test für Freigabe gegen Entfernen mit zwei Verbindungen.
**Vorhandene Tests:** `ImagePipelineAuditTest:299` deckt den Schreibfehler beim normalen PM-Publish ab; dort wird die Marke bereits separat gesetzt. Dieser Test beweist die nachträglichen Freigabewege nicht.
### O04 · P2 · Restore-Tombstones werden nur über die numerische ID zugeordnet
**Fundstellen:** `app/Services/Archive/EvidenceArchive.php:450–480, 817–834`.
**Befund:** Der Restore-Abgleich löscht zunächst den im Export genannten Dateipfad, sucht die zu tombstonende Datenbankzeile aber ausschließlich mit `find(exportierte ID)`. Es wird nicht geprüft, ob deren `payload_path` zum Export passt. Die neue Wiederregistrierung erzeugt ihrerseits frische Auto-Increment-IDs.
**Nachvollziehbarer Fall:** Nach Rückkehr zu einem älteren DB-Stand wird ID 101 für Payload B verwendet, während der aktuelle Tombstone-Export ID 101 für die früher gelöschte Payload A enthält. Der Abgleich markiert B als gelöscht, bereinigt gegebenenfalls die Anwendungskopien von B und löscht in der anschließenden Tombstone-Schleife auch dessen Payload. Die HMAC kann dabei vollständig gültig sein: Falsch ist die Identitätszuordnung. Wiederregistrierung in anderer Reihenfolge und ein erneuter Abgleich sind ein weiterer Weg zu abweichenden IDs.
**Abhilfe:** Den UUID-haltigen Payload-Pfad beziehungsweise eine dauerhaft gespeicherte Archiv-UUID als Wiederherstellungsidentität verwenden. ID und Pfad müssen vor jeder destruktiven Aktion konsistent sein; bei Konflikten abbrechen und melden. Eine fremde Zeile darf niemals allein aufgrund der alten ID tombstoned werden.
**Regressionstest:** Gültiger Export für ID X/Pfad A, aktuelle DB-Zeile mit ID X/Pfad B. B samt Anwendungskopien bleibt erhalten. Zusätzlich Wiederregistrierung mit geänderten IDs und wiederholten Restore-Abgleich prüfen.
### O05 · P2 · Ungültige und unsignierte Tombstone-Exporte werden nicht sicher abgegrenzt
**Fundstellen:** `app/Services/Archive/EvidenceArchive.php:560–564, 847–888`; `app/Console/Commands/ArchiveReregister.php:29–49`; `app/Console/Commands/ArchiveReconcileAfterRestore.php:27–42`.
**Befund A – Signatur-Rückfall:** Fehlt `hmac` oder ist es kein String, behandelt `readTombstoneExport()` den Export als zulässiges Altformat und gibt die Zeilen ungeprüft zurück. Ein veränderter neuer Export wird somit verwendbar, sobald sein HMAC-Feld entfernt oder auf NULL gesetzt wird. Das Dateiformat selbst weist nicht vertrauenswürdig nach, dass es tatsächlich vor der Signaturumstellung entstand. Betroffen ist der zugesagte Integritätsschutz einer Löschliste; ein anonymer Zugriff auf diese Datei wurde nicht festgestellt.
**Befund B – Wiederregistrierung:** Bei ungültiger Signatur liefert der Leser eine leere Liste plus Status `invalid`. `reregister()` übernimmt nur die Liste und verwirft den Status. Der Befehl kann daraufhin Payloads als neue Einträge registrieren, die laut dem eigentlich maßgeblichen Export gelöscht bleiben müssten, und dennoch Erfolg melden. Das betrifft auch einen Export, dessen früherer Signaturschlüssel versehentlich nicht konfiguriert ist.
**Abhilfe:** Für den Normalbetrieb gültig signierte Exporte verlangen. Altformat-Import nur über einen ausdrücklich ausgewählten, kontrollierten Migrationsweg zulassen. Ungültige Exporte müssen Wiederregistrierung und destruktiven Exportabgleich stoppen. Für fehlende Exporte eine ausdrückliche Restore-Regel vorsehen, statt Unkenntnis still als „nichts gelöscht“ zu behandeln.
**Regressionstest:** Manipulierten Export zusätzlich ohne `hmac` und mit `hmac=null` prüfen. Bei ungültiger Signatur darf `archive:reregister` keine Zeile erzeugen und muss fehlschlagen. Der vorhandene Manipulationstest prüft nur geänderte Einträge bei weiterhin vorhandenem HMAC im Reconcile-Befehl.
### O06 · P2 · Dauerhaft blockierte Kandidaten verstopfen den Veröffentlichungslauf
**Fundstellen:** `app/Console/Commands/PublishScheduledPressReleases.php:68–98, 132–180`.
**Befund:** Jeder Lauf lädt nur die ältesten `--limit` Kandidaten, standardmäßig 200. Meldungen ohne Kontingent oder ohne Bildbestätigung bleiben mit unverändertem Status und Termin in derselben Auswahl. Ein Fortsetzen hinter diesen Kandidaten findet nicht statt. Der Lauf endet bei ausschließlich fachlichen Blockaden erfolgreich.
**Nachvollziehbarer Fall:** 200 ältere fällige Meldungen sind blockiert, eine jüngere ist vollständig veröffentlichungsfähig. Jeder Lauf verarbeitet erneut dieselben 200. Die jüngere Meldung erscheint nie, solange der blockierte Bestand die vordere Auswahl füllt. Zum kleinen Regressionstest genügen `--limit=2`, zwei blockierte und eine zulässige Meldung.
**Abhilfe:** Kandidaten fair und mit stabilem Cursor durchlaufen; fachliche Blockaden dürfen nicht dauerhaft das gesamte Laufbudget belegen. Beispielsweise Seiten nach `(scheduled_at, id)` mit getrenntem Scan-/Zeitbudget verarbeiten oder blockierte Kandidaten mit einer kontrollierten Wiedervorlage versehen. Allein das Limit zu erhöhen verschiebt den Fehler.
**Regressionstest:** Die zulässige Meldung hinter einer vollen Seite blockierter Kandidaten wird im selben oder einem nachweislich fortschreitenden Folgelauf veröffentlicht. Blockaden bleiben sichtbar und werden weiterhin erneut geprüft.
### O07 · P2 · Archivaufträge sind trotz Transaktionen nicht gegen Doppelverarbeitung geschützt
**Fundstellen:** `app/Services/Archive/ArchivePendingQueue.php:88–99, 123–135`; `app/Services/Archive/PendingArchiveRetry.php:58–69, 108–140`; `app/Console/Commands/ArchiveRetryPending.php:43–53`; `app/Services/Archive/EvidenceArchive.php:138–165`.
**Befund:** Sowohl `completeWith()` als auch `retry()` starten die Archivierung mit einer bereits geladenen Auftraginstanz. Keiner der Wege lädt und beansprucht die Auftragszeile exklusiv unter Sperre; auch das zwischenzeitliche Löschen des Auftrags wird vor der Archivierung nicht geprüft. Für die neuen `pm_depublished`-/`gdpr_change`-Aufträge gibt es keine eindeutige, im Archiveintrag gespeicherte Auftragsidentität.
**Nachvollziehbarer Fall:** Der Direktweg hat seinen fachlichen Commit abgeschlossen, aber noch nicht archiviert. Der Nachholer liest den nun sichtbaren Auftrag. Beide erzeugen ein Archiv-ZIP und eine Archiveintrag-Zeile; erst anschließend löschen sie denselben Auftrag. Die zweite Löschung mit null Treffern rollt den zweiten Archiveintrag nicht zurück. Auch zwei manuell gleichzeitig gestartete Nachholer können dies auslösen. `withoutOverlapping()` am Scheduler koordiniert den HTTP-Direktweg nicht.
**Auswirkung:** Ein Ereignis bekommt mehrere Nachweise und gegebenenfalls unterschiedliche Erfassungs-/Aufbewahrungszeitpunkte. Bei Bildereignissen konkurrieren zusätzlich Dateilesen und die Bereinigung lokaler Beweisdateien. Das Versprechen „ein Auftrag je Ereignis, einmaliger Abschluss“ ist damit nicht vollständig umgesetzt.
**Abhilfe:** Beide Wege über denselben atomaren Claim beziehungsweise dieselbe Auftragssperre führen und nach Sperrerwerb das weitere Bestehen prüfen. Eine dauerhafte eindeutige Ereignis-/Auftrags-ID am Archiveintrag verhindert Doppelablage zusätzlich. Externe Dateioperationen und Wiederholung nach Prozessabbruch ausdrücklich einbeziehen.
**Regressionstest:** Zwei Verbindungen/Prozesse bearbeiten denselben Auftrag; genau ein vollständiger Archiveintrag entsteht, der Marker wird erst nach Abschluss entfernt. Zusätzlich eine bereits gelöschte, aber noch im Speicher gehaltene Auftraginstanz erneut verarbeiten: keine weitere Ablage. Keine dynamische MySQL-Reproduktion in dieser Sitzung.
### O08 · P2 · Depublikationsauftrag hält Bildpfade fest, aber nicht deren Verfügbarkeit
**Fundstellen:** `app/Services/Archive/PressReleaseArchiver.php:123–145, 154–193`; `app/Services/PressRelease/Images/PressReleaseImageRemover.php:149–180`; `app/Services/Archive/PendingArchiveRetry.php:108–128`; `app/Services/Archive/ArchivePendingQueue.php:90–96`.
**Befund:** Der festgehaltene Depublikationsstand enthält den Snapshot-Pfad und Bildmetadaten. Die Bytes werden erst beim späteren Archivieren gelesen. Existiert der Pfad dann nicht mehr, wird die Datei ohne Fehler ausgelassen. `recordDepublished()` erzeugt trotzdem einen Eintrag, wodurch der Auftrag als erfolgreich erledigt gilt.
**Nachvollziehbarer Fall:** Depublikation bei Archivstörung erzeugt einen offenen Auftrag. Nach Behebung der Störung wird das Bild vor dem Nachholer entfernt und regulär als `image_removed` archiviert; der Remover bereinigt seine lokalen Snapshots. Der spätere `pm_depublished`-Eintrag enthält nun den Text und Bildmetadaten, aber keine Bilddatei. Der Auftrag verschwindet ohne Hinweis auf einen unvollständigen Nachweis. Die Datei kann noch im getrennten Bildarchiv vorhanden sein, wird hier aber weder übernommen noch verbindlich referenziert. Bei unabhängigem Dateiverlust fehlt auch dieser Rückweg.
**Abhilfe:** Unveränderliche Snapshot-Dateien bis zum Abschluss aller referenzierenden Aufträge erhalten oder den Auftrag auf eine dauerhaft verfügbare archivierte Fassung verweisen lassen. Erwartete, aber fehlende Dateien dürfen nicht still als vollständige Archivierung gelten. Für v2-Bilder Vollständigkeit anhand der im Auftrag festgehaltenen Fassung prüfen; Altbestand ohne Snapshot gesondert kennzeichnen.
**Regressionstest:** Depublikation mit fehlgeschlagener Archivierung, danach erfolgreiche Bildentfernung, danach Retry. Der Depublikationsnachweis enthält die richtige damalige Bildfassung oder eine belastbare Archivverknüpfung. Bei fehlenden Bytes bleibt ein sichtbarer Fehler/offener Auftrag bestehen. Der vorhandene A-04-Test prüft spätere Textänderungen, nicht diese Dateilebensdauer.
## 5. Offene Punkte und Hinweise ohne neuen bestätigten Laufzeitbefund
1. **Deployment und Rechtebereinigung:** Block 9 ist in dieser Arbeitskopie uncommittet. Ob die acht Migrationen, die Allowlist, Worker-Neustarts und die Prüfung alter API-Zuordnungen auf Live durchgeführt wurden, ist nicht belegt. Die vorhandenen neuen Dateien müssen beim späteren Commit/Deploy vollständig erfasst werden. Eine neue Sperre am API-Einstieg entzieht bereits vorhandene Fremdrechte nicht rückwirkend.
2. **Bekanntes Zugangsdatenleck:** Der Entwicklungsplan dokumentiert ein aus der Arbeitskopie entferntes KAS-Passwort, das in der Git-Historie verblieben ist. Der Wert wurde hier weder ausgegeben noch in das Audit übernommen. Die erfolgreiche Rotation ist aus dem geprüften Stand nicht nachgewiesen und bleibt vorrangig zu bestätigen. Das ist ein offener Betriebsnachweis, keine Feststellung einer erfolgten Kompromittierung.
3. **Newsletter:** `NEWSLETTER_SENDING_ENABLED` ausgeschaltet lassen, bis U05–U07 umgesetzt und geprüft sind. Der neue Schalter ersetzt deren fachliche Korrektur nicht.
4. **Restore-Abnahme:** DB-alt/Disk-neu, Disk-alt/DB-neu und beide-alt inklusive Export, wiederverwendeten IDs, Signaturfehlern, Quarantäne und erneuten Abgleichen in einer isolierten Umgebung testen. Bei Wiederregistrierung sind Legal Holds laut bestehendem Code/Runbook zusätzlich zu prüfen. Ein externes Tombstone-Ziel bleibt nach E16 offen.
5. **Bildbetrieb:** Proxy-/PHP-Uploadgrenzen, Speicherbedarf großer Bilder, Zeitbudget des synchronen Vorabchecks und Browser-/CDN-Verhalten nach OG-Korrekturen sind weiterhin nicht durch diese Prüfung belegt. Das sind die bereits dokumentierten Mess-/Abnahmepunkte, keine hier gemessenen neuen Ausfälle.
6. **Qualität:** MySQL-/Worker-Integration und Browser-/Tastaturabnahme bleiben offen. Die gelesenen Tests belegen gute Abdeckung der normalen und vieler einzelner Fehlerwege, aber nicht die oben aufgeführten Kombinationen und Parallelabläufe. Volt-Code ist nicht vollständig durch den bisherigen PHPStan-Scope abgesichert.
7. **Dokumentationsstand:** Die lokalen Löschungen unter `docs/` sind weiterhin vorhanden und wurden nicht verändert. Der Plan enthält neben dem aktuellen Block-9-Stand noch historische „offen“-Aussagen zu bereits zusammengeführten Loginabschlüssen und Snapshot-Versionierung. Bei einer späteren Dokumentationspflege historische Protokolle deutlich von der aktuellen Restliste trennen.
8. **Weitere Fachfreigaben:** Die im letzten Audit benannten Freigaben für Rechtstexte, Vorabcheck und spätere Bildfunktionen werden durch dieses technische Audit nicht ersetzt. E19–E34 sind im Plan ausdrücklich als noch zu bestätigende Entscheidungen gekennzeichnet; eine spätere Bestätigung wurde hier nicht festgestellt.
## 6. Empfohlene nächste Umsetzung
| Reihenfolge | Arbeit | Abnahmebedingung |
|---|---|---|
| 1 | O01: Signaturnachweis für jede sperrwirksame Bounce-Art | Auch wiederholte unsignierte Soft-DSNs sperren keine Adresse. |
| 2 | O02: Testumgebung und tatsächliche DB-Konfiguration absichern | URL-Override wird vor Verbindung und `RefreshDatabase` abgefangen. |
| 3 | O03: Bildfreigabe, Veröffentlichungsmarke und Nachweisauftrag koordinieren | Ein öffentlich freigegebenes Bild gerät trotz Snapshotfehler nicht in die Sofortlöschung. |
| 4 | O04/O05: Restore-Identität und Export-Vertrauen korrigieren | Kein fremder Archiveintrag wird gelöscht, kein gelöschter Nachweis bei ungültigem Export wieder registriert. |
| 5 | O07/O08: Archivaufträge exklusiv und vollständig abschließen | Ein Ereignis ergibt einen vollständigen, reproduzierbaren Nachweis; fehlende Dateien bleiben sichtbar. |
| 6 | O06: Fairer Fortschritt im Scheduler | Blockierte Kandidaten halten andere fällige Veröffentlichungen nicht dauerhaft auf. |
| 7 | Betroffene Regressionstests, dann isolierte Gesamtsuite und Betriebsabnahme | Aktuelle Ergebnisse dokumentiert; Live-Schritte und Zugangsdatenrotation nachgewiesen. |
**Abnahmeurteil:** Die Kernrichtung der Nacharbeit ist sachgerecht. Die pauschale Kennzeichnung von Block 9 als vollständig erledigt ist für Sicherheit, Bildnachweis und Restore jedoch zu weitgehend. Die oben beschriebenen Restfehler sollten vor der jeweiligen Freigabe geschlossen werden. Dieses Audit selbst hat ausschließlich dokumentiert.

View file

@ -0,0 +1,452 @@
# Umsetzung: Bildnachweis, KI-Kennzeichnung und Rechtebestätigung
*Decision-Update und Umsetzungsspezifikation für pressekonto.com / presseecho.de / businessportal24.com*
*Stand: 15.09.2026 · Status: Entwurf zur Umsetzung · Rechtstexte sind Entwürfe, Anwaltsprüfung offen*
---
## 0. Zweck
Bilder in Pressemitteilungen sollen mit weniger Formularaufwand eingereicht werden, dabei aber so dokumentiert sein, dass im Fall einer Abmahnung der Regress gegen den Einreicher trägt und der Bildnachweis öffentlich sichtbar ist. Das Portal liest Herkunftsdaten (Content Credentials, IPTC) beim Upload aus, belegt das Formular vor und sperrt eine Abschwächung der Kennzeichnung. Jede Veröffentlichung endet mit einer aktiven, protokollierten Zustimmung.
**Nicht Teil dieser Umsetzung**
- Der Bestand (migrierte und bisher veröffentlichte Bilder) wird nicht nachgeprüft und nicht nachbestätigt. Stichprobe ohne Auffälligkeiten, Bestand bleibt wie er ist.
- Keine inhaltliche Bildprüfung durch das Portal (kein Vorab-Check auf Personen, Marken, Urheberschaft). Das Portal prüft Metadaten, nicht Inhalte.
- Keine Änderung am Freigabelauf für Texte.
---
## 1. Bestätigte Entscheidungen
| Nr. | Entscheidung | Typ |
|---|---|---|
| E1 | Bestand bleibt unverändert. Neue Regeln gelten ab Deployment für neue Uploads. | Geschäft |
| E2 | Bildnachweis wird sichtbar am Bild ausgegeben: dauerhaft als Textzeile unter dem Bild **und** als Overlay-Element im Bild, das sich zu einem Detailfenster öffnet. | Geschäft |
| E3 | Beim Upload werden C2PA-Manifest und IPTC/XMP-Metadaten ausgelesen. Ergebnis wird persistiert, nicht nur im Bild belassen. | Technik |
| E4 | Treffer in den Herkunftsdaten belegen das Formular vor. Die KI-Kennzeichnung ist dann **gesperrt** und kann nicht abgeschwächt werden. Eine Verschärfung durch den User bleibt immer möglich. | Geschäft |
| E5 | Ohne Treffer muss der User Bildnachweis und KI-Kennzeichnung selbst angeben (Pflicht). | Geschäft |
| E6 | In jedem Fall ist eine aktive Zustimmung (Toggle) mit protokollierter Textversion erforderlich. Kein stilles Durchwinken, auch nicht bei Vorbelegung. | Geschäft |
| E7 | Die Abfragen zu Personen, Marken und privaten Orten entfallen als eigene Felder. Sie sind im Bestätigungstext und in den AGB abgedeckt. | Geschäft |
| E8 | Die API erhält dieselben Bildfelder (Bildnachweis, KI-Kennzeichnung, Rechtebestätigung). Serverseitig läuft dieselbe Metadatenprüfung. connektar liefert derzeit keine Bilder, künftige API-Nutzer schon. | Technik |
| E9 | Das Original der hochgeladenen Datei wird privat aufbewahrt (Beweissicherung). Öffentlich ausgeliefert wird nur das bearbeitete Derivat. | Technik |
**Grundregel für die Sperre (E4):** Die Sperre wirkt nur in eine Richtung. Metadaten „KI-generiert" → User kann nicht „Originalfoto" wählen. Metadaten „Digitalaufnahme" → User kann trotzdem „KI-bearbeitet" wählen. Damit gibt es keinen Fall, in dem das Portal einen User zwingt, eine Kennzeichnung wegzulassen.
---
## 2. Datenmodell
### 2.1 Tabelle `press_release_images` (Ergänzungen)
| Spalte | Typ | Inhalt |
|---|---|---|
| `credit` | string(255), not null | Bildnachweis, wie er ausgegeben wird (z. B. „Musterfirma GmbH", „Foto: Anna Beispiel / Musterfirma") |
| `credit_source` | enum: `user`, `metadata`, `api` | Woher der Bildnachweis stammt |
| `license_url` | string, nullable | Optionaler Link zur Lizenz oder Quelle |
| `ai_declaration` | enum: `not_declared`, `original`, `ai_generated`, `ai_edited` | Effektive KI-Kennzeichnung |
| `ai_declaration_source` | enum: `user`, `metadata`, `api`, `legacy` | Woher die Kennzeichnung stammt |
| `ai_declaration_locked` | boolean, default false | true, wenn aus verifizierten Metadaten abgeleitet |
| `provenance` | json, nullable | Normalisiertes Ergebnis der Metadatenprüfung (siehe 2.3) |
| `provenance_checked_at` | timestamp, nullable | Zeitpunkt der Prüfung |
| `provenance_tool_version` | string, nullable | z. B. `c2patool 0.x / exiftool 13.x` |
| `original_path` | string, nullable | Pfad auf privater Disk |
| `original_sha256` | string(64) | Hash der Originaldatei |
| `rights_confirmation_id` | FK → `image_rights_confirmations` | Letzte gültige Bestätigung |
Bestand: alle vorhandenen Datensätze erhalten `ai_declaration = not_declared`, `ai_declaration_source = legacy`, `credit` bleibt wie migriert (auch leer).
### 2.2 Tabelle `image_rights_confirmations` (neu)
Das ist der eigentliche Beweis. Ohne diese Tabelle ist der Toggle im Streitfall wertlos.
| Spalte | Inhalt |
|---|---|
| `id` | |
| `press_release_image_id` | FK |
| `confirmed_at` | Zeitstempel |
| `actor_type` | `user`, `magic_link`, `api_key` |
| `actor_id` | User-ID, Token-ID oder API-Key-ID |
| `company_id` | Firmenprofil, für das eingereicht wurde |
| `confirmation_text_version` | Version des Bestätigungstextes (Single-Source aus `legal_document_sections`) |
| `agb_version` | AGB-Version zum Zeitpunkt der Bestätigung |
| `declaration_snapshot` | json: `credit`, `ai_declaration`, `ai_declaration_source`, `locked`, `provenance` zum Zeitpunkt der Bestätigung |
| `file_sha256` | Hash der Originaldatei |
| `ip_hash` | gehashte IP (kein Klartext) |
| `user_agent` | gekürzt |
Regeln: Insert-only, keine Updates, keine Löschung außerhalb der Löschfrist. Bei erneuter Bestätigung (z. B. nach Bildtausch) neuer Datensatz.
### 2.3 Struktur `provenance` (json)
```json
{
"c2pa": {
"present": true,
"signature_valid": true,
"issuer": "OpenAI OpCo, LLC",
"claim_generator": "OpenAI Media Service API",
"signed_at": "2026-09-09T10:12:00Z",
"digital_source_type": "trainedAlgorithmicMedia",
"actions": ["c2pa.created"]
},
"iptc": {
"digital_source_type": null,
"creator": null,
"credit": null,
"copyright_notice": null
},
"derived": {
"ai_declaration": "ai_generated",
"locked": true,
"credit_suggestion": null
}
}
```
---
## 3. Upload-Prüfung (Herkunftsdaten)
### 3.1 Ablauf
1. Datei landet über Livewire im Temp-Verzeichnis.
2. Synchron (Timeout 10 s) laufen `c2patool` und `exiftool` gegen die Datei. Beides ist in unter einer Sekunde durch.
3. Ergebnis wird normalisiert (2.3) und an das Formular zurückgegeben.
4. Erst bei Speichern: Original auf private Disk, Derivate erzeugen, Datensatz anlegen.
5. Bei Toolfehler oder Timeout: Formular im Zustand „kein Treffer" mit internem Vermerk `provenance_check_failed`. Der Upload wird nicht blockiert.
### 3.2 Werkzeuge
| Tool | Zweck | Installation |
|---|---|---|
| `exiftool` | IPTC/XMP/EXIF lesen und schreiben | `apt install libimage-exiftool-perl` |
| `c2patool` | C2PA-Manifest lesen und Signatur prüfen | Release-Binary von GitHub (contentauth/c2patool), nach `/usr/local/bin` |
Aufruf aus Laravel über `Symfony\Component\Process\Process`, Ausgabe als JSON (`c2patool --detailed file.jpg`, `exiftool -json -struct file.jpg`).
### 3.3 Zuordnungsregeln
| Quelle | Wert | Ergebnis `ai_declaration` | Sperre |
|---|---|---|---|
| C2PA, Signatur gültig | `digitalSourceType` = `trainedAlgorithmicMedia` | `ai_generated` | ja |
| C2PA, Signatur gültig | `compositeWithTrainedAlgorithmicMedia` | `ai_edited` | ja |
| C2PA, Signatur gültig | `algorithmicallyEnhanced` | `ai_edited` | ja (siehe offener Punkt O3) |
| C2PA, Signatur gültig | `digitalCapture`, `negativeFilm`, `positiveFilm`, `print` | Vorbelegung `original` | nein |
| C2PA, Signatur **ungültig** oder nicht prüfbar | beliebig | Hinweis im Formular, keine Vorbelegung | nein |
| IPTC/XMP `DigitalSourceType` (ohne C2PA) | `trainedAlgorithmicMedia` | `ai_generated` | ja |
| IPTC/XMP `DigitalSourceType` (ohne C2PA) | `compositeWithTrainedAlgorithmicMedia` | `ai_edited` | ja |
| IPTC/XMP `DigitalSourceType` (ohne C2PA) | `digitalCapture` u. a. | Vorbelegung `original` | nein |
| keine der obigen | | `not_declared`, User muss wählen | nein |
Bildnachweis-Vorbelegung (immer editierbar): Reihenfolge `IPTC Credit` → `XMP dc:creator` → `IPTC By-line` → `EXIF Artist` → `CopyrightNotice`. Kein Treffer → Vorbelegung mit dem Firmennamen des Firmenprofils.
Nicht ausgewertet (zu schwach, zu viele Fehltreffer): `CreatorTool`-Strings, Dateinamen, Bildinhalt.
### 3.4 Was mit den Derivaten passiert
Skalierung und WebP-Konvertierung entfernen C2PA-Manifest und die meisten Metadaten. Deshalb:
- Ergebnis der Prüfung liegt in der Datenbank (2.3), nicht im Bild.
- In das öffentliche Derivat werden per `exiftool` zurückgeschrieben: `XMP-dc:Rights` und `IPTC:CopyrightNotice` (= `credit`), `XMP-iptcExt:DigitalSourceType` (bei `ai_generated` / `ai_edited`). Damit tragen Google Bildersuche, LinkedIn und andere Plattformen den Nachweis und die Kennzeichnung weiter, statt dass das Portal sie entfernt. **BALD** (Phase 2).
- EXIF-GPS und Geräteseriennummern werden im Derivat entfernt (Datenminimierung). Das Original behält alles, ist aber privat.
---
## 4. Formular v2
Drei Blöcke statt vier. Feld-Reihenfolge und Texte je nach Zustand.
### 4.1 Zustand A: Herkunftsdaten erkannt und gesperrt
```
1 BILD
[Datei] [Vorschau]
Herkunftsdaten erkannt
Diese Datei enthält Content Credentials.
Erstellt mit: OpenAI Media Service API
Ausgestellt von: OpenAI OpCo, LLC am 9. September 2026
Das Bild wird öffentlich als KI-generiert gekennzeichnet.
Diese Kennzeichnung ist fest hinterlegt und kann nicht geändert werden.
Wenn die Herkunftsdaten nicht zutreffen, melde dich bei uns: [Kontakt]
2 BILDNACHWEIS Pflicht
[Musterfirma GmbH ] ← vorbelegt, editierbar
So erscheint der Nachweis unter dem Bild. Bei Stock- oder Agenturbildern
den Nachweis so eintragen, wie die Lizenz ihn verlangt.
Lizenz oder Quelle (optional) [https://...]
> Optionale Angaben Alt-Text, interne Hinweise
3 BESTÄTIGUNG
[○ ] Ich bestätige, dass ich zur Veröffentlichung dieses Bildes
berechtigt bin und meine Angaben zutreffen. Details ▾
```
„Details ▾" klappt den vollständigen Bestätigungstext (5.1) auf. Der Toggle ist erst aktiv, wenn `credit` gefüllt ist.
### 4.2 Zustand B: keine Herkunftsdaten
```
1 BILD
[Datei] [Vorschau]
Keine Herkunftsdaten in der Datei gefunden. Bitte mach die Angaben selbst.
2 BILDNACHWEIS Pflicht
[Musterfirma GmbH ] ← Firmenname vorbelegt
Lizenz oder Quelle (optional) [https://...]
KI-KENNZEICHNUNG Pflicht
( ) Originalfoto, nicht mit KI erzeugt oder bearbeitet
( ) KI-generiert
( ) KI-bearbeitet (z. B. generative Füllung, KI-Retusche)
KI-generierte oder KI-bearbeitete Bilder erhalten öffentlich einen
neutralen Hinweis. Übliche Bearbeitungen wie Zuschnitt, Farbkorrektur
oder Schärfung gelten nicht als KI-Bearbeitung.
> Optionale Angaben
3 BESTÄTIGUNG
[○ ] Ich bestätige ... Details ▾
```
### 4.3 Zustand C: Vorbelegung ohne Sperre (z. B. C2PA `digitalCapture`)
Wie B, aber Radio „Originalfoto" vorausgewählt mit dem Hinweis „Vorbelegt aus den Herkunftsdaten der Datei (Aufnahme mit [Gerät/App]). Du kannst die Auswahl ändern."
### 4.4 Validierung
| Regel | Fehlermeldung |
|---|---|
| `credit` leer | „Bitte gib an, wer das Bild erstellt hat oder die Rechte hält." |
| `ai_declaration` = `not_declared` bei Zustand B | „Bitte wähle aus, ob das Bild mit KI erstellt oder bearbeitet wurde." |
| Toggle nicht aktiv | „Die Bestätigung ist erforderlich, um das Bild zu veröffentlichen." |
| gesperrter Wert wird per Request manipuliert | serverseitig verwerfen, Metadatenwert bleibt, Log-Eintrag `lock_bypass_attempt` |
Serverseitig gilt immer: effektiver Wert = max(Metadaten, Userangabe) in der Reihenfolge `original` < `ai_edited` < `ai_generated`.
---
## 5. Texte
Alle Texte gehören ins Single-Source-System (`legal_document_sections`) mit eigener Versionsnummer, damit `confirmation_text_version` referenzierbar ist. Entwürfe, vor Livegang durch den Anwalt prüfen lassen.
### 5.1 Bestätigungstext v2 (Upload, vollständig)
> Ich bestätige, dass ich zur Veröffentlichung dieses Bildes auf presseecho.de und businessportal24.com berechtigt bin. Das umfasst die Rechte des Urhebers, die Rechte abgebildeter Personen sowie Marken- und sonstige Rechte Dritter. Mein Bildnachweis und meine Angabe zur KI-Kennzeichnung sind zutreffend. Ich weiß, dass ich für fehlerhafte oder unvollständige Angaben verantwortlich bin und den Betreiber von Ansprüchen Dritter freistelle (Ziffer [X] der AGB).
Kurzform am Toggle: „Ich bestätige, dass ich zur Veröffentlichung dieses Bildes berechtigt bin und meine Angaben zutreffen."
### 5.2 AGB-Klausel (Entwurf, neue Ziffer „Bilder und Medieninhalte")
> **X.1 Rechte.** Der Kunde reicht nur Bilder ein, für die er über die zur Veröffentlichung auf den Portalen erforderlichen Rechte verfügt. Dazu gehören das Recht zur öffentlichen Zugänglichmachung, zur technisch erforderlichen Bearbeitung (insbesondere Skalierung, Zuschnitt auf Vorschauformate, Formatkonvertierung) und zur Weitergabe im Rahmen der Verbreitung der Pressemitteilung über Feeds und Partner.
>
> **X.2 Rechte Dritter.** Der Kunde stellt sicher, dass abgebildete Personen in die Veröffentlichung eingewilligt haben oder eine gesetzliche Ausnahme greift und dass keine Marken-, Eigentums- oder sonstigen Rechte Dritter verletzt werden.
>
> **X.3 Bildnachweis.** Der Kunde gibt zu jedem Bild einen Bildnachweis an. Der Betreiber gibt diesen Nachweis am Bild aus. Der Kunde ist dafür verantwortlich, dass der Nachweis den Anforderungen der jeweiligen Lizenz entspricht.
>
> **X.4 KI-Kennzeichnung.** Der Kunde kennzeichnet Bilder, die mit KI erzeugt oder bearbeitet wurden. Der Betreiber liest in der Datei enthaltene Herkunftsdaten (etwa Content Credentials) aus und kennzeichnet das Bild entsprechend. Eine aus solchen Herkunftsdaten abgeleitete Kennzeichnung kann der Kunde nicht abschwächen. Eigene Pflichten des Kunden aus der Verordnung (EU) 2024/1689 bleiben unberührt.
>
> **X.5 Freistellung.** Der Kunde stellt den Betreiber von allen Ansprüchen Dritter frei, die auf einer Verletzung der Pflichten nach X.1 bis X.4 beruhen, einschließlich der angemessenen Kosten der Rechtsverfolgung und Rechtsverteidigung. Der Betreiber informiert den Kunden unverzüglich über geltend gemachte Ansprüche und stimmt sich mit ihm über die Verteidigung ab.
>
> **X.6 Entfernung.** Der Betreiber darf Bilder bei begründeten Hinweisen auf eine Rechtsverletzung ohne vorherige Ankündigung entfernen. Das Meldeverfahren des Betreibers gilt entsprechend. Eine Entfernung nach diesem Absatz begründet keinen Anspruch des Kunden auf Erstattung oder Gutschrift.
>
> **X.7 Beweissicherung.** Der Betreiber speichert die eingereichte Originaldatei sowie die Angaben und die Bestätigung des Kunden für die Dauer von [Frist] nach Ende der Veröffentlichung.
### 5.3 Ergänzung Datenschutzerklärung
Neuer Absatz unter Verarbeitung bei Veröffentlichung: Speicherung der Originaldatei inklusive enthaltener Metadaten, der Bestätigungsdaten (Zeitpunkt, gehashte IP, Textversion) für [Frist]; Rechtsgrundlage Art. 6 Abs. 1 lit. f (Nachweis- und Verteidigungsinteresse) und lit. c, soweit gesetzliche Aufbewahrung. Hinweis, dass öffentliche Bildderivate von Standort- und Gerätedaten bereinigt werden.
### 5.4 Ergänzung Veröffentlichungsrichtlinien
Kurzer Abschnitt „Bilder": Bildnachweis ist Pflicht, KI-Bilder werden gekennzeichnet, Herkunftsdaten werden ausgelesen, Bilder ohne Rechte werden entfernt. Kein Juristendeutsch, drei bis vier Sätze.
### 5.5 Texte im öffentlichen Detailfenster (Overlay)
Titel: **Bildnachweis**
| Zeile | Text | Bedingung |
|---|---|---|
| Nachweis | Bild: [credit] | immer |
| Lizenz | Lizenz / Quelle: [Link] | wenn `license_url` |
| KI-Hinweis | Dieses Bild wurde mit KI erstellt. | `ai_generated` |
| KI-Hinweis | Dieses Bild wurde mit KI bearbeitet. | `ai_edited` |
| Herkunft | Herkunftsnachweis in der Datei: erstellt mit [claim_generator], ausgestellt von [issuer] am [Datum]. Vom Portal beim Upload ausgelesen. | `provenance.c2pa.present && signature_valid` |
| Verantwortlich | Verantwortlich für Bild und Angaben: [Firma] | immer |
| Bestand | Bildnachweis vom Einreicher nicht hinterlegt. | `credit` leer (Bestand) |
| Aktion | Bild melden → [Meldeverfahren] | immer |
Sichtbare Zeile unter dem Bild (ohne Klick): „Bild: [credit]" plus Zusatz „· KI-generiert" oder „· KI-bearbeitet".
Bewusst weggelassen: Wörter wie „Deepfake", „manipuliert", „Warnung". Der Hinweis ist neutral, keine Wertung.
---
## 6. Öffentliche Ausgabe
### 6.1 Overlay-Element
- Position: unten links im Bild, rundes Icon (≈ 28 px), halbtransparenter Hintergrund, Symbol „i" oder ein eigenes Zeichen. Kein „cr"-Logo verwenden, das ist die geschützte Marke der C2PA-Initiative und nur für konforme Anzeigen gedacht.
- Klick / Tastatur (Enter, Escape) öffnet ein Popover mit den Zeilen aus 5.5.
- Alpine.js, keine externe Bibliothek. `aria-haspopup`, `aria-expanded`, Fokusfalle im Popover.
- Auf Mobil: Popover wird zum Bottom-Sheet.
### 6.2 Sichtbare Nachweiszeile
Immer unter dem Bild, auch ohne Klick. Grund: Die Urhebernennung nach § 13 UrhG muss „in üblicher Weise" erfolgen. Ein Nachweis, der nur hinter einem Klick liegt, ist angreifbar. Die Zeile ist die eigentliche Erfüllung, das Overlay ist die Erklärung.
### 6.3 Weitere Ausgabekanäle
| Kanal | Umsetzung | Phase |
|---|---|---|
| Artikelseite | Zeile + Overlay | 1 |
| Listen / Teaser | kein Nachweis (Vorschau, Link zur Artikelseite) | 1 |
| RSS / Feeds | `<media:content>` mit `<media:credit>` und `<media:description>` inkl. KI-Zusatz | 2 (BALD) |
| Structured Data | `ImageObject` mit `creditText`, `copyrightNotice`, `creator`, optional `license`; bei KI zusätzlich `digitalSourceType` | 2 (BALD) |
| Derivat-Metadaten | Rückschreiben per exiftool (3.4) | 2 (BALD) |
| OpenGraph-Bild | keine Änderung | |
---
## 7. API
### 7.1 Request-Felder pro Bild
```json
{
"images": [
{
"file": "<base64 oder URL>",
"credit": "Musterfirma GmbH",
"license_url": null,
"ai_declaration": "original",
"alt_text": "…",
"rights_confirmed": true,
"rights_confirmation_text_version": "img-confirm-v2"
}
]
}
```
| Feld | Pflicht | Regel |
|---|---|---|
| `credit` | ja, wenn Bild vorhanden | max. 255 Zeichen |
| `ai_declaration` | ja, wenn Bild vorhanden | `original`, `ai_generated`, `ai_edited` |
| `rights_confirmed` | ja, muss `true` sein | |
| `rights_confirmation_text_version` | ja | muss eine aktive Version sein, sonst 422 mit Hinweis auf die aktuelle |
| `license_url`, `alt_text` | nein | |
### 7.2 Serverseitige Verarbeitung
1. Dieselbe Metadatenprüfung wie beim Upload (3.).
2. Effektive Kennzeichnung = max(Metadaten, `ai_declaration`). Widerspricht die API-Angabe den Metadaten (API sagt `original`, Datei sagt `ai_generated`), wird der Metadatenwert gesetzt und im Response gemeldet. Kein Abbruch.
3. `image_rights_confirmations` mit `actor_type = api_key`.
4. Fehlt `credit` oder `rights_confirmed`: Bild wird verworfen, PM wird ohne Bild weiterverarbeitet, Response enthält Warnung. Die PM wird nicht blockiert (siehe O5).
### 7.3 Response
```json
{
"images": [
{
"id": "…",
"credit": "Musterfirma GmbH",
"ai_declaration_effective": "ai_generated",
"ai_declaration_source": "metadata",
"provenance_detected": true,
"warnings": ["ai_declaration überschrieben: Datei enthält Content Credentials (OpenAI OpCo, LLC)."]
}
]
}
```
### 7.4 API-Dokumentation (Textbaustein)
> Bilder sind optional. Wird ein Bild übermittelt, sind `credit`, `ai_declaration` und `rights_confirmed: true` Pflicht. Das Portal liest Herkunftsdaten in der Datei aus (C2PA, IPTC). Enthält die Datei eine KI-Kennzeichnung, gilt diese unabhängig von der übermittelten `ai_declaration`; eine Abschwächung ist nicht möglich. Bilder ohne die Pflichtfelder werden verworfen, die Pressemitteilung wird ohne Bild verarbeitet. Mit `rights_confirmed: true` bestätigt der API-Nutzer für jedes Bild die Rechteerklärung in der jeweils angegebenen Version. Die Rechteerklärung ist Bestandteil der API-Nutzungsbedingungen.
### 7.5 Vertragliche Seite
Die API-Nutzungsbedingungen (bzw. der Partnervertrag) müssen die Ziffer aus 5.2 enthalten oder darauf verweisen. Für connektar gilt das auch ohne Bilder, weil die Lücke sonst mit dem ersten übermittelten Bild aufgeht. Das ist Teil der ohnehin ausstehenden Syndikationsvereinbarung.
---
## 8. Beweissicherung und Aufbewahrung
| Was | Wo | Frist |
|---|---|---|
| Originaldatei | private Disk, nicht öffentlich erreichbar, Pfad in `original_path` | [Frist] nach Depublizierung, Vorschlag 3 Jahre (Verjährung § 102 UrhG / § 195 BGB), Anwalt bestätigt |
| `image_rights_confirmations` | Datenbank, insert-only | wie Original |
| `provenance` json | Datenbank | wie Datensatz |
| Lock-Bypass-Versuche | Audit-Log ohne Klartext-Nutzdaten | 12 Monate |
Löschung des Originals erfolgt per Scheduler nach Frist, mit Log-Eintrag. Bei laufender Auseinandersetzung (Flag `legal_hold` am Datensatz) wird nicht gelöscht.
---
## 9. Umsetzungsreihenfolge
| Phase | Inhalt | Abhängigkeit |
|---|---|---|
| **1a** | Migration: neue Spalten, Tabelle `image_rights_confirmations`, Bestand auf `legacy` setzen | keine |
| **1b** | `exiftool` und `c2patool` auf dem Server, Service `ImageProvenanceInspector` mit Zuordnungsregeln (3.3) und Tests gegen Beispieldateien (OpenAI, Adobe Firefly, Google, Kamera-JPEG, Screenshot) | 1a |
| **1c** | Formular v2 (Zustände A/B/C), Validierung, Sperrlogik serverseitig, Bestätigungs-Insert | 1a, 1b |
| **1d** | Original-Ablage auf private Disk, Hash, Derivat-Pipeline unverändert | 1a |
| **1e** | Öffentliche Ausgabe: Nachweiszeile + Overlay auf Artikelseiten, Bestandsfallback | 1a |
| **1f** | Texte ins Single-Source-System (5.1, 5.4, 5.5), Versionen anlegen | 1c |
| **1g** | AGB-Entwurf (5.2) und DSE-Ergänzung (5.3) an Anwalt | unabhängig, blockiert Livegang von 1c |
| **2a BALD** | API-Felder, Verarbeitung, Response, Doku (7.) | 1b, 1c |
| **2b BALD** | Metadaten-Rückschreiben ins Derivat, Structured Data, Feeds (3.4, 6.3) | 1e |
| **2c BALD** | Löschscheduler mit `legal_hold` (8.) | 1d, Anwaltsfrist |
Livegang von Phase 1 erst, wenn 1g (AGB-Klausel) geprüft ist. Bis dahin kann das Formular hinter einem Feature-Flag laufen.
---
## 10. Offene Punkte nach Dringlichkeit
| Nr. | Punkt | Wer | Dringlichkeit |
|---|---|---|---|
| O1 | Aufbewahrungsfrist für Original und Bestätigung festlegen | Kevin + Anwalt | hoch, blockiert 1g und 5.3 |
| O2 | Bestandsbilder ohne `credit`: Fallbacktext im Overlay wie in 5.5 („nicht hinterlegt") oder Firmenname des Einreichers als Nachweis? Firmenname ist eine Behauptung, die falsch sein kann. Empfehlung: „nicht hinterlegt" + Verantwortlich-Zeile. | Kevin | hoch, blockiert 1e |
| O3 | `algorithmicallyEnhanced` (KI-Upscaling, KI-Entrauschen): als `ai_edited` sperren oder nur vorbelegen? Sperren ist konservativ, trifft aber keine üblichen Kamerafotos, weil Kameras den Wert nicht schreiben. Empfehlung: sperren. | Kevin | mittel |
| O4 | Widerspruchsweg bei gesperrter Kennzeichnung: nur Kontaktlink (Empfehlung, kein Betreuungsaufwand im Regelfall) oder Begründungsfeld mit Admin-Freigabe? | Kevin | mittel |
| O5 | API: Bild ohne Pflichtfelder verwerfen und PM durchlassen (Empfehlung) oder gesamte PM mit 422 ablehnen? | Kevin | mittel, vor 2a |
| O6 | Textstil im Formular: Du oder Sie? Das Dokument nutzt Du für Formular und Overlay, Sie für AGB. An bestehende Portalkonvention angleichen. | Kevin | niedrig |
| O7 | Eigenes Overlay-Icon gestalten (kein „cr"-Logo) | Kevin | niedrig |
---
## 11. Anwaltsfragen (Ergänzung zur Liste im Handover-Dokument)
5. Macht der Freigabelauf (inkl. KI-Scoring) die Bilder zu eigenen Inhalten im Sinne der marions-kochbuch-Rechtsprechung (BGH I ZR 166/07)? Reicht die Verantwortlich-Zeile am Bild und die neutrale Kommunikation der Freigabe als „formale Prüfung", um im Hoster-Status zu bleiben?
6. Hält die Freistellungsklausel X.5 der AGB-Kontrolle nach § 307 BGB im B2B-Verkehr stand, insbesondere ohne Verschuldensbezug und mit Rechtsverteidigungskosten?
7. Ist die gesperrte, aus Metadaten abgeleitete KI-Kennzeichnung (X.4) rechtlich unproblematisch, auch wenn die Herkunftsdaten im Einzelfall falsch sind und der Kunde das nicht ändern kann?
8. Angemessene Aufbewahrungsfrist für Originaldatei und Bestätigung (O1). Rechtsgrundlage in der Datenschutzerklärung.
9. Genügt die Nachweiszeile unter dem Bild der Nennung „in üblicher Weise" nach § 13 UrhG, oder muss der Nachweis ins Bild bzw. direkt daneben?
10. Übertragung der Rechteerklärung auf API-Nutzer: reicht `rights_confirmed: true` pro Request in Verbindung mit den API-Nutzungsbedingungen, oder braucht es eine einmalige schriftliche Erklärung je Partner?
---
## 12. Revisit-Bedingungen
- connektar oder ein anderer API-Partner beginnt, Bilder zu liefern → Phase 2a wird sofort fällig.
- Die Kommission veröffentlicht die finalen Leitlinien zu Art. 50 KI-VO → Zuordnungsregeln (3.3) und Overlay-Texte (5.5) gegenprüfen.
- Erste Abmahnung wegen eines Bildes → Regressweg real testen, Lücken dokumentieren.
- Mehr als eine Handvoll Widersprüche gegen gesperrte Kennzeichnungen pro Monat → O4 neu entscheiden.
- Anteil KI-gekennzeichneter Bilder wird relevant → prüfen, ob der neutrale Hinweis in Listen/Teasern ergänzt wird.
---
## 13. Anti-Zombie-Check
| Kriterium | Ergebnis |
|---|---|
| Versteckte Kosten | keine. Bilder bleiben kostenlos, Kennzeichnung kostet nichts |
| Automatik gegen den Nutzer | Sperre ist erklärt, wirkt nur gegen Abschwächung, Kontaktweg steht daneben |
| Transparenz | Herkunftsdaten werden dem User im Formular genau so gezeigt wie später öffentlich |
| Menschlicher Kontakt | Widerspruch gegen Sperre läuft über direkten Kontakt |
| Betreuungsaufwand | Regelfall läuft ohne Admin. Einziger manueller Fall: Widerspruch gegen Sperre |
| Formularaufwand | von 6 Pflichtangaben + Toggle auf 1 bis 2 Pflichtangaben + Toggle |

View file

@ -0,0 +1,316 @@
# Feedback / Audit: Bildnachweis, KI-Kennzeichnung und Rechtebestätigung
**Stand:** 15.09.2026
**Grundlage:** [Umsetzungsvorschlag vom 15.09.2026](bildkennzeichnung-2026-09-15.md)
**Codebasis:** Arbeitsverzeichnis `/var/www/html`, HEAD `d60f31d78e33033207896cd8ea070a54f44ca54d`, einschließlich des beim Audit vorhandenen Arbeitsstands.
**Prüfumfang:** Statischer Codeabgleich, lesende Prüfung der relevanten Datenbankstrukturen und bestehender Tests sowie Gegenprüfung technischer Standards anhand offizieller Quellen. Keine Implementierung.
## 1. Gesamturteil
**Die Richtung ist sinnvoll. Den Entwurf würde ich vor der Umsetzung an mehreren Stellen korrigieren.** Weniger Formularfelder, ein sichtbarer Bildnachweis, eine eigenständige KI-Kennzeichnung und eine nachvollziehbare Zustimmung passen zur Anwendung. Der bestehende Code bietet dafür bereits wichtige Grundlagen.
Die größten Probleme liegen in der Bestandsmigration, der Vertrauensbewertung von Metadaten und den Übergängen zwischen Upload, Veröffentlichung und späterer Löschung. Einige als vorhanden vorausgesetzte Bausteine fehlen; andere sind bereits anders umgesetzt.
**Die fünf wichtigsten Ergebnisse:**
1. **Bestehende KI-Deklarationen unbedingt erhalten.** Der vorgeschlagene pauschale Wechsel auf `not_declared` würde vorhandene Angaben überschreiben und öffentliche KI-Hinweise entfernen.
2. **`algorithmicallyEnhanced` ist kein belastbarer KI-Nachweis.** Der IPTC-Wert umfasst ausdrücklich auch gewöhnliches Schärfen und Entrauschen.
3. **Eine gültige C2PA-Signatur und frei editierbare IPTC-Angaben sind unterschiedliche Vertrauensstufen.** Die aktuelle Sperrregel vermischt sie.
4. **Die Bild-API existiert bereits.** Sie muss zum ersten produktiven Einsatz der neuen Regeln berücksichtigt werden; das geplante JSON-Beispiel entspricht außerdem nicht dem heutigen Upload-Vertrag.
5. **Originalablage, Zustimmung und Löschregeln müssen gemeinsam geplant werden.** Eine private Disk allein schützt das Original nicht vor öffentlicher Auslieferung über die vorhandene Medienroute.
Prioritäten in diesem Audit: **P1** = vor Umsetzung beziehungsweise Livegang verbindlich klären; **P2** = in die Umsetzungsspezifikation aufnehmen. Es wird damit kein bereits eingetretener Produktionsschaden behauptet.
## 2. Was bereits vorhanden ist
| Thema | Bestehender Code | Konsequenz für den Vorschlag |
|---|---|---|
| Öffentlich sichtbarer Bildnachweis | `copyright`, mit Fallback auf `author`; Ausgabe unter dem Artikelbild | Bestehende Ausgabe erweitern, Daten nicht unverbunden duplizieren |
| KI-Kennzeichnung pro Bild | `AiImageDeclaration` mit `original`, `ai_generated`, `ai_edited`; öffentlicher Badge | Kein vollständiger Neubau erforderlich |
| Rechteangaben beim Upload | Gemeinsamer Volt-Bildmanager für Kunden und Admins; `rights_confirmed_at` und Lizenzfelder | Vereinfachung ist möglich; bestehende Nachweise erhalten |
| KI-Snapshot beim Einreichen | `PressReleaseService::logAiDeclaration()` und `press_release_ai_declarations` | Neue Bildrechtebestätigung mit diesem Ablauf abstimmen |
| Private Bildspeicherung | Neue Pressebilder werden standardmäßig auf `local` gespeichert | Öffentliches Derivat und privates Beweisoriginal brauchen getrennte Zugriffspfade |
| Bild-API | Eigene v1- und Legacy-Bilduploads | Neue Regeln müssen sämtliche aktiven Einstiegspunkte erfassen |
| Rechtstextversionen | `legal_documents` mit Typ, Version, Gültigkeitsdatum und Notiz | Keine bereits vorhandene Tabelle `legal_document_sections`; Textarchivierung fehlt als eigener Baustein |
| Weitere Darstellung | Kundenansicht, Adminansicht, öffentliche Vorschau und Beleg-PDF | Zusätzlich zur Artikelseite in den Änderungsumfang aufnehmen |
Codebelege: [Bildmodell](../../app/Models/PressReleaseImage.php), [KI-Enum](../../app/Enums/AiImageDeclaration.php), [Bildmanager](../../resources/views/livewire/components/press-release-images-manager.blade.php), [Artikelseite](../../resources/views/web/release-detail.blade.php), [Veröffentlichungsservice](../../app/Services/PressRelease/PressReleaseService.php), [Rechtstextmodell](../../app/Models/LegalDocument.php), [PDF-Ausgabe](../../app/Services/PressRelease/ProofPdfRenderer.php).
## 3. Priorisierte Befunde
### F01 · P1 · Die Bestandsmigration widerspricht dem Bestandsschutz
**Entwurf:** Abschnitt 2.1 setzt alle bestehenden Bilder auf `ai_declaration = not_declared`. E1 und Abschnitt 0 verlangen gleichzeitig, den Bestand unverändert zu lassen.
**Code:** Es gibt bereits ausdrücklich deklarierte KI-Bilder. Die Migration vom 08.07.2026 hat zusätzlich Bilder mit `is_ai_generated = true` auf `ai_generated` gesetzt. Die öffentliche Artikelseite erzeugt ihren KI-Hinweis aus `ai_declaration`.
**Auswirkung:** Bereits abgegebene Angaben gehen verloren. Bei bisher als `ai_edited` oder `ai_generated` gekennzeichneten Bildern kann der öffentliche Hinweis verschwinden. Ohne Erweiterung des PHP-Enums führt ein gespeichertes `not_declared` beim Enum-Cast außerdem zu einem Fehler. Nach einer Erweiterung würde die heutige Einreichungsprüfung den Wert dennoch nicht erkennen: Sie prüft nur auf `NULL`.
**Empfehlung:**
- Vorhandene Werte in `ai_declaration`, `copyright`, `author` und den bisherigen Rechtefeldern erhalten.
- Nur die fehlende historische Herkunft als `legacy` beziehungsweise `unknown` ergänzen; nicht die Deklaration selbst ersetzen.
- Für fehlende Deklarationen zunächst das vorhandene `NULL` weiterverwenden. Ein vierter Enum-Wert ist nur sinnvoll, wenn alle Leser, Validatoren und Veröffentlichungsprüfungen angepasst werden.
- Bestandsschutz ausdrücklich für alte Entwürfe, abgelehnte PMs und erneutes Einreichen definieren. Ein neues Ersatzbild unterliegt immer den neuen Regeln.
- Bestandsschutz über eine serverseitige Regelversion oder einen eindeutig definierten Stichtag feststellen, nicht über ein frei setzbares Request-Feld.
**Belege:** [Backfill](../../database/migrations/2026_07_08_172113_backfill_ai_declaration_on_press_release_images.php), [Enum](../../app/Enums/AiImageDeclaration.php), [Artikelseite, Zeilen 47–50](../../resources/views/web/release-detail.blade.php#L47), [Vollständigkeitsprüfung, ab Zeile 572](../../app/Services/PressRelease/PressReleaseService.php#L572).
### F02 · P1 · C2PA-Verifikation braucht mehr als `signature_valid`
**Entwurf:** Eine gültige Signatur löst die Sperre aus. Unsigned IPTC/XMP löst dieselbe Sperre aus; das Datenmodell beschreibt sie zugleich als Ergebnis „verifizierter Metadaten“.
Eine kryptografisch gültige Signatur allein beantwortet weder, ob dem Unterzeichner vertraut wird, noch ob alle relevanten Bindungen an die hochgeladene Datei gültig sind. Frei bearbeitbare IPTC/XMP-Felder besitzen diese Signaturprüfung überhaupt nicht. Die C2PA-Spezifikation unterscheidet die Prüfung von Manifest, Aussagen, Signatur, Dateibindung und Vertrauenskette. [Quelle: C2PA-Spezifikation, Validierung](https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html).
**Empfehlung:** Ein normalisiertes Ergebnis mit getrennten Zuständen vorsehen, etwa:
- `absent`: keine unterstützten Herkunftsdaten gefunden;
- `validated_trusted`: relevante Prüfungen erfolgreich, Unterzeichner nach definierter Vertrauensliste akzeptiert;
- `validated_untrusted`: technisch geprüft, Vertrauensanker nicht akzeptiert;
- `invalid`: Prüfung fehlgeschlagen;
- `unsupported` oder `error`: nicht unterstützt beziehungsweise technische Prüfung nicht abgeschlossen;
- ergänzend `unsigned_metadata` für ungesicherte IPTC/XMP-Angaben.
Eine automatische Mindestkennzeichnung würde ich auf belastbare, vertrauenswürdig validierte Aussagen begrenzen. Bei ungesicherten Angaben würde ich vorbelegen und eine begründete Korrektur protokollieren. Soll auch dort geschäftlich zwingend gesperrt werden, muss das ausdrücklich als Portalregel formuliert werden; die Anzeige darf sie nicht als verifizierte Content Credentials ausgeben.
Zusätzlich fehlen Regeln für widersprüchliche C2PA-/XMP-Angaben, mehrere Manifeste, referenzierte Ausgangsbilder und KI-Bestandteile eines zusammengesetzten Bildes. Der `digitalSourceType` eines Bestandteils darf nicht blind als Typ des gesamten Endbilds übernommen werden. Diese Fälle mit echten Dateien und festgelegten Erwartungen prüfen. Prüfergebnis und verwendete Vertrauens-/Regelversion speichern.
### F03 · P1 · `algorithmicallyEnhanced` nicht automatisch als KI-Bearbeitung sperren
**Entwurf:** Abschnitt 3.3 und O3 schlagen `ai_edited` mit Sperre vor.
**Gegenprüfung:** IPTC beschreibt den Wert als algorithmische Änderung oder Korrektur ohne Veränderung des Hauptinhalts, ausdrücklich beispielsweise Schärfung und Rauschreduzierung. Generative KI ist dafür keine Voraussetzung. Für generative Ergänzungen wie Inpainting und Outpainting existiert `compositeWithTrainedAlgorithmicMedia`. [IPTC: algorithmicallyEnhanced](https://cv.iptc.org/newscodes/digitalsourcetype/algorithmicallyEnhanced), [IPTC: compositeWithTrainedAlgorithmicMedia](https://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia).
Die Sperre widerspricht damit dem eigenen Formulartext, der übliche Schärfung ausdrücklich ausnimmt. Auch die Behauptung, Kameras schrieben diesen Wert nicht, ist keine ausreichende Grundlage für die Zuordnung.
**Empfehlung:** Allein daraus keine KI-Kennzeichnung ableiten. Als technischen Bearbeitungshinweis speichern; nur zusätzliche eindeutige KI-Aussagen berücksichtigen. Vollständige IPTC-URIs normalisieren, statt nur auf unqualifizierte Kurzbezeichnungen zu vergleichen.
### F04 · P1 · API-Phase und API-Vertrag passen nicht zum bestehenden System
**Code:** v1 nimmt ein einzelnes Bild im Feld `image` an, separat unter `POST /api/v1/press-releases/{id}/images`. Legacy verwendet `press_release_image[image]`. v1 verlangt bereits eine KI-Deklaration; der Legacy-Controller setzt dagegen fest `original`. Ein Rechtebestätigungsprotokoll gibt es in diesen Bildcontrollern noch nicht.
**Probleme im Entwurf:**
- Das JSON mit `images[]` und „base64 oder URL“ ist ein neuer Vertrag und keine bloße Ergänzung vorhandener Felder.
- „Bild verwerfen, PM weiterverarbeiten“ passt nicht zum separaten Bildupload: Die PM existiert dabei bereits.
- Werden diese Zugänge erst in Phase 2 berücksichtigt, können neue Uploads weiterhin ohne die neuen Nachweise entstehen. Ob ein bestimmter Partner momentan Bilder liefert, ändert nichts an den vorhandenen Endpunkten.
**Empfehlung:** Den bestehenden Multipart-Upload beibehalten und gezielt erweitern. Für einen ungültigen separaten Bildupload ist `422` die klare Antwort; die bestehende PM bleibt erhalten. Eine Sammelverarbeitung mit Teilfehlern nur als ausdrücklich neu spezifizierte Schnittstelle planen. Externe Bild-URLs würden zusätzlich einen abgesicherten Download mit Grenzen für Zieladressen, Weiterleitungen, Zeit und Datenmenge erfordern.
Zum ersten produktiven Einsatz entweder beide Uploadpfade auf den gemeinsamen Ablauf umstellen oder eine klar definierte Übergangsregel durchsetzen. Legacy-Kompatibilität bewusst entscheiden; keine heimliche Umstellung auf `original` und kein unbemerkter Bildverlust. Bestehende Response-Felder kompatibel ergänzen, inklusive effektiver Kennzeichnung und maschinenlesbarer Warncodes.
**Belege:** [v1-Controller](../../app/Http/Controllers/Api/V1/PressReleaseImageController.php), [v1-Request](../../app/Http/Requests/Api/V1/StorePressReleaseImageRequest.php), [Legacy-Controller](../../app/Http/Controllers/Api/Legacy/LegacyPressReleaseImageController.php), [Resource](../../app/Http/Resources/PressReleaseImageResource.php), [API-Routen](../../routes/api.php).
### F05 · P1 · Original und öffentliches Derivat müssen technisch strikt getrennt bleiben
**Code:** `ImageService::storePressReleaseImage()` erzeugt Varianten und löscht anschließend normalerweise die hochgeladene Originaldatei. `path` zeigt dann auf das Cover. Die API nennt diese Darstellung trotzdem `urls.original`. Die Medienroute liefert Bilder veröffentlichter PMs anonym aus, auch wenn deren Disk `local` heißt.
**Folgerung:** Das Beweisoriginal darf weder in `path` noch in der öffentlich verwendeten `variants`-Map landen. Die Bezeichnung „private Disk“ reicht als Schutz nicht aus.
Zusätzlich besteht ein relevanter Fehlerpfad: Wenn keine Cover-Variante entsteht, fällt der Service auf die hochgeladene Quelldatei zurück. Eine unverändert übernommene Pipeline garantiert deshalb nicht „öffentlich immer nur bereinigtes Derivat“.
**Empfehlung:**
- Original vor der bisherigen Transformation in einen gesonderten privaten Bereich kopieren, erfolgreichen Schreibvorgang prüfen und SHA-256 bilden.
- Hash, Herkunftsprüfung und gespeichertes Original müssen dieselben Bytes betreffen.
- Öffentliches Speichern nur bei erfolgreicher Erzeugung des vorgesehenen Derivats; kein Rückfall auf das Original.
- Zugriff auf Beweisoriginale über eine separate, eng autorisierte Funktion. Öffentliche Medienrouten, Vorschau-Signaturen und API-Resources dürfen diesen Pfad nicht zugänglich machen.
- Die bestehende Bedeutung von `urls.original` kompatibel behandeln und dokumentieren; sie darf nicht plötzlich auf das Beweisoriginal umgebogen werden.
**Belege:** [ImageService, ab Zeile 117](../../app/Services/Image/ImageService.php#L117), [Variantenfehlerpfad, ab Zeile 244](../../app/Services/Image/ImageService.php#L244), [Pfadauflösung](../../app/Models/PressReleaseImage.php), [Mediencontroller](../../app/Http/Controllers/Web/PressReleaseImageMediaController.php), [Medienberechtigungen](../../app/Http/Controllers/Web/Concerns/AuthorizesPressReleaseMediaAccess.php), [API-Resource](../../app/Http/Resources/PressReleaseImageResource.php).
### F06 · P1 · Das vorausgesetzte Rechtstextsystem existiert so noch nicht
**Code und Datenbankschema:** Vorhanden ist `legal_documents`. Es speichert Versionsmetadaten, aber keinen archivierten Textinhalt. `legal_document_sections` findet sich im Anwendungscode und abgefragten Schema nicht. Der AGB-Inhalt steht im Blade-Template.
**Auswirkung:** `confirmation_text_version = img-confirm-v2` alleine macht den tatsächlich angezeigten Wortlaut noch nicht dauerhaft rekonstruierbar. Abschnitt 1f ist eine zusätzliche technische Abhängigkeit und muss vor einer gültigen Bestätigung bereitstehen.
**Empfehlung:** Das vorhandene Versionssystem gezielt erweitern oder ein kleines, unveränderliches Archiv der Bestätigungstexte ergänzen. Speichern beziehungsweise unveränderlich referenzieren:
- vollständigen angezeigten Text mit Version, Sprache und aufgelösten Portalbezeichnungen;
- die maßgebliche AGB-Fassung;
- Datei-Hash und bestätigte Angaben einschließlich Lizenz-/Quellenangaben;
- handelnden Benutzer und gegebenenfalls zusätzlich die Token-ID;
- Firma/Vertretungskontext zum Bestätigungszeitpunkt.
Bei API-Nutzung sind Tokeninhaber und konkretes Token hilfreicher als nur `actor_type = api_key`. Bei Admin-Uploads muss erkennbar bleiben, dass der Admin gehandelt hat; das darf nicht als Bestätigung des Kunden protokolliert werden. Eine Firma oder ein Token kann später geändert beziehungsweise gelöscht werden, ohne dass damit der historische Kontext verschwinden darf.
Den Satz „Ohne diese Tabelle ist der Toggle im Streitfall wertlos“ würde ich ersetzen: Entscheidend ist die rekonstruierbare Bestätigung samt Identität und Inhalt, nicht ein bestimmter Tabellenname. Welche Nachweise rechtlich genügen, gehört in die vorgesehene Anwaltsprüfung.
**Belege:** [LegalDocument](../../app/Models/LegalDocument.php), [AGB-Template](../../resources/views/web/agb.blade.php), [bestehendes Bestätigungsmodell](../../app/Models/BookingConfirmation.php), [bestehendes KI-Protokoll](../../app/Models/PressReleaseAiDeclaration.php).
### F07 · P1 · Aufbewahrung und Veröffentlichung sind noch nicht durchgängig abgesichert
**Code:** Kunden können Bilder bei Entwürfen oder abgelehnten PMs ändern; Admins können im gemeinsamen Bildmanager auch andere Status bearbeiten, außer `Archived`. v1 beschränkt Änderungen auf Entwurf/abgelehnt. Der Legacy-Bildupload besitzt diese Statusprüfung nicht. `publish()` prüft die neuen Bildrechte naturgemäß noch nicht; die heutige KI-Vollständigkeitsprüfung erfolgt beim Einreichen.
**Empfehlung:** Für jedes neue Bild muss beim öffentlichen Wirksamwerden eine gültige Bestätigung zur aktuellen Datei und Deklaration vorliegen. Das betrifft auch Admin-Bildtausch nach Veröffentlichung und Legacy-Nachlieferungen. Bestehende PMs dürfen durch die neuen Regeln nicht unbeabsichtigt blockiert werden; diese Ausnahme muss ausdrücklich auf den geschützten Bestand begrenzt sein.
Die Löschung benötigt ebenfalls ein vollständiges Modell:
- `deletePressReleaseImage()` löscht heute Dateien sofort; Bildzeilen werden anschließend weich gelöscht. Der neue Beweisbestand braucht eine davon getrennte Aufbewahrungsentscheidung.
- Die ursprüngliche Bildtabelle hat einen kaskadierenden Fremdschlüssel zur PM. Ein späteres hartes Löschen darf Bestätigungen nicht ungewollt mit entfernen. `PressReleaseMediaPrivatizer` besitzt außerdem einen gezielten `forceDelete()`-Pfad für fehlende Legacy-Dateien; dessen Bestandsscope beibehalten.
- `legal_hold` wird erwähnt, aber nicht modelliert. Zuständigkeit, Setzen, Aufheben und Vorrang vor der Löschung fehlen.
- Für nie veröffentlichte Bilder, entfernte Entwürfe und Bildtausch gibt es kein eindeutiges Datum „nach Depublizierung“. Dafür eigene Friststarter definieren; der bestehende Draft-Purge archiviert lediglich.
Den Löschmechanismus und seine Fristberechnung würde ich zusammen mit der Originalspeicherung planen und zum Livegang betriebsfähig machen. Das Wort „BALD“ ist für bereits gesammelte Beweisdaten keine belastbare Aufbewahrungsregel.
**Belege:** [Bildmanager, ab Zeile 292](../../resources/views/livewire/components/press-release-images-manager.blade.php#L292), [Veröffentlichung, ab Zeile 212](../../app/Services/PressRelease/PressReleaseService.php#L212), [Bildmigration](../../database/migrations/2026_04_23_171001_create_press_release_images_table.php), [Privatisierung](../../app/Services/PressRelease/PressReleaseMediaPrivatizer.php), [Draft-Archivierung](../../app/Console/Commands/PurgeExpiredPressReleaseDrafts.php).
### F08 · P2 · Datenmodell als Erweiterung konkretisieren
Der Entwurf beschreibt einige bestehende Felder als neue Spalten und lässt andere benötigte Felder offen:
| Punkt | Empfehlung |
|---|---|
| `credit` gegenüber vorhandenem `copyright` | Vorerst `copyright` als Speicherfeld für den sichtbaren Nachweis weiterverwenden; `credit` kann ein API-/Formularname sein. Eine echte Umbenennung braucht eine vollständige Migration aller Leser und Schreiber. |
| `ai_declaration`, `license_url` | Bereits vorhanden; keine zweite Spalte anlegen. Derzeit ist `ai_declaration` ein `varchar(20)` mit PHP-Enum-Cast. |
| `credit NOT NULL`, Originalhash, Bestätigungs-FK | Für Bestand ohne diese Daten nullable beziehungsweise anderweitig explizit migrationsfähig; Pflichtregeln für neue Uploads serverseitig durchsetzen. |
| `rights_confirmation_id` und Rückverweis auf Bild | Zirkuläre Referenz: Bild zunächst mit leerem Bestätigungsverweis, dann Bestätigung, dann Verweis setzen; gemeinsam in einer Transaktion. Alternativ aktuelle gültige Bestätigung über eine Beziehung ermitteln. |
| `alt_text` | Noch kein gleichnamiges Bildfeld vorhanden; heute werden `description`/`title` verwendet. Speicherung, Länge und Fallback festlegen. |
| `credit_source` | `legacy`/`unknown` und einen Firmennamenvorschlag abbilden; „woher vorgeschlagen“ und „wer final bestätigt“ sind verschiedene Informationen. |
| `license_url` | DB aktuell 255 Zeichen, Formularvalidierung bis 2048; Länge bei dieser Änderung konsistent festlegen. |
| `is_ai_generated`, `license_type` | Weiterverwendung und Übergang definieren; Kunden-/Adminansicht und PDF lesen diese Felder noch. |
Die sichtbare Nachweiszeile sollte über eine gemeinsame Auflösung für neue Angaben und Bestandsfallbacks verfügen. Historische Details müssen nicht im vereinfachten Formular erscheinen, sollten aber erhalten bleiben.
**Belege:** [Bildmodell](../../app/Models/PressReleaseImage.php), [Lizenzmigration](../../database/migrations/2026_05_29_130849_add_license_fields_to_press_release_images.php), [Bildmanager](../../resources/views/livewire/components/press-release-images-manager.blade.php), [PDF-Bildangaben](../../app/Services/PressRelease/ProofPdfRenderer.php).
### F09 · P2 · Prüfung und Zustimmung an die konkrete Dateiversion binden
Eine Prüfung nach der Dateiauswahl genügt nicht, wenn zwischen Prüfung und Speichern die Datei oder die bestätigten Angaben wechseln. Im heutigen Formular gibt es noch keine Herkunftsprüfung und dementsprechend auch keine Bindung an deren Ergebnis.
**Empfehlung:** Prüfungsergebnis serverseitig an Upload-ID beziehungsweise Hash binden. Bei Dateiwechsel Prüfung, Vorbelegung und Zustimmung zurücksetzen. Eine manuelle Nachweisänderung nicht anschließend durch verspätete Prüfergebnisse überschreiben. Relevante Änderungen nach der Zustimmung erfordern eine neue Zustimmung.
Die endgültigen Werte, der Hash, der Actor und die Textversion werden serverseitig bestimmt. Öffentliche Livewire-Properties sind grundsätzlich Eingaben des Clients; eine ausgegraute Auswahl ist keine Absicherung. [Quelle: Livewire 4, Properties und Sicherheit](https://livewire.laravel.com/docs/4.x/properties).
Dateispeicherung und Datenbanktransaktion müssen als zusammenhängender Vorgang mit Fehlerbereinigung gestaltet werden. Ein DB-Rollback löscht keine gespeicherten Dateien. Gleichzeitige Uploads müssen außerdem die bestehende „ein Titelbild“-Regel zuverlässig einhalten. Ein passender gemeinsamer Upload-Service kann diese Regeln für Volt, v1 und Legacy bündeln; der vorhandene `ImageService` bleibt für die Bildverarbeitung zuständig.
### F10 · P2 · „Kein Treffer“ und „Prüfung fehlgeschlagen“ getrennt anzeigen
**Entwurf:** Bei Timeout wird derselbe Formularzustand wie bei fehlenden Herkunftsdaten verwendet.
Das ist für den Nutzer eine falsche Aussage. Eine bewusst aufwendig präparierte Datei könnte außerdem einen Timeout provozieren und so eine sonst vorgesehene Sperre umgehen.
**Empfehlung:** Bei Fehler „Herkunftsdaten konnten nicht geprüft werden“ anzeigen. Entscheiden, ob nur das Speichern als Entwurf erlaubt ist oder ob eine manuelle Erklärung bei ungeprüfter Herkunft ausreicht. Letzteres kann eine bewusste Produktregel sein, darf aber nicht als vollständig durchgesetzte Metadatensperre beschrieben werden. Technische Fehlerstatus, Wiederholungen und Ausfallquote erfassen.
### F11 · P2 · Die Kategorie „Originalfoto“ ist für Nicht-KI-Bilder zu eng
Das Formular erfasst auch Logos, Diagramme, Screenshots, Illustrationen und klassische Renderings. Ohne KI sind diese nicht zwingend „Originalfotos“. Ebenso beweist ein Hinweis auf eine Digitalaufnahme nicht, dass später keine KI-Bearbeitung stattgefunden hat.
**Empfehlung:** Die erste Option etwa „Ohne generative KI erstellt oder bearbeitet“ nennen und die Definition üblicher Bearbeitung unmittelbar ergänzen. Den internen Wert `original` kann man aus Kompatibilitätsgründen zunächst erhalten.
`max(original, ai_edited, ai_generated)` ist eine Portalregel, keine natürliche Rangfolge des Wahrheitsgehalts. Ein KI-erzeugter Bestandteil in einer Fotomontage macht nicht automatisch das komplette Bild zu einem vollständig KI-generierten Bild. Erst den relevanten Metadatenbefund fachlich zuordnen, danach die vereinbarte Mindestkennzeichnung anwenden. Nutzerangabe, abgeleiteter Befund und effektive Ausgabe getrennt speichern, damit die Entscheidung erklärbar bleibt.
### F12 · P2 · Bildnachweis, Urheber und Lizenz nicht gleichsetzen
Ein vorbefüllter Firmenname kann praktisch sein, ist aber nicht automatisch der richtige Bildnachweis. Dasselbe gilt für eine Agentur-Credit-Line gegenüber dem eigentlichen Urheber oder einem Copyright-Vermerk.
**Empfehlung:**
- Firmennamen nur klar als überprüfbaren Vorschlag anzeigen; vorhandene Nutzertexte nicht ersetzen.
- Neben klassischem IPTC Credit auch dessen XMP-Entsprechung berücksichtigen. Mehrere Creator, Sprachvarianten und zu lange Werte kontrolliert normalisieren; Nachweistexte nicht still abschneiden.
- Die Felder `creator`, `copyrightNotice` und `creditText` beim späteren Export nur mit den jeweils bekannten Informationen befüllen. Einen frei formulierten Credit nicht pauschal in alle drei Felder kopieren.
- `license_url` und `source_url` fachlich getrennt halten, selbst wenn die Oberfläche vereinfacht wird. Ein Link zur Agentur oder Ursprungsseite ist nicht automatisch eine Lizenz.
- Nur zulässige Linkschemata veröffentlichen und alle aus Dateien gewonnenen Texte bei HTML-/JSON-Ausgabe korrekt escapen.
Google unterscheidet diese Angaben ausdrücklich; `license` soll auf eine Beschreibung der tatsächlichen Nutzungsbedingungen verweisen. [Quelle: Google-Bildmetadaten](https://developers.google.com/search/docs/appearance/structured-data/image-license-metadata).
### F13 · P2 · Öffentliche Ausgabe und Metadatenexport genauer abgrenzen
Die sichtbare Zeile plus Detailinformation ist nachvollziehbar. Die heutige Artikelseite besitzt bereits Nachweiszeile und KI-Badge; die Umsetzung sollte diese konsistent weiterentwickeln.
**Ergänzungen zum Umfang:**
- Kundenansicht, Adminansicht, anonyme Vorschau und `ProofPdfRenderer` mitziehen. Historische Belegausgaben müssen zur jeweils dokumentierten Fassung passen.
- Keine internen Notizen, IP-Hashes, privaten Pfade oder ungefilterten Tool-Dumps im Overlay beziehungsweise API-Response ausgeben. Nur ausdrücklich freigegebene Herkunftsfelder anzeigen.
- Korrekturen mit den vorhandenen Web-Caches abstimmen. Öffentliche Mediendateien werden derzeit mit `max-age=86400` ausgeliefert; bei geänderten Derivat-Metadaten Dateiversionen beziehungsweise Cache-Verhalten berücksichtigen.
- Das vorhandene `NewsArticle.image` ist derzeit eine URL-Liste. Eine Erweiterung um `ImageObject` ist möglich. `digitalSourceType` ist eine Schema.org-Eigenschaft; gültiges Schema und tatsächliche Unterstützung durch Suchmaschinen sind getrennt zu prüfen. [Quelle: Schema.org](https://schema.org/digitalSourceType).
**Technische Korrekturen zu Abschnitt 3.4:**
1. Der aktuelle GD-Service behält JPEG/PNG/WebP als Ausgabeformat bei; eine generelle WebP-Konvertierung existiert nicht. Der Export muss die tatsächlich erzeugten Formate unterstützen.
2. Für WebP dokumentiert ExifTool das Schreiben von EXIF, XMP und ICC-Profilen. Klassisches `IPTC:CopyrightNotice` ist deshalb keine formatübergreifende Schreibstrategie. Passende XMP-Felder verwenden und nach dem Schreiben wieder auslesen. [Quelle: ExifTool-Tagdokumentation](https://www.exiftool.org/TagNames.pdf).
3. Ein exportiertes XMP-Feld stellt keine C2PA-Signatur des Derivats her. Die Herkunftsanzeige muss sich ausdrücklich auf die beim Upload geprüfte Originaldatei beziehen.
4. „Damit tragen Google, LinkedIn und andere Plattformen … weiter“ ist zu verbindlich. Metadaten schaffen eine Auswertungsmöglichkeit. Google garantiert deren Anzeige ausdrücklich nicht; für LinkedIn ist im Entwurf kein Nachweis einer solchen Garantie vorhanden. [Quelle: Google-Bildmetadaten, Troubleshooting](https://developers.google.com/search/docs/appearance/structured-data/image-license-metadata#troubleshooting).
### F14 · P2 · Formularzustände, Korrekturweg und Betrieb vervollständigen
**Formular:**
- E4 erlaubt eine Höherstufung, Zustand A behauptet dagegen „kann nicht geändert werden“. Bei Mindestwert `ai_edited` muss die erlaubte Wahl von `ai_generated` weiterhin möglich sein, falls diese Regel bestehen bleibt.
- Zustand A kann laut Zuordnung auch durch unsigned IPTC entstehen. Dann ist „Diese Datei enthält Content Credentials“ falsch. Unterschiedliche Herkunftstexte vorsehen.
- „Ausgestellt von“ nur aus den dafür geeigneten Zertifikatsinformationen ableiten; `claim_generator` ist nicht automatisch Urheber oder verifizierter Produktname. Unbekannte Aussteller und fehlende Zeitangaben brauchen Fallbacks.
- Die Kundenoberfläche verwendet aktuell überwiegend „Sie“. Für diese Änderung dabei bleiben oder einen bewussten portalweiten Wechsel planen.
- Ein Kontaktlink für Widersprüche reicht als Einstieg. Intern braucht es dennoch einen autorisierten, protokollierten Korrekturweg mit Grund, Bearbeiter und altem/neuem Wert. Die ursprüngliche Prüfung bleibt erhalten.
- Für das Overlay vorhandene Flux-Komponenten prüfen. Popover und modaler Dialog benötigen unterschiedliche Fokusführung; Tastaturbedienung, Rückgabe des Fokus und zugängliche Beschriftung ausdrücklich abnehmen.
**Betrieb:**
- „Beides ist in unter einer Sekunde durch“ erst nach Messung behaupten. Ein Timeout ist keine Laufzeitgarantie.
- Toolversionen und Binärquellen festlegen; Installation in der tatsächlichen App-/Worker-Laufzeit reproduzierbar machen. Im Sail-Projekt reicht eine Installation ausschließlich auf dem Host nicht.
- Dateityp, Dateigröße und Bildabmessungen vor kostenintensiver Verarbeitung begrenzen. Aktuell erlaubt der Bildmanager 16 MiB, die API 8 MiB; diesen Unterschied entscheiden oder dokumentieren.
- Prozesse mit Argumentlisten und kontrollierten lokalen Pfaden starten; CPU-/Speicher-/Ausgabegrenzen und Behandlung externer Manifestverweise festlegen.
- Originalspeicherung, Backups, Toolpflege und Fehlerbearbeitung erzeugen Betriebsaufwand. Im Anti-Zombie-Check besser zwischen „keine zusätzlichen Kundengebühren“ und tatsächlichen Betriebskosten unterscheiden.
## 4. Rechtliche Annahmen, die gezielt zurück an den Anwalt sollten
Die vorgesehenen Anwaltsfragen sind sinnvoll. Ergänzen beziehungsweise präzisieren würde ich folgende Punkte; dies ist keine rechtliche Freigabe der Texte:
1. **Nachweisplatzierung und Teaser:** § 13 UrhG enthält nicht wörtlich die im Entwurf in Anführungszeichen gesetzte Vorgabe „in üblicher Weise“. Er betrifft die Anerkennung der Urheberschaft und die Bestimmung der Bezeichnung. Aus ihm folgt keine pauschale Freistellung für Vorschaubilder. „Listen/Teaser ohne Nachweis“ und unveränderte OpenGraph-Bilder deshalb anhand der tatsächlich verwendeten Lizenzen prüfen. [§ 13 UrhG](https://www.gesetze-im-internet.de/urhg/__13.html).
2. **Aufbewahrungsfrist:** „Drei Jahre nach Depublizierung“ lässt sich nicht einfach mit der regelmäßigen Verjährung gleichsetzen. § 199 BGB knüpft deren Beginn grundsätzlich an Jahresende und Kenntnis an; § 102 UrhG verweist zudem auf weitere Vorschriften. Eine konkrete Frist samt Ausnahmen, nie veröffentlichten Uploads und Rechtsstreitfällen festlegen lassen. [§ 199 BGB](https://www.gesetze-im-internet.de/bgb/__199.html), [§ 102 UrhG](https://www.gesetze-im-internet.de/urhg/__102.html).
3. **Entfernung von Rechteinformationen:** Prüfen, ob das Verschieben der Wiederaufnahme von Rechte-Metadaten in Phase 2 angesichts der genutzten Bilder und Lizenzen vertretbar ist. § 95c UrhG enthält ein an bestimmte Voraussetzungen gebundenes Verbot der Entfernung/Änderung von Rechteinformationen; das ist weder ein pauschales Verbot jeder Metadatenbereinigung noch automatisch irrelevant. [§ 95c UrhG](https://www.gesetze-im-internet.de/urhg/__95c.html).
4. **Datensparsamkeit:** Die Originale enthalten möglicherweise mehr als GPS und Seriennummern, etwa Namen oder Ortsangaben. Erforderlichkeit, Zugriff, Frist, Backups und Umfang des Provenance-Snapshots konkret festlegen. Einen IP-Hash nicht als anonym garantieren; für die technische Umsetzung eher einen zweckgebundenen, geheimnisgestützten Hash mit Versionierung prüfen.
5. **Vertragspartner und Vertretung:** Klären, wer bei Agentur-, Admin- und API-Uploads welche Erklärung für wen abgibt. Das protokollierte Firmenprofil ist nicht automatisch der Vertragspartner oder Rechteinhaber.
6. **Zusammenspiel der Zusicherungen:** Umfang der Bearbeitungs-/Syndikationsrechte, Freistellung und Korrektur einer falschen automatischen Kennzeichnung prüfen. Das Datenbankprotokoll belegt eine abgegebene Erklärung; es beweist nicht selbst das Vorliegen aller Rechte und garantiert keinen erfolgreichen Regress.
## 5. Vorschlag für eine angepasste Umsetzung
Ich würde die geplante Architektur beibehalten, aber den ersten produktiven Lieferumfang anders schneiden:
| Schritt | Inhalt | Ergebnis |
|---|---|---|
| 1. Regeln festziehen | Bestandsschutz, Vertrauensstufen, KI-Zuordnung, API-Übergang, Korrekturweg, Aufbewahrung und Rechtstexte | Eindeutige Regeln vor Schema und Formular |
| 2. Datenmodell ergänzen | Bestehende Werte erhalten; Herkunftsbefund, Originalreferenz, unveränderliche Bestätigung und Fristdaten ergänzen | Verlustfreie Migration |
| 3. Gemeinsamen Upload-Ablauf bauen | Original sichern und prüfen, Nutzereingaben validieren, effektive Kennzeichnung bestimmen, Derivate und Bestätigung konsistent speichern | Dieselben Regeln für Volt, v1 und Legacy |
| 4. Formular vereinfachen | Bestehenden Bildmanager umbauen; Vorschläge, Prüfstatus, Zustimmung und erlaubte Korrekturen | Weniger Aufwand mit nachvollziehbarer Entscheidung |
| 5. Ausgabe und Lebenszyklus schließen | Nachweis/Overlay, Vorschau/PDF, Veröffentlichungsgates, Entfernung, Aufbewahrung, `legal_hold`, Cache-Korrekturen | Durchgängiges Verhalten bis zur Löschung |
| 6. Weitere Verbreitung ergänzen | Formatgerechte Metadaten, strukturierte Daten und tatsächlich benötigte Feed-Verträge | Konsistente Weitergabe; juristisch erforderliche Teile gegebenenfalls bereits in Schritt 5 |
**Pragmatische Alternative:** Falls die C2PA-Verifikation den ersten Schritt zu groß macht, zunächst die Formularvereinfachung, nachvollziehbare Selbstdeklaration, Originalablage und versionierte Zustimmung umsetzen. Metadaten zunächst als Hinweise auslesen und speichern. Automatische Sperren erst nach einer getesteten Vertrauens- und Zuordnungslogik aktivieren. Das wäre eine bewusste Änderung von E4, die fachlich entschieden werden muss.
Diese Alternative vermeidet eine voreilige, teilweise falsche Automatik und liefert trotzdem früh den wesentlichen Nutzen: weniger Formularaufwand und nachvollziehbare Angaben.
## 6. Konkrete Abnahmetests für die spätere Implementierung
Für diesen reinen Dokumentationsaudit wurden keine Tests verändert oder ausgeführt. Die folgenden Fälle gehören in die Implementierung; reine Fake-Bilder reichen für die Herkunftsprüfung nicht aus.
| Bereich | Wesentliche Fälle |
|---|---|
| Migration | Vorhandene `original`-/`ai_generated`-/`ai_edited`-Werte und Credits unverändert; leere Bestandswerte bleiben darstellbar; neues Ersatzbild erhält neue Regeln |
| Metadaten | Vertrauenswürdig signiertes KI-Bild; untrusted Signatur; beschädigte Dateibindung; unsigned XMP; widersprüchliche Quellen; mehrere Manifeste und KI-Bestandteile; fehlender oder unbekannter Source-Type |
| Zuordnung | `algorithmicallyEnhanced` ohne generative KI erzwingt keinen KI-Hinweis; URI-Normalisierung; Screenshot/Illustration ohne KI; generativ ergänztes Foto |
| UI-Manipulation | Gesperrte Mindestkennzeichnung kann nicht unterschritten werden; erlaubte Änderung funktioniert; Dateiwechsel und spätes Prüfergebnis verwenden keine alte Zustimmung |
| Fehler | Timeout, fehlendes Binary, ungültiges JSON, zu großer Output, Schreibfehler und gescheiterte Derivaterzeugung; niemals versehentlich öffentliches Original |
| Atomarität | DB-Fehler nach Dateispeicherung; Wiederholung eines Requests; parallele Titelbilduploads; keine Bestätigung für die falsche Datei |
| Zugriff | Beweisoriginal weder öffentlich noch über normale Vorschau-/Varianten-URLs erreichbar; berechtigter interner Zugriff; keine internen Daten in API/Overlay |
| API | v1 und Legacy; fehlende Pflichtangaben; ungültige Textversion; Statusgrenzen; Metadatenkonflikt; kompatible Responses |
| Bestätigung | Wortlaut und Sprache später rekonstruierbar; korrekter Benutzer/Token/Admin; Bildtausch; geänderte Firma; Wechsel der Textversion bei geöffnetem Formular |
| Lebenszyklus | Alte PM erneut eingereicht; Admin-Bildtausch an veröffentlichter PM; Entfernen; nie veröffentlichter Upload; Fristablauf; gesetzter und aufgehobener `legal_hold` |
| Ausgabe | Beide Portale, Kunden-/Adminansicht, Vorschau, PDF; Nachweiszeile ohne Klick; kein falscher C2PA-Text; Metadaten nach Export aus allen ausgelieferten Formaten wieder lesbar |
Geeignete bestehende Anknüpfungspunkte:
- [PressReleaseImageLicenseTest](../../tests/Feature/PressReleaseImageLicenseTest.php): prüft bereits Pflichtangaben und erwartet derzeit ausdrücklich, dass die Originaldatei gelöscht wird. Diese Erwartung beim Umbau gezielt ersetzen.
- [PressReleaseAiDeclarationTest](../../tests/Feature/PressReleaseAiDeclarationTest.php): Einreichungssperre, Deklaration und bestehender Snapshot.
- [PressReleaseImageApiTest](../../tests/Feature/Api/V1/PressReleaseImageApiTest.php): Upload, Löschung, Größenlimit, Zugriff und KI-Pflichtangabe.
- [PressReleaseMediaPrivatizerTest](../../tests/Feature/PressReleaseMediaPrivatizerTest.php): Umgang mit privaten und fehlenden Legacy-Medien.
- [LegalDocumentTest](../../tests/Feature/Legal/LegalDocumentTest.php): gültige Rechtstextversion und zukünftige Fassungen.
## 7. Prüfgrenzen und Abschluss
Die Codebefunde beruhen auf den verlinkten Implementierungen und dem lesend abgefragten Schema. Externe Quellen wurden für C2PA/IPTC, Metadatenexport und die genannten gesetzlichen Bezugspunkte herangezogen. Es wurden keine echten C2PA-Dateien verarbeitet, keine Laufzeit- oder Lastmessungen durchgeführt und keine juristischen Einzelfälle bewertet. Ob einzelne API-Partner derzeit Bilder einliefern, wurde nicht anhand produktiver Uploaddaten untersucht.
**Fazit:** Formularvereinfachung, sichtbaren Nachweis und dokumentierte Zustimmung würde ich umsetzen. Vorher würde ich vor allem Bestandsmigration, Metadatenvertrauen, API-Einbindung und Original-/Bestätigungslebenszyklus korrigieren. Dafür ist eine gezielte Erweiterung des vorhandenen Systems ausreichend; ein kompletter Neubau ist nicht erforderlich.

View file

@ -0,0 +1,186 @@
# Kontakterreichbarkeit: DNS-/MX-Prüfung, Bounces und Veröffentlichung
**Stand:** 15.09.2026
**Status:** Entwicklungsgrundlage aus der besprochenen Empfehlung. Dieses Dokument beschreibt den vorhandenen Code und das vorgeschlagene Zielverhalten. Die Erweiterung ist noch nicht implementiert; konkrete neue Feldnamen, API-Felder und Betriebsparameter sind Umsetzungsvorschläge.
**Prüfumfang:** Lesender Codeabgleich im Arbeitsverzeichnis und Gegenprüfung der Mailstandards. Die produktive Konfiguration, tatsächlich laufende Jobs und konkrete Rückläufer wurden nicht geprüft.
## 1. Ziel und fachliche Leitlinie
Über die API werden Pressemitteilungen mit einer Firma und gegebenenfalls Pressekontakten angelegt. Bei Veröffentlichung oder Ablehnung werden Benachrichtigungen versendet. Ungültige Adressen verursachen Bounces und erschweren journalistische Rückfragen.
Die Kontaktdaten sollen bereits beim Einreichen technisch geprüft werden. Dabei gelten folgende Grundsätze:
- Eine DNS-/MX-Prüfung erkennt Probleme der E-Mail-Domain frühzeitig.
- Ein positiver DNS-Befund bestätigt weder das einzelne Postfach noch die Identität oder Verantwortlichkeit des Kontakts.
- Bounce-Verarbeitung und Versandsperren bleiben erforderlich.
- Ein einzelner Zustellfehler führt nicht automatisch zur Ablehnung oder Depublikation einer Pressemitteilung.
- Sind sämtliche relevanten Kontaktadressen nachweislich unzustellbar, werden neue automatische Veröffentlichungen zur Kontaktkorrektur oder manuellen Prüfung zurückgehalten.
- Die inhaltliche Prüfung bleibt eigenständig: Gute Kontaktdaten ersetzen keine Inhaltsprüfung, schlechte Kontaktdaten sind kein Beweis für falsche Inhalte.
## 2. Vorhandener Stand im Code
| Baustein | Vorhandenes Verhalten | Bedeutung für die Erweiterung |
|---|---|---|
| `EmailSuppressionService` | Speichert Bounce-Ereignisse und adressbezogene Versandsperren; Hard Bounce sperrt sofort, Soft Bounces standardmäßig ab drei Ereignissen in sieben Tagen | Weiterverwenden; keine zweite unabhängige Bounce-Sperrliste aufbauen |
| `GateOutgoingMail` | Entfernt gesperrte Empfänger unmittelbar vor dem Versand; berücksichtigt eine globale Versandpause | Auch bereits wartende Nachrichten müssen weiterhin gegen aktuelle Sperren geprüft werden |
| `PressReleaseService::notifyAuthor()` | Bei API-Einreichungen Auswahl eines Pressekontakts, danach Firmenadresse; bei fehlenden oder gesperrten Adressen keine Nachricht | Kontaktprüfung und Empfängerauswahl aufeinander abstimmen |
| `PressReleaseService::publish()` | Veröffentlichung und anschließende Benachrichtigung; bisher keine davon abgeleitete Kontaktfreigabe | Zentraler Ansatzpunkt für die neue Veröffentlichungsprüfung |
| `BounceMailProcessor` | Verarbeitet Rückläufer aus dem Postfach und unterscheidet Hard/Soft | Ursachen müssen vor einer fachlichen Sperrentscheidung genauer unterschieden werden |
| Scheduler | Bounce-Abholung alle zehn Minuten vorgesehen | Produktive Postfachkonfiguration und Ausführung bei Einführung separat verifizieren |
Codebelege: [Versandsperren](../../app/Services/Mail/EmailSuppressionService.php), [Versandprüfung](../../app/Listeners/GateOutgoingMail.php), [Pressemitteilungsservice](../../app/Services/PressRelease/PressReleaseService.php), [Bounce-Verarbeitung](../../app/Services/Mail/BounceMailProcessor.php), [Konfiguration](../../config/bounces.php), [Scheduler](../../routes/console.php).
### Bestehende Grenzen
1. **Policy-Fehler können Adresssperren auslösen.** Der DSN-Prozessor behandelt unter anderem `5.7.x` als Soft Bounce. Wiederholte Spamfilter-, Authentifizierungs- oder Policy-Ablehnungen können dadurch den Soft-Bounce-Schwellwert erreichen. Eine solche Sperre beweist kein ungültiges Postfach.
2. **Die aktuelle Kontaktwahl prüft nicht alle Alternativen.** Zuerst wird der erste Pressekontakt mit E-Mail nach ID ausgewählt und anschließend dessen Sperre geprüft. Ist dieser gesperrt, folgt die Firmenadresse; ein weiterer geeigneter Pressekontakt wird dabei nicht gesucht.
3. **Ein späterer Bounce löst keine automatische Ersatzbenachrichtigung aus.** Die Empfängerauswahl berücksichtigt die zum Auswahlzeitpunkt bekannten Sperren. Ein nachträglicher Rückläufer ist ein eigener Vorgang.
Diese Grenzen bei der Erweiterung berücksichtigen. Insbesondere darf die vorhandene Versandsperre nicht unverändert als Nachweis fehlender Kontakterreichbarkeit verwendet werden.
## 3. Was die DNS-/MX-Prüfung leisten soll
Für `presse@firma.example` wird die Domain `firma.example` geprüft. Ein MX-Eintrag nennt einen für die Domain vorgesehenen Mailserver. Zusätzlich muss dessen Hostname zu einer nutzbaren IP-Adresse auflösbar sein.
### Zu unterscheidende Ergebnisse
| Befund | Einordnung | Folge |
|---|---|---|
| Nutzbarer MX vorhanden | Mailrouting im DNS vorhanden | DNS-Prüfung bestanden; Postfach bleibt unbestätigt |
| Kein MX, aber A-/AAAA-Eintrag der Domain | Impliziter MX als zulässiger Fallback | Als möglichen Zustellweg berücksichtigen; keine Ablehnung allein wegen fehlendem MX |
| Domain existiert nicht (`NXDOMAIN`) | Domain zum Prüfzeitpunkt nicht vorhanden | Adresse technisch unzustellbar, bis neue Daten oder eine erneute Prüfung das Gegenteil ergeben |
| Gültiger Null MX (`MX 0 .`) | Domain erklärt ausdrücklich, keine E-Mails anzunehmen | Keine Nachricht versenden; Kontaktkorrektur erforderlich |
| Kein MX und kein A/AAAA bei erfolgreicher DNS-Abfrage | Kein Zustellweg im DNS vorhanden | Kontaktkorrektur erforderlich |
| MX vorhanden, sämtliche Ziele nachweislich nicht auflösbar | Fehlerhafte Mailkonfiguration | Technisches Kontaktproblem; bei mehreren Zielen alle berücksichtigen |
| Timeout, `SERVFAIL` oder anderer vorübergehender Resolverfehler | Ergebnis unbekannt | Erneut prüfen; nicht als ungültige Adresse oder Bounce zählen |
| Widersprüchliche oder nicht sicher auswertbare DNS-Antwort | Ergebnis unklar | Erneute oder manuelle Prüfung; keine Vermutung als gesicherten Befund speichern |
Der A-/AAAA-Fallback gilt bei fehlenden MX-Einträgen. Ein vorhandener, aber defekter MX wird nicht durch beliebiges Zurückfallen auf die Webserver-IP umgangen. Null MX wird ebenfalls nicht durch einen vorhandenen A-Eintrag aufgehoben. Grundlage: [RFC 5321, Abschnitt 5.1](https://datatracker.ietf.org/doc/html/rfc5321#section-5.1), [RFC 7505](https://www.rfc-editor.org/rfc/rfc7505.html).
### Grenzen
- `presse@firma.example` und `nichtvorhanden@firma.example` können dasselbe positive DNS-Ergebnis haben.
- Ein DNS-Eintrag belegt keine aktuelle Annahmebereitschaft des Mailservers.
- Keine Bounces gesehen zu haben bedeutet nicht, dass das Postfach bestätigt wurde.
- Für diese Ausbaustufe sind keine SMTP-Probeanmeldungen und keine zusätzlichen Testmails vorgesehen.
- Eine erfolgreiche DNS-Prüfung hebt niemals eine bekannte Postfachsperre auf.
## 4. Drei getrennte Bewertungen
Die Umsetzung benötigt getrennte Informationen für unterschiedliche Entscheidungen:
1. **Domainprüfung:** Ist ein Zustellweg im DNS vorhanden, fehlt er nachweislich oder ist das Ergebnis offen?
2. **Adressbezogener Versandstatus:** Darf diese konkrete E-Mail-Adresse angeschrieben werden? Ursache einer Sperre erhalten, beispielsweise ungültiges Postfach, wiederholte temporäre Fehler, Policyproblem, manuelle Sperre oder Abbestellung.
3. **Kontaktfreigabe der Pressemitteilung:** Darf die Mitteilung den automatischen Veröffentlichungsprozess fortsetzen, wartet sie auf Prüfung oder benötigt sie eine Korrektur/manuelle Entscheidung?
Eine Abbestellung oder manuelle Versandsperre ist für sich genommen kein Nachweis technischer Unerreichbarkeit. Ebenso darf ein positives DNS-Ergebnis keine Abbestellung umgehen. Bestehende Regeln für Benachrichtigungen weiter beachten.
Die konkrete Speicherung ist bei Umsetzung an das vorhandene Schema anzupassen. Kein zusätzliches pauschales Boolean-Feld `erreichbar`, das alle drei Bewertungen vermischt.
## 5. Empfohlener Ablauf beim Einreichen
1. **Eingang speichern und Syntax validieren.** Bestehende API-Verträge und Validierung beachten. Eine externe DNS-Störung darf keine bereits eingereichten Daten verlieren lassen.
2. **Firmenadresse und alle mit der Mitteilung verknüpften Pressekontakte erfassen.** Doppelte Adressen und Domains für die Prüfung zusammenfassen. Keine fremden Kontakte allein aufgrund derselben Domain zuordnen.
3. **Bekannte Adressprobleme einbeziehen.** Dauerhafte Empfängerfehler von temporären Fehlern, Policyproblemen und anderen Sperrgründen unterscheiden.
4. **DNS-Prüfung unmittelbar nach erfolgreicher Speicherung im Hintergrund starten.** Gültige Cache-Ergebnisse dürfen direkt verwendet werden. Die API muss nicht auf externe DNS-Antworten warten.
5. **Gesamtergebnis für die Mitteilung ermitteln.** Mindestens eine technisch geeignete Adresse ohne bekannten dauerhaften Empfängerfehler reicht für die technische Kontaktanforderung. Das ist keine Bestätigung des Postfachs.
6. **Vor automatischer Veröffentlichung zentral erneut entscheiden.** Inhaltsfreigabe und Kontaktfreigabe müssen zusammenpassen. Ein noch laufender DNS-Job darf nicht von einem schnelleren Klassifizierungsjob überholt werden.
7. **Bei Korrektur neu prüfen und den Ablauf fortsetzen.** Prüfergebnisse alter Adressstände dürfen neue Daten nicht überschreiben. Wiederholte Jobausführung darf weder doppelte Veröffentlichung noch doppelte Benachrichtigung verursachen.
Eine technisch geeignete, aber beispielsweise abgemeldete Adresse darf zur technischen Bewertung beitragen; eine Benachrichtigung an sie bleibt entsprechend der geltenden Versandregeln unterdrückt.
## 6. Entscheidung über die Veröffentlichung
| Kontaktsituation | Empfohlenes Verhalten |
|---|---|
| Pressekontakt technisch ungeeignet, Firmenadresse geeignet | Normaler Inhalts-/Veröffentlichungsprozess; Kontaktkorrektur anzeigen |
| Firmenadresse technisch ungeeignet, mindestens ein Pressekontakt geeignet | Normaler Prozess; Firmendatenkorrektur anzeigen |
| Erster Pressekontakt gesperrt, weiterer Kontakt geeignet | Weitere Kontakte berücksichtigen; Versand nur an zulässige Empfänger |
| Alle relevanten Adressen nachweislich unzustellbar | Automatische Veröffentlichung zurückhalten; Kontaktkorrektur oder manuelle Prüfung erforderlich |
| Keine Kontaktadresse vorhanden | Kontaktanforderung nicht erfüllt; Korrektur oder dokumentierte Ausnahme erforderlich |
| Keine geeignete Adresse bekannt und mindestens eine DNS-Prüfung ausstehend | Auf Prüfung warten; keine endgültige Ablehnung |
| Ausschließlich Policy-/Absenderprobleme oder ein einzelner temporärer Bounce | Keine inhaltliche Ablehnung daraus ableiten; Versandproblem gesondert bearbeiten |
| Inhaltsfilter lehnt unabhängig davon ab | Inhaltliche Ablehnung darf abgeschlossen werden; Kontaktproblem und Versandstatus separat ausweisen |
**Alternative Kontaktwege:** Bei manuell bestätigtem Kontakt, etwa telefonisch oder über einen verantwortlichen Ansprechpartner beim API-Einlieferer, ist eine begründete Ausnahme möglich. Eine bloß hinterlegte Telefonnummer genügt dafür nicht. Zeitpunkt, prüfende Person und Grund protokollieren; die Ausnahme hebt keine Versandsperre auf.
**Bereits veröffentlichte Mitteilungen:** Ein späterer Bounce erzeugt einen Prüfhinweis und gegebenenfalls eine Korrekturanforderung. Keine automatische Depublikation und keine pauschale Sperrung sämtlicher Veröffentlichungen einer Firma. Zusätzliche Auffälligkeiten können eine redaktionelle Prüfung begründen. Neue Einreichungen werden anhand des aktuellen Kontaktstands bewertet.
## 7. Technische Anforderungen für die Entwicklung
### Prüfung und Speicherung
- Domain normalisieren, internationale Domains korrekt in eine DNS-taugliche Darstellung überführen und die bestehende Adressnormalisierung konsistent verwenden.
- Resolver muss `NXDOMAIN`, erfolgreiche Antworten ohne passende Records und vorübergehende Fehler unterscheiden können. Eine alleinige Ja/Nein-Abfrage auf MX reicht nicht.
- Pro Domain Ergebnis, maschinenlesbaren Grund, Prüfzeitpunkt, Ablaufzeitpunkt und nächste Wiederholung verfügbar halten.
- Ergebnis nach DNS-Gültigkeitsdauer zwischenspeichern; negative Antworten mit ihrer negativen Cache-Gültigkeit berücksichtigen. Resolverfehler nur kurz behandeln und erneut prüfen.
- Bei unklarer Gültigkeitsdauer konservative, konfigurierbare Cache-Grenzen vorsehen. Ein DNS-Befund darf nicht unbegrenzt als dauerhafte Domainsperre bestehen bleiben.
- Jobs erst nach erfolgreichem Datenbank-Commit starten. DNS-Abfragen außerhalb von Datenbanktransaktionen und Veröffentlichungssperren ausführen.
- Wiederholungen mit begrenztem Zeitbudget und zunehmenden Abständen ausführen. Nach ausgeschöpften Versuchen zur manuellen Prüfung übergeben; keine automatische Umdeutung in „Adresse ungültig“.
- Änderungen an Adressen oder Kontaktzuordnungen machen die bisherige Gesamtbewertung ungültig. Ergebnisse an den geprüften Datenstand binden.
- Bei zeitversetzter Veröffentlichung abgelaufene Domainbefunde und aktuelle Adresssperren berücksichtigen. Das Veröffentlichungsdatum bleibt erhalten, solange die Kontaktprüfung wartet.
### Anbindung an das bestehende System
- API v1 und Legacy müssen dieselbe fachliche Prüfung verwenden.
- Den vorhandenen zentralen Veröffentlichungsservice erweitern, damit Klassifizierungsjobs und zeitgesteuerte Veröffentlichung die Prüfung nicht umgehen.
- Die Anwendung auf Kundeneinreichungen ausdrücklich festlegen; Ausgangsumfang dieses Konzepts sind API-Einreichungen. Die Herkunft muss serverseitig nachvollziehbar bleiben.
- Einen eigenen Prüfgrund neben der Inhaltsklassifikation führen. Kontaktkorrektur nicht als inhaltlichen Blacklist-Verstoß speichern.
- Kontaktprüfung vor endgültiger Veröffentlichung und Verbrauch eines Veröffentlichungsslots entscheiden.
- Vorhandene adressbezogene Versandsperren zentral weiterverwenden. Domainbefunde gesondert verwalten; kein synthetischer Hard Bounce allein aus einer DNS-Abfrage.
- Empfängerauswahl über alle zulässigen Pressekontakte und danach die Firmenadresse vornehmen; unmittelbar vor Versand erneut auf Sperren prüfen.
- Keine pauschale Übertragung eines Postfachfehlers auf alle Adressen derselben Domain oder das gesamte Firmenkonto.
- Historische Sperren mit unklarer Ursache zur Prüfung markieren, statt sie beim Start automatisch in Veröffentlichungssperren umzuwandeln oder freizugeben.
### API und Admin
- Einlieferern verständliche, maschinenlesbare Gründe bereitstellen, beispielsweise Domain nicht vorhanden, Domain nimmt keine Mail an, Postfach dauerhaft abgewiesen oder Prüfung ausstehend. Feldnamen und Antwortformat an die bestehenden API-Verträge anpassen.
- Annahme der Einreichung, Kontaktprüfung, Inhaltsentscheidung und Benachrichtigungszustellung unterscheidbar anzeigen. Eine erfolgreich gespeicherte Einreichung ist noch keine Veröffentlichungszusage.
- Im Admin „Kontaktprüfung erforderlich“ und „Prüfung ausstehend“ filterbar machen; betroffene Adressen, Gründe, Prüfzeit und Mitteilungen anzeigen.
- Korrektur, erneute Prüfung und begründete manuelle Freigabe nachvollziehbar anbieten. Kein unbelegtes Label „Kontakt verifiziert“ nach bloßer DNS-Prüfung.
- Kontaktprobleme dem API-Einlieferer über abrufbaren Status oder einen gebündelten Bericht zugänglich machen. Keine Korrekturanforderung an eine bereits gesperrte Adresse schicken.
- Ein späterer Bounce bleibt auch dann sichtbar, wenn die Veröffentlichung bereits erfolgreich abgeschlossen wurde.
## 8. Abnahmekriterien für die Implementierung
Die spätere Umsetzung mit deterministischen Tests und simulierten DNS-Antworten prüfen; keine Abhängigkeit von öffentlichen DNS-Servern im Testlauf.
1. Gültiger MX erlaubt die Fortsetzung, bestätigt aber kein Postfach und löscht keine Bounce-Sperre.
2. Fehlender MX mit A/AAAA wird als zulässiger Fallback behandelt.
3. Null MX, `NXDOMAIN` sowie erfolgreich festgestelltes Fehlen sämtlicher Zustellwege führen zu nachvollziehbaren Kontaktproblemen.
4. Ein nutzbares MX-Ziel reicht aus, wenn weitere Ziele defekt sind; vorübergehende Auflösungsfehler werden nicht als endgültiges Fehlen behandelt.
5. Timeout und `SERVFAIL` erzeugen Wiederholungen und bei ausgeschöpftem Budget eine manuelle Prüfung, keine inhaltliche Ablehnung.
6. Firmenadresse und alle zugeordneten Pressekontakte werden berücksichtigt; ein defekter erster Kontakt verdeckt keinen geeigneten weiteren Kontakt.
7. Alle Adressen ungeeignet oder fehlend verhindert automatische Veröffentlichung und Slot-Verbrauch; eine dokumentierte Ausnahme ist nachvollziehbar.
8. Policyfehler, temporäre Bounces und Abbestellungen werden nicht als dauerhafte Empfängerfehler gewertet; Versandbeschränkungen bleiben wirksam.
9. API v1, Legacy, Klassifizierungsjob und Scheduler befolgen die gemeinsame Kontaktentscheidung ohne Umgehung oder Race Condition.
10. Wiederholte Jobs und konkurrierende Freigaben erzeugen keine doppelte Veröffentlichung, Slot-Belastung oder Benachrichtigung.
11. Kontaktkorrekturen verwerfen veraltete Bewertungen und ermöglichen die erneute Prüfung; Cache-Ablauf löst eine neue Domainprüfung aus.
12. Spätere Bounces depublizieren bestehende Mitteilungen nicht automatisch und sperren keine anderen Postfächer derselben Domain.
13. API-Status und Admin zeigen Kontaktursache, Inhaltsstatus und Versandstatus getrennt; manuelle Ausnahmen sind protokolliert.
## 9. Empfohlene Umsetzungsreihenfolge und offene Details
1. **Bounce-Ursachen präzisieren:** Dauerhafte Empfängerfehler zuverlässig von Policy-/Absenderproblemen unterscheiden; bestehende Sperren entsprechend einordnen.
2. **DNS-Prüfung und Beobachtung ergänzen:** Ergebnisse speichern, Admin-Anzeige und API-Rückmeldung bereitstellen. Zunächst ohne automatische Veröffentlichungssperren beobachten.
3. **Kontaktregel aktivieren:** Nach Prüfung der Ergebnisse neue API-Einreichungen entsprechend Abschnitt 6 zurückhalten; Korrektur- und Ausnahmeprozess gleichzeitig bereitstellen.
Vor Aktivierung konkret festlegen:
- Zeitbudget, Anzahl und Abstände der DNS-Wiederholungen sowie Zuständigkeit für liegengebliebene Prüfungen.
- API-Felder und Bereitstellung eines gebündelten Berichts, falls dieser zusätzlich zum abrufbaren Status benötigt wird.
- Berechtigung und Geltungsdauer manueller Ausnahmen.
- Geltung für erneutes Einreichen alter Entwürfe und eine mögliche spätere Erweiterung auf Kundeneinreichungen.
Diese Betriebsdetails sind noch keine implementierten oder abschließend festgelegten Werte. Es ist keine nachträgliche Massen-Depublikation vorgesehen.
## 10. Quellen und Bezug zum bisherigen Konzept
- [SMTP: DNS-Auflösung und impliziter MX, RFC 5321 § 5.1](https://datatracker.ietf.org/doc/html/rfc5321#section-5.1)
- [Null MX: Domain nimmt keine E-Mails an, RFC 7505](https://www.rfc-editor.org/rfc/rfc7505.html)
- [Postmark: Einordnung von Bounces und Spam-Beschwerden](https://postmarkapp.com/support/article/what-are-bounces-and-spam-complaints)
- [Bisheriges Bounce-Konzept vom 15.09.2026](../2026-09-nutzergewinnung/bounce-mails-2026-09-15.md)
Dieses Dokument ergänzt das bisherige Bounce-Konzept um die Prüfung beim Einreichen und deren Auswirkungen auf die Veröffentlichung. Aussagen zum vorhandenen Verhalten beruhen auf dem oben verlinkten Code, nicht auf einer bestätigten produktiven Ausführung.

View file

@ -0,0 +1,245 @@
{
"from": "2026-06-01",
"to": "2026-09-21",
"stichtag": "2026-09-21",
"portals": [
{
"portal": "presseecho",
"label": "Presseecho",
"segments": [
{
"segment": "gesamt",
"stats": {
"anzahl": 2080,
"min": 10,
"p10": 67,
"p25": 86,
"median": 111,
"p75": 157,
"p90": 214,
"max": 50871,
"mittelwert": 151.8,
"verteilung": {
"0–24": 2,
"25–49": 100,
"50–99": 694,
"100–199": 998,
"200–499": 285,
"500–999": 0,
"1000+": 1
}
}
},
{
"segment": "seit Relaunch",
"stats": {
"anzahl": 1804,
"min": 10,
"p10": 76,
"p25": 91,
"median": 112,
"p75": 150,
"p90": 212,
"max": 50871,
"mittelwert": 156.7,
"verteilung": {
"0–24": 1,
"25–49": 10,
"50–99": 629,
"100–199": 943,
"200–499": 220,
"500–999": 0,
"1000+": 1
}
}
},
{
"segment": "Altbestand",
"stats": {
"anzahl": 276,
"min": 24,
"p10": 34,
"p25": 43,
"median": 72,
"p75": 196,
"p90": 216,
"max": 496,
"mittelwert": 119.8,
"verteilung": {
"0–24": 1,
"25–49": 90,
"50–99": 65,
"100–199": 55,
"200–499": 65,
"500–999": 0,
"1000+": 0
}
}
}
],
"cohorts": [
{
"alter": "0–6 Tage",
"anzahl": 176,
"median": 75,
"mittelwert": 75.1
},
{
"alter": "7–13 Tage",
"anzahl": 177,
"median": 98,
"mittelwert": 98.4
},
{
"alter": "14–20 Tage",
"anzahl": 185,
"median": 111,
"mittelwert": 115.3
},
{
"alter": "21–27 Tage",
"anzahl": 191,
"median": 109,
"mittelwert": 112.5
},
{
"alter": "28–55 Tage",
"anzahl": 703,
"median": 122,
"mittelwert": 133.2
},
{
"alter": "56–90 Tage",
"anzahl": 184,
"median": 230,
"mittelwert": 234.4
},
{
"alter": "91+ Tage",
"anzahl": 0,
"median": null,
"mittelwert": null
}
]
},
{
"portal": "businessportal24",
"label": "Businessportal24",
"segments": [
{
"segment": "gesamt",
"stats": {
"anzahl": 2140,
"min": 21,
"p10": 116,
"p25": 171,
"median": 260,
"p75": 332,
"p90": 388,
"max": 224830,
"mittelwert": 368.7,
"verteilung": {
"0–24": 1,
"25–49": 31,
"50–99": 109,
"100–199": 539,
"200–499": 1387,
"500–999": 62,
"1000+": 11
}
}
},
{
"segment": "seit Relaunch",
"stats": {
"anzahl": 1847,
"min": 21,
"p10": 128,
"p25": 183,
"median": 266,
"p75": 331,
"p90": 376,
"max": 224830,
"mittelwert": 384.2,
"verteilung": {
"0–24": 1,
"25–49": 7,
"50–99": 58,
"100–199": 467,
"200–499": 1282,
"500–999": 29,
"1000+": 3
}
}
},
{
"segment": "Altbestand",
"stats": {
"anzahl": 293,
"min": 26,
"p10": 54,
"p25": 97,
"median": 198,
"p75": 377,
"p90": 592,
"max": 1366,
"mittelwert": 271.2,
"verteilung": {
"0–24": 0,
"25–49": 24,
"50–99": 51,
"100–199": 72,
"200–499": 105,
"500–999": 33,
"1000+": 8
}
}
}
],
"cohorts": [
{
"alter": "0–6 Tage",
"anzahl": 181,
"median": 115,
"mittelwert": 120.6
},
{
"alter": "7–13 Tage",
"anzahl": 179,
"median": 171,
"mittelwert": 176.4
},
{
"alter": "14–20 Tage",
"anzahl": 180,
"median": 217,
"mittelwert": 225.8
},
{
"alter": "21–27 Tage",
"anzahl": 190,
"median": 253,
"mittelwert": 254.5
},
{
"alter": "28–55 Tage",
"anzahl": 727,
"median": 314,
"mittelwert": 318
},
{
"alter": "56–90 Tage",
"anzahl": 194,
"median": 346,
"mittelwert": 344.4
},
{
"alter": "91+ Tage",
"anzahl": 0,
"median": null,
"mittelwert": null
}
]
}
]
}

View file

@ -0,0 +1,244 @@
{
"from": "2026-06-01",
"to": "2026-09-01",
"portals": [
{
"portal": "presseecho",
"label": "Presseecho",
"segments": [
{
"segment": "gesamt",
"stats": {
"anzahl": 1413,
"min": 24,
"p10": 65,
"p25": 95,
"median": 126,
"p75": 183,
"p90": 229,
"max": 496,
"mittelwert": 141.4,
"verteilung": {
"0–24": 1,
"25–49": 90,
"50–99": 309,
"100–199": 735,
"200–499": 278,
"500–999": 0,
"1000+": 0
}
}
},
{
"segment": "seit Relaunch",
"stats": {
"anzahl": 1137,
"min": 58,
"p10": 86,
"p25": 103,
"median": 128,
"p75": 178,
"p90": 233,
"max": 420,
"mittelwert": 146.7,
"verteilung": {
"0–24": 0,
"25–49": 0,
"50–99": 244,
"100–199": 680,
"200–499": 213,
"500–999": 0,
"1000+": 0
}
}
},
{
"segment": "Altbestand",
"stats": {
"anzahl": 276,
"min": 24,
"p10": 34,
"p25": 43,
"median": 72,
"p75": 196,
"p90": 216,
"max": 496,
"mittelwert": 119.7,
"verteilung": {
"0–24": 1,
"25–49": 90,
"50–99": 65,
"100–199": 55,
"200–499": 65,
"500–999": 0,
"1000+": 0
}
}
}
],
"cohorts": [
{
"alter": "0–6 Tage",
"anzahl": 188,
"median": 109,
"mittelwert": 112.5
},
{
"alter": "7–13 Tage",
"anzahl": 146,
"median": 108,
"mittelwert": 112.2
},
{
"alter": "14–20 Tage",
"anzahl": 171,
"median": 104,
"mittelwert": 114.6
},
{
"alter": "21–27 Tage",
"anzahl": 136,
"median": 128,
"mittelwert": 139.7
},
{
"alter": "28–55 Tage",
"anzahl": 351,
"median": 202,
"mittelwert": 206.8
},
{
"alter": "56–90 Tage",
"anzahl": 0,
"median": null,
"mittelwert": null
},
{
"alter": "91+ Tage",
"anzahl": 0,
"median": null,
"mittelwert": null
}
]
},
{
"portal": "businessportal24",
"label": "Businessportal24",
"segments": [
{
"segment": "gesamt",
"stats": {
"anzahl": 1466,
"min": 26,
"p10": 149,
"p25": 243,
"median": 303,
"p75": 354,
"p90": 409,
"max": 1536,
"mittelwert": 303.5,
"verteilung": {
"0–24": 0,
"25–49": 24,
"50–99": 52,
"100–199": 168,
"200–499": 1152,
"500–999": 61,
"1000+": 9
}
}
},
{
"segment": "seit Relaunch",
"stats": {
"anzahl": 1173,
"min": 99,
"p10": 213,
"p25": 264,
"median": 310,
"p75": 353,
"p90": 395,
"max": 1536,
"mittelwert": 311.6,
"verteilung": {
"0–24": 0,
"25–49": 0,
"50–99": 1,
"100–199": 96,
"200–499": 1047,
"500–999": 28,
"1000+": 1
}
}
},
{
"segment": "Altbestand",
"stats": {
"anzahl": 293,
"min": 26,
"p10": 54,
"p25": 97,
"median": 198,
"p75": 377,
"p90": 591,
"max": 1365,
"mittelwert": 271.1,
"verteilung": {
"0–24": 0,
"25–49": 24,
"50–99": 51,
"100–199": 72,
"200–499": 105,
"500–999": 33,
"1000+": 8
}
}
}
],
"cohorts": [
{
"alter": "0–6 Tage",
"anzahl": 187,
"median": 252,
"mittelwert": 253.9
},
{
"alter": "7–13 Tage",
"anzahl": 154,
"median": 294,
"mittelwert": 298.2
},
{
"alter": "14–20 Tage",
"anzahl": 175,
"median": 336,
"mittelwert": 342.1
},
{
"alter": "21–27 Tage",
"anzahl": 142,
"median": 319,
"mittelwert": 330.8
},
{
"alter": "28–55 Tage",
"anzahl": 368,
"median": 338,
"mittelwert": 338.1
},
{
"alter": "56–90 Tage",
"anzahl": 0,
"median": null,
"mittelwert": null
},
{
"alter": "91+ Tage",
"anzahl": 0,
"median": null,
"mittelwert": null
}
]
}
]
}

View file

@ -0,0 +1,235 @@
# Basiswerte Block 0
**Erhoben:** 01.09.2026 auf dem Live-System; Kohorten-Basiswert am 21.09.2026 (Abschnitt „✔ Basiswert 0.1")
**Fenster:** 01.06.2026 bis 01.09.2026 (Kohorten: bis 21.09.2026)
**Erhoben mit:** `press-releases:view-distribution`, `press-releases:rejection-rate`
Diese Werte sind der Vergleichspunkt für alles, was aus Block 1 folgt. Sie
stehen fest, bevor die erste Maßnahme live geht — genau dafür existiert
Block 0.
---
## 0.1 Aufrufzahlen
### Gesamtverteilung im Fenster
| Portal | PMs | Min | P10 | P25 | **Median** | P75 | P90 | Max | Mittelwert |
|---|---|---|---|---|---|---|---|---|---|
| Presseecho | 1436 | 1 | 43 | 72 | **98** | 164 | 212 | 412 | 118,6 |
| Businessportal24 | 1491 | 7 | 81 | 127 | **200** | 279 | 351 | 1376 | 214,4 |
Aufgeteilt nach Bestand:
| Portal | Bestand | PMs | Median |
|---|---|---|---|
| Presseecho | seit Relaunch | 1172 | 100 |
| Presseecho | Altbestand | 264 | 58 |
| Businessportal24 | seit Relaunch | 1210 | 202 |
| Businessportal24 | Altbestand | 281 | 189 |
Häufigkeitsverteilung:
| Klasse | Presseecho | Businessportal24 |
|---|---|---|
| 0–24 Aufrufe | 2 % | 0 % |
| 25–49 | 11 % | 4 % |
| 50–99 | 38 % | 11 % |
| 100–199 | 37 % | 35 % |
| 200–499 | 12 % | 48 % |
| 500–999 | 0 % | 2 % |
| 1000+ | 0 % | 0 % |
### ⚠ Der Gesamtmedian ist NICHT der Basiswert
`press_releases.hits` ist ein mitlaufender Gesamtzähler, und die Aufrufe wachsen
nach der Veröffentlichung über Wochen weiter. Gemessen auf der Dev-Kopie
(Stand 13.08.), nur Meldungen seit dem Relaunch:
| Alter der Meldung | Presseecho ⌀ | Businessportal24 ⌀ |
|---|---|---|
| 0–6 Tage | 76 | 81 |
| 7–13 Tage | 128 | 126 |
| 14–20 Tage | 169 | 179 |
| 21–27 Tage | 235 | 231 |
Nach drei Wochen ist **kein Plateau erreicht** — die Kurve steigt weiter. Damit
misst der Median über ein Drei-Monats-Fenster nicht, wie viele Aufrufe eine
Meldung bekommt, sondern wie viel eine zufällig alte Meldung bisher gesammelt
hat. Dieser Wert verschiebt sich mit der Altersmischung des Fensters; zwei
Messungen zu verschiedenen Zeitpunkten wären nicht vergleichbar.
**Vergleichbar ist nur die einzelne Alterskohorte.** Der Report gibt sie seit
dem 01.09. mit aus. Als Basiswert festzuhalten ist die Zeile
**„28–55 Tage"** je Portal — sie lässt sich in einem halben Jahr identisch
erheben.
> **Offen:** Die Kohortenwerte vom Live-System fehlen noch. Der Report ist
> nachgerüstet, aber die Zahlen oben stammen aus dem Lauf davor. Vor dem
> Livegang von Block 1 einmal nachziehen:
> `php artisan press-releases:view-distribution --json > basiswert-0.1.json`
>
> **21.09.2026:** Lauf mit `--from=2026-06-01 --to=2026-09-01 --json` auf
> dem Live-System, Datei `basiswert-0.1.json` liegt im Ordner. Auswertung
> im nächsten Abschnitt.
### Erhebung 21.09.2026 — dasselbe Fenster, drei Wochen später
Gleiches Fenster (01.06.–01.09.), gemessen am 21.09. statt am 01.09.
(`basiswert-0.1.json`):
| Portal | PMs | Median 01.09. | **Median 21.09.** | Mittelwert 21.09. | P10 / P90 |
|---|---|---|---|---|---|
| Presseecho | 1413 | 98 | **126** | 141,4 | 65 / 229 |
| Businessportal24 | 1466 | 200 | **303** | 303,5 | 149 / 409 |
Der Gesamtmedian ist in drei Wochen um 29 % (Presseecho) bzw. 52 %
(Businessportal24) gestiegen, ohne dass sich am Fenster etwas geändert hat
— genau der Effekt, vor dem der Abschnitt oben warnt. (Die PM-Zahl ist um
23 bzw. 25 gesunken: seither offline genommene Meldungen.)
Kohorten laut Datei (Alter gerechnet ab `--to` = 01.09., Zähler aber
Stand 21.09. — das **tatsächliche Alter liegt 20 Tage höher**):
| Zeile in der Datei | tatsächliches Alter | Presseecho PMs / Median | Businessportal24 PMs / Median |
|---|---|---|---|
| 0–6 Tage | 20–26 | 188 / 109 | 187 / 252 |
| 7–13 Tage | 27–33 | 146 / 108 | 154 / 294 |
| 14–20 Tage | 34–40 | 171 / 104 | 175 / 336 |
| 21–27 Tage | 41–47 | 136 / 128 | 142 / 319 |
| 28–55 Tage | 48–75 (nur ab Go-Live 20.07.) | 351 / 202 | 368 / 338 |
Lesbar ist daraus schon einiges: Businessportal24 erreicht ab etwa fünf
Wochen ein Plateau um 320–340 Aufrufe; Presseecho liegt bei rund 105–130
und springt nur in der Go-Live-Kohorte (Meldungen vom 20.07.–04.08.) auf
202 — vermutlich der Erstcrawl nach dem Umzug, kein Dauerzustand.
**Als Basiswert taugt diese Kohortenzeile trotzdem nicht:** Der Report
rechnete das Alter ab `--to`, die Zähler stehen aber immer auf heute. Mit
einem Enddatum in der Vergangenheit verschieben sich alle Kohorten um die
Verzögerung des Laufs. Seit dem 21.09.2026 zählt der Report das Alter ab
dem Stichtag der Erhebung (Feld `stichtag` in der JSON-Ausgabe); ein
`--to` in der Vergangenheit lässt die jungen Kohorten leer.
> ~~**Offen (letzter Schritt für 0.1):** einmal mit Stichtag = heute laufen lassen~~
> — erledigt, siehe nächster Abschnitt.
### ✔ Basiswert 0.1 — Kohorte „28–55 Tage", Stichtag 21.09.2026
Lauf `press-releases:view-distribution --from=2026-06-01 --json` auf dem
Live-System am 21.09.2026 (Enddatum = Stichtag, Alter und Zähler passen
zusammen; `basiswert-0.1-kohorten.json`). Nur Meldungen seit dem Relaunch.
| Portal | PMs in der Kohorte | **Median** | Mittelwert |
|---|---|---|---|
| **Presseecho** | 703 | **122** | 133,2 |
| **Businessportal24** | 727 | **314** | 318,0 |
**Das ist der Vergleichspunkt für Block 1–3.** Nächste Erhebung mit
demselben Befehl (ohne `--to`), Zeile „28–55 Tage", frühestens im
Frühjahr 2027; die Kohorte enthält dann ausschließlich Meldungen, die
unter den Maßnahmen von Block 1 veröffentlicht wurden.
Die ganze Kurve am Stichtag 21.09.2026:
| Alter | Presseecho PMs / Median | Businessportal24 PMs / Median |
|---|---|---|
| 0–6 Tage | 176 / 75 | 181 / 115 |
| 7–13 Tage | 177 / 98 | 179 / 171 |
| 14–20 Tage | 185 / 111 | 180 / 217 |
| 21–27 Tage | 191 / 109 | 190 / 253 |
| **28–55 Tage** | **703 / 122** | **727 / 314** |
| 56–90 Tage (= Go-Live-Wochen 20.07.–27.07.) | 184 / 230 | 194 / 346 |
> **Fußnote (25.09.2026, Umsetzungsaudit N15):** Die Kohorten dieser
> Erhebung waren je einen Tag schmaler als beschriftet — der Report bildete
> die Untergrenze mit `Stichtag − (Obergrenze − 1)` und ließ an jeder
> Kohortengrenze 24 Stunden aus. „28–55 Tage" umfasst hier also 27 statt
> 28 Tage, die Kohortensumme (1.616 / 1.651) liegt deshalb unter „seit
> Relaunch" (1.804 / 1.847). Der Report ist seit dem 25.09.2026 korrigiert
> (halboffene Intervalle, lückenlos). Die Werte 122 / 314 bleiben als
> Basiswert stehen — eine Neuerhebung heute träfe eine andere
> Meldungsmenge, nicht denselben Stichtag. Die nächste Erhebung läuft mit
> dem korrigierten Report; der Unterschied von einem Tag Breite ist für den
> Vergleich der Mediane unerheblich.
Lesart:
- **Businessportal24 sammelt stetig** bis etwa Woche 5 und liegt dann bei
gut 300; Meldungen bekommen dort im ersten Monat rund zweieinhalbmal so
viele Aufrufe wie auf Presseecho.
- **Presseecho ist nach zwei Wochen praktisch fertig** (111 → 109 → 122).
Die 230 der Go-Live-Kohorte sind der Erstcrawl nach dem Umzug, kein
Dauerzustand — der Ausreißer aus der ersten Datei ist damit erklärt.
- Die Fensterwerte der ersten Datei (Median 126 / 303 für 01.06.–01.09.)
lagen genau deshalb höher als der Kohortenwert: sie enthalten die
Go-Live-Wochen zu einem Drittel.
- Für E2 ändert sich nichts: keine Bodensatz-Klasse, Mittelwert nahe am
Median, Portale um den Faktor 2,5 auseinander — Rohzahlen, je Portal.
---
## 0.2 Ablehnungsquote je Quelle
| Quelle | Eingereicht | Veröffentlicht | Abgelehnt | **Quote** |
|---|---|---|---|---|
| API: connektar.de Presseverteiler (#13993) | 2428 | 2369 | 59 | **2,4 %** |
| direkt (Kundenbereich) | 13 | 13 | 0 | 0 % |
| **gesamt** | **2441** | **2382** | **59** | **2,4 %** |
### Nur eine API-Quelle
Die Roadmap nannte ursprünglich „connektar, openPR, direkt". Das war ein
Missverständnis: openPR ist kein zweiter Kanal, sondern der Presse-API-Verteiler
von connektar — also derselbe Zulauf. Bestätigt am 01.09.2026.
In den Daten: genau ein API-Token wurde je benutzt (User 13993). Der Report
gruppiert trotzdem generisch je API-Client, damit ein späterer zweiter Kunde
automatisch eine eigene Zeile bekommt.
### ⚠ Was diese Zahl für Block 1d bedeutet
59 Ablehnungen im Quartal sind eine schmale Grundlage. Grobe Abschätzung bei
n ≈ 2400 und p = 2,4 %: Der Standardfehler liegt bei rund 0,3 Prozentpunkten,
der eines Vorher-Nachher-Vergleichs bei rund 0,44.
| Angestrebte Wirkung von 1d | Erkennbar nach einem Quartal? |
|---|---|
| Quote halbiert (2,4 % → 1,2 %) | ja, rund 2,7 Standardfehler |
| Quote um 20 % gesenkt (2,4 % → 1,9 %) | nein, rund 1,1 Standardfehler |
**Folge für die Abbruchbedingung von Block 1:** „Wiedereinreichung nach
Ablehnung ist messbar" als Kriterium arbeitet mit rund 59 Fällen je Quartal.
Ein Schwellwert muss dazu passen, sonst wird Block 1 an einem Rauschen
gemessen.
---
## Was daraus für E2 folgt
**E2 — Zeigt die Absenderansicht Rohzahlen oder eine Einordnung gegen den
Portaldurchschnitt?**
**Empfehlung: Rohzahlen.** Begründung:
1. **Kein Bodensatz, den eine Einordnung kaschieren müsste.** Unter 25 Aufrufen
liegen 2 % (Presseecho) beziehungsweise 0 % (Businessportal24) der
Meldungen. Die Befürchtung aus der Roadmap — „fällt die Verteilung schwach
aus, werden Rohzahlen zum Nachteil" — tritt nicht ein.
2. **Die Verteilungen sind nahezu symmetrisch.** Mittelwert zu Median: 118,6 zu
98 und 214,4 zu 200. Es gibt keine Handvoll Ausreißer, die den Schnitt nach
oben zieht und die Mehrheit darunter zurücklässt.
3. **Eine Einordnung gegen den Portaldurchschnitt wäre sogar falsch.** Wegen
der Wachstumskurve oben vergleicht sie eine zwei Tage alte Meldung gegen
einen Schnitt, der drei Monate alte Meldungen enthält — jede frische
Veröffentlichung sähe wie ein Misserfolg aus. Eine Einordnung müsste
zwingend altersgleich sein, und dann ist sie deutlich aufwendiger als der
Nutzen rechtfertigt.
4. **Ein portalübergreifender Durchschnitt verbietet sich ohnehin.** Die
Mediane unterscheiden sich um den Faktor zwei (98 gegenüber 200). Jede
Einordnung müsste je Portal geführt werden.
Wenn später doch eine Einordnung gewünscht ist, dann als **Perzentil-Angabe
innerhalb der eigenen Alterskohorte** („liegt über 60 % der Meldungen
vergleichbaren Alters auf diesem Portal") — nicht als Vergleich gegen einen
Durchschnitt. Die Kohortenlogik dafür steht bereits im Report.

View file

@ -0,0 +1,375 @@
# Bestandsaufnahme Code — Stand 01.09.2026
**Zweck:** Ausgangslage vor der Umsetzung von `roadmap-2026.md`. Was ist gesund,
was ist kaputt, was muss vor dem ersten Roadmap-Arbeitspaket geradegezogen
werden.
**Prüfumfang:** 348 PHP-Dateien / ~33.900 Zeilen in `app/`, 108 Migrationen,
201 Testdateien, dazu der gesamte noch nicht committete Arbeitsstand
(41 geänderte + 22 neue Dateien aus den Blöcken „Suchtabelle/FULLTEXT-Umzug",
„410-Tombstone", „API-Portal-Zuordnung", „Bounce-Import").
> **Bearbeitungsstand 01.09.2026:** Abschnitt 7 ist vollständig abgearbeitet.
> Was genau geändert wurde, welche Vorschläge abgewandelt oder verworfen wurden
> und welche Entscheidung noch offen ist, steht in
> **[`umsetzung-protokoll.md`](umsetzung-protokoll.md)**.
>
> **Eine Korrektur an diesem Bericht:** Befund **L6** war falsch — siehe unten.
---
## 1 Werkzeugstand
| Prüfung | Befehl | Ergebnis |
|---|---|---|
| Tests | `php artisan test --compact` | **1479 grün**, 1 übersprungen, 6472 Assertions, 121 s |
| Codestil | `vendor/bin/pint --test` | **passed** |
| Statische Analyse | `vendor/bin/phpstan analyse` | **fehlgeschlagen — 390 Fehler außerhalb der Baseline** |
Die Testabdeckung ist gut und der neue Arbeitsstand ist mitgetestet
(`GoneTombstoneTest`, `PressReleaseSearchIndexTest`, `ApiPortalResolutionTest`,
`FixApiPortalDuplicatesTest`, `ImportNewsletterBouncesTest`,
`OnlineSchemaChangeCommandTest`). Die statische Analyse dagegen ist faktisch
außer Betrieb — siehe P1, das ist der Befund mit dem größten Hebel.
---
## 2 Werkzeug & Prozess
### P1 — Statische Analyse liefert seit Monaten nur noch Rauschen
**Symptom:** 390 Fehler außerhalb der Baseline. Darunter Dutzende Meldungen der
Art „Strict comparison using === between string and
`App\Enums\PressReleaseStatus::Published` will always evaluate to false"
(u. a. `app/Models/PressRelease.php:507`, `app/Services/PressRelease/BoostService.php:45`,
`app/Listeners/ProcessStripeWebhook.php:424`). Zur Laufzeit funktioniert dieser
Code — die Meldungen sind falsch.
**Ursache:** Larastan löst **überhaupt keine Casts auf**. Nachgewiesen mit
`PHPStan\dumpType()`:
```
$pressRelease->status → string (erwartet: PressReleaseStatus)
$pressRelease->portal → string (erwartet: Portal)
$pressRelease->published_at → string|null (erwartet: Carbon|null)
```
Grund: Alle 49 Models deklarieren ihre Casts über die `casts()`-**Methode**
(Laravel-11-Stil). Laravel führt diese Methode erst zur Laufzeit mit
`$this->casts` zusammen (`HasAttributes.php:206`), Larastan instanziiert Models
aber mit `newInstanceWithoutConstructor()` und sieht deshalb nichts. Für genau
diesen Fall gibt es den Schalter `parseModelCastsMethod`, dessen Larastan-Default
`false` ist (`vendor/larastan/larastan/extension.neon:34`) und der in
`phpstan.neon` nicht gesetzt ist.
**Nachgewiesene Wirkung des Schalters:** mit `parseModelCastsMethod: true`
lösen alle drei Typen oben korrekt auf, die Fehlerzahl fällt von 390 auf 266.
**Zweitursache:** Die Baseline (`phpstan-baseline.neon`, 127 Einträge) ist seit
dem 12.05.2026 unverändert — knapp vier Monate Drift. 39 ihrer Einträge greifen
inzwischen ins Leere (`ignore.unmatched`).
**Drittursache:** `composer analyse` existiert, läuft aber in keiner Pipeline.
`.github/workflows/lint.yml` ruft nur Pint auf, `tests.yml` nur die Tests. Nichts
hält die Analyse grün.
**Schritte**
1. `parseModelCastsMethod: true` in `phpstan.neon` setzen.
2. Der Rest der 266 Meldungen ist überwiegend echtes Rauschen ohne Codefehler:
84 × `property.notFound` (`withCount`-Aliasse wie `recent_releases_count`,
Relationen ohne Generic), 50 × `method.notFound` (eigene Scopes wie
`forPortalValues()`, `visibleInArchive()` ohne `@method`-Annotation),
35 × `nullsafe.neverNull`. Das lässt sich mit `@method`-Annotationen auf den
Scopes und `/** @return Builder<static> */` an den Scope-Methoden
substanziell senken, ohne eine Zeile Logik anzufassen.
3. Baseline **danach** neu erzeugen, nicht vorher — sonst zementiert sie die
falschen Cast-Meldungen.
4. `composer analyse` in `tests.yml` aufnehmen. Ohne Gate driftet die Baseline
binnen weniger Wochen erneut.
---
## 3 Befunde — hoch
### H1 — Deploy-Fenster: bis zu 60 s SQL-Fehler in der Suche
`app/Models/PressReleaseSearchIndex.php:75` — `isAvailable()` cacht die Antwort
eine Minute:
```php
return Cache::remember('press-release-search.available', now()->addMinute(), …);
```
Solange `false` gecacht ist, greift der Rückfallpfad
(`PressReleaseSearchIndex.php:110`) und setzt `whereFullText()` **auf
`press_releases`** ab. Läuft in diesem Fenster die Migration
`drop_fulltext_indexes_from_press_releases`, existiert der Index dort nicht mehr
→ MySQL-Fehler 1191 („Can't find FULLTEXT index matching the column list") →
500er auf jeder Suche der Web-Seite und der Admin-PM-Liste, bis der Cache-
Eintrag abläuft.
**Fix:** In `up()` beider Such-Migrationen (`create_press_release_search_table`
und `drop_fulltext_indexes_from_press_releases`)
`Cache::forget('press-release-search.available')` aufrufen. Ergänzend den
Rückfallpfad in `constrain()` gegen `QueryException` absichern und im Fehlerfall
auf die Suchtabelle umschalten.
### H2 — Newsroom-Entfernung flusht den kompletten Seiten-Cache N-mal
`app/Services/Company/CompanyRemovalService.php:61-71` lädt **alle**
Pressemitteilungen der Firma mit `->get()` in den Speicher und ruft je Zeile
`PressReleaseService::markGone()`. Diese Methode flusht in
`PressReleaseService.php:495` über `WebCaches::forgetPublicPages()` jedes Mal:
* `WebPageCache::flush()` → Generations-Zähler +1 → **sämtliche** `web.`-Seiten-Caches verworfen,
* `WebCaches::forgetSitemaps()` → alle Sitemap-Caches je Host.
Bei einer Firma mit 300 Meldungen sind das 300 vollständige Cache-Invalidierungen
in einer Schleife, gefolgt von einer 301. in Zeile 73. Jede davon zwingt die
gesamte öffentliche Seite in den Neuaufbau — ein selbst ausgelöster
Cache-Stampede auf einer Aktion, die aus dem Admin heraus mit einem Klick kommt.
Dazu der unbegrenzte `->get()`.
**Fix:** `markGone()`/`restoreFromGone()` um einen Parameter erweitern, der den
Flush unterdrückt (oder eine interne `markGoneWithoutFlush()`), über
`chunkById()` statt `get()` iterieren und **einmal** am Ende flushen. Zeile 73
macht das ohnehin schon.
### H3 — `restoreFromGone()` aktiviert Firmen, die vorher deaktiviert waren
`app/Services/Company/CompanyRemovalService.php:90-93` setzt beim Aufheben
bedingungslos `is_active = true`. `markGone()` (Zeile 46-49) setzt vorher
bedingungslos `is_active = false`, ohne den Ausgangswert zu sichern.
War die Firma **vor** der 410-Entfernung bereits deaktiviert — im Bestand der
Normalfall bei Karteileichen —, ist sie nach dem Aufheben aktiv und taucht in
Newsroom-Übersicht und Sitemap auf. Die UI verspricht ausdrücklich
Umkehrbarkeit; das ist keine.
**Fix:** Ausgangszustand mitschreiben (z. B. Flag in `activity_log`-Meta oder
eine Spalte `was_active_before_gone`) und beim Aufheben zurücksetzen. Minimal:
`is_active` beim Aufheben gar nicht anfassen und den Redakteur entscheiden
lassen.
### H4 — `no_export` bleibt nach dem Aufheben einer PM-Entfernung stehen
`PressReleaseService::markGone()` (Zeile 480-490) setzt `no_export = true`.
`restoreFromGone()` (Zeile 511-519) setzt nur `gone_at` zurück. Die Mitteilung
ist danach zwar wieder freigegeben, bleibt aber dauerhaft aus allen Exporten
ausgeschlossen — ohne dass das irgendwo sichtbar wäre. Dieselbe Asymmetrie wie
H3, im selben Arbeitspaket.
**Fix:** Entweder `no_export` mit zurücksetzen (dann den Vorzustand sichern, weil
die Spalte auch redaktionell gesetzt sein kann) oder `no_export` in `markGone()`
gar nicht setzen — `gone_at` filtert bereits über `scopeVisibleInArchive()`.
---
## 4 Befunde — mittel
### M1 — Generations-Zähler wird prozessweit eingefroren
`app/Support/WebPageCache.php:74`
```php
return self::$generation ??= (int) Cache::get(self::GenerationKey, 0);
```
Unter PHP-FPM stimmt das (ein Prozess = ein Request). In langlebigen Prozessen —
`queue:work`, ein späterer Octane-Betrieb — wird der Wert beim ersten Aufruf
eingefroren und nie wieder gelesen. Konkrete Folge:
`BoostService::forgetPlacementCaches()` (Zeile 230-240) baut seine
`Cache::forget()`-Keys über `WebPageCache::key()`. Läuft das in einem Worker mit
veralteter Generation, löscht es Keys einer alten Generation, während der
aktuelle Eintrag stehen bleibt — die Boost-Platzierung erscheint bis zum
TTL-Ablauf nicht.
**Fix:** Memoisierung an die Request-/Job-Lebensdauer binden (Reset in einem
`JobProcessing`-Listener) oder ganz streichen und pro Aufruf lesen — bei Redis
ist das ein GET.
### M2 — Der Generations-Zähler liegt im Namensraum, den er invalidiert
`WebPageCache::GenerationKey = 'web.generation'` (Zeile 39). Der Zähler ist genau
der eine `web.`-Key, der einen Flush überleben muss. Der Code fängt einen Verlust
zwar ab (Zeile 88-94), aber nur innerhalb desselben Prozesses. Umbenennen auf
etwas außerhalb des Präfixes, z. B. `page-cache:generation`.
### M3 — Wasserstandsmarke ist nur chunkweise idempotent
`app/Console/Commands/AggregatePressReleaseViews.php:90-101`: erst laufen alle
`increment()` des Chunks, danach wird die Marke geschrieben. Bricht der Prozess
dazwischen ab (OOM, Deploy, `kill`), sind die bereits verbuchten Releases beim
nächsten Lauf **ein zweites Mal** dran. Der Klassenkommentar (Zeile 26-27)
behauptet ausdrücklich das Gegenteil („ein doppelter Lauf zählt nichts doppelt").
**Fix:** Increments und `AppSetting::setValue()` je Chunk in eine
`DB::transaction()`. Kommentar entsprechend präzisieren.
### M4 — `press_release_events` wächst unbegrenzt
Die Tabelle ist seit dem Umbau die Quelle jedes Seitenaufrufs (`PressRelease::recordDeferredView()`),
liegt laut Migrationskommentar bei knapp einer Million Zeilen und wächst mit
jedem Request weiter. Im Scheduler (`routes/console.php`) gibt es Aufräum-Tasks
für Magic Links, Snapshots und Entwürfe — für dieses Log keinen.
Nach der Aggregation werden die Aufruf-Ereignisse nur noch für die geplante
Absenderansicht (Roadmap 1c.1, „Aufrufe im Verlauf") gebraucht. Das legt einen
Aufbewahrungszeitraum nahe, keine Ewigkeit.
**Fix:** Retention festlegen (Vorschlag: 13 Monate, damit Vorjahresvergleiche
möglich bleiben) und einen `press-releases:purge-events`-Task ergänzen, der
ausschließlich Ereignisse **unterhalb der Wasserstandsmarke** löscht. Diese
Entscheidung gehört vor 1c.1 — sie bestimmt, wie weit die Verlaufsansicht
zurückreicht.
### M5 — Migration umgeht den AppSetting-Cache
`database/migrations/2026_08_25_120000_seed_press_release_view_watermark.php`
schreibt mit `DB::table('app_settings')->updateOrInsert()` und räumt den
`rememberForever`-Eintrag `app-setting.press_release_views.aggregated_through_id`
nicht ab. Lief der Scheduler-Task im Deploy-Fenster einmal vor der Migration, hält
der Cache die alte, niedrigere Marke und der nächste Lauf zählt das Fenster
dazwischen doppelt. Ein `Cache::forget()` am Ende von `up()` genügt.
### M6 — `$companyId` in der Firmen-Bearbeitung ist nicht `#[Locked]`
`resources/views/livewire/admin/companies/edit.blade.php:19` —
`public int $companyId;` ohne Attribut, obwohl das Gegenstück in
`admin/press-releases/edit.blade.php:34` `#[Locked] public int $id` verwendet.
`save()`, `deleteCompany()` und die neuen `markGone()`/`restoreGone()` arbeiten
alle auf diesem vom Client manipulierbaren Wert. Die `authorize('companies:write')`-
Prüfung greift, das Ausmaß ist also begrenzt — aber die Aktion trifft dann eine
andere Firma als die im Formular angezeigte, und die Inkonsistenz zum
PM-Formular ist unnötig. `#[Locked]` ergänzen.
### M7 — Legacy-API kann `portal = null` schreiben
`app/Http/Controllers/Api/Legacy/LegacyPressReleaseController.php:111`
```php
$pressRelease->portal = $this->resolveRequestedPortal(…)?->value;
```
`resolveRequestedPortal()` (`app/Http/Controllers/Api/Concerns/ResolvesRequestedPortal.php:20`)
gibt `null` zurück, wenn weder Parameter noch Portal-Kontext noch
`$company->portal` greifen. Die Spalte ist ein `ENUM … NOT NULL DEFAULT 'both'`
(`create_press_releases_table.php:19-22`) — das Ergebnis ist ein SQL-Fehler und
damit ein 500er statt einer verwertbaren Fehlermeldung für den Alt-Client.
Praktisch schwer zu erreichen, aber der `?->`-Operator sagt, dass der Autor den
Fall für möglich hält. Entweder auf `Portal::Both` defaulten oder mit 422
antworten.
### M8 — Passwort in der pt-osc-Optionsdatei ist nicht maskiert
`app/Console/Commands/OnlineSchemaChange.php:192-196` schreibt
```php
sprintf("[client]\nuser=%s\npassword=%s\n", …)
```
In einer my.cnf beginnt `#` einen Kommentar, führende/abschließende Leerzeichen
werden verschluckt. Ein DB-Passwort mit `#` wird an dieser Stelle abgeschnitten
und pt-osc scheitert mit einem Authentifizierungsfehler, dessen Ursache man
nirgends sieht. Wert in doppelte Anführungszeichen setzen und `"`/`\` escapen.
Die Umgehung der Kommandozeile (Zeile 81-83) ist ansonsten richtig gelöst,
ebenso `tempnam()` + `chmod(0600)` vor dem Schreiben.
### M9 — Bounce-Suppression erfasst nur `users`, nicht `newsletter_subscriptions`
`app/Console/Commands/ImportNewsletterBounces.php:58` sucht ausschließlich über
`User::query()->where('email', …)` und protokolliert alles Übrige als „Kein
User-Konto". Die Tabelle `newsletter_subscriptions` führt eine eigene
Empfängerliste mit eigenem `unsubscribed_at` und bleibt unberührt.
Die Roadmap macht „Bounce-Suppression produktiv" zum harten Blocker für 1b.6,
1d.3 und 3.2. Solange die zweite Liste nicht mitgeführt wird, ist diese
Voraussetzung nicht erfüllt — mit unmittelbarer Wirkung auf die
Absender-Reputation und damit auf die Zustellbarkeit der Magic Links, die die
Roadmap selbst als das eigentliche Risiko benennt.
**Fix:** `newsletter_subscriptions` in denselben Lauf aufnehmen und ein
gemeinsames Suppression-Konzept festlegen (eigene Tabelle oder gemeinsames
Merkmal), bevor Block 1b/1d gebaut wird.
---
## 5 Befunde — niedrig / Aufräumen
| Nr. | Fundstelle | Befund |
|---|---|---|
| L1 | `app/Services/PressRelease/ProofPdfRenderer.php:211` | `$image === null` ist immer falsch — `$embed` ist bereits null, wenn `$image` null ist |
| L2 | `app/Services/PressRelease/ProofPdfRenderer.php:946` | `$width < 1 \|\| $height < 1` nach `imagesx()`/`imagesy()` immer falsch |
| L3 | `app/Services/Billing/ManualInvoiceService.php:78` | `current_period_end === null` — laut Schema nicht nullable, Prüfung tot |
| L4 | `app/Models/Company.php:178` | linke Seite von `&&` immer wahr |
| L5 | `app/Models/PressRelease.php:415` | `null`-Arm im `match ($this->portal)` unerreichbar (Spalte NOT NULL) |
| ~~L6~~ | `app/Listeners/ProcessStripeWebhook.php:56-57` | **FALSCHER BEFUND.** Beide werden in den Zeilen 123, 149, 257 und 293 gelesen. Die PHPStan-Meldung ist ein Folgefehler: Cashier typisiert `findBillable()` gegen den *Trait* `Billable`, PHPStan hält `if (! $user instanceof User) return;` deshalb für immer wahr und alles danach — inklusive der Lesezugriffe — für unerreichbar. Nichts entfernt. |
| L7 | `app/Console/Commands/ReindexPressReleaseSearch.php:66` | `upsert()` mit 500 `mediumText`-Zeilen kann `max_allowed_packet` reißen — `--chunk` beim Erstlauf niedriger ansetzen oder im Kommentar vermerken |
| L8 | `resources/views/livewire/admin/press-releases/index.blade.php:301` | `fn($inner)` statt `fn ($inner)` — Pint erfasst Blade nicht, deshalb unbemerkt |
| L9 | `app/Console/Commands/ImportNewsletterBounces.php:58` | Adressen werden kleingeschrieben verglichen; unter MySQL (`utf8mb4_*_ci`) egal, unter SQLite im Test nicht — verdeckt mögliche Lücken |
Positiv und ausdrücklich erwähnt: keine Roh-SQL-Stelle nimmt Nutzereingaben
entgegen (alle `selectRaw`/`whereRaw` sind parametrisiert oder erhalten
Literale), alle `{!! !!}`-Ausgaben in `resources/views/web/` speisen sich aus
Sprachdateien oder laufen über `PressReleaseHtmlSanitizer::render()`, und die
neuen Admin-Aktionen prüfen durchgängig `authorize()`.
---
## 6 Voraussetzungen, die die Roadmap braucht und die noch fehlen
Reine Feststellungen aus dem Code, keine neuen Arbeitspakete.
1. **`companies.email` hat keinen B-Tree-Index.** Vorhanden sind nur
`(portal, slug)`, `(portal, is_active)`, `(created_at, id)` und ein
FULLTEXT über `(name, email, slug)`. Block 1a schlüsselt die
Newsroom-Konsolidierung ausdrücklich über die Firmen-E-Mail, und 1a.4 will
diesen Lookup bei **jeder** eingehenden API-Meldung ausführen. Ohne Index ist
das ein Full Scan über 70 MB pro Einreichung. Der Index selbst ist billig:
`ADD INDEX` läuft auch mit vorhandenem FULLTEXT-Index `INPLACE` — nur
`ADD COLUMN` erzwingt den COPY-Rebuild.
2. **Aufbewahrung von `press_release_events` ist offen** (M4). Sie bestimmt, wie
weit die Verlaufsansicht aus 1c.1 zurückreicht, und muss vor 0.3
(„Bestandsaufnahme Aufrufzahlen: Median und Verteilung der letzten drei
Monate") entschieden sein.
3. **Bounce-Suppression ist unvollständig** (M9) — harter Blocker für 1b.6, 1d.3
und 3.2.
4. **Der `gone_at`-Pfad ist nicht sauber umkehrbar** (H3, H4). Roadmap 1b.5
(„fällt ein Newsroom auf null Meldungen, verschwindet die öffentliche Seite
mit") baut direkt darauf auf.
5. **`ReleaseDetailPageData` ist ungecacht, `NewsroomDetailPageData` gecacht**,
und der Newsroom zahlt seit dem 410-Umbau einen zusätzlichen Slug-Lookup
**vor** dem Cache (`NewsroomDetailPageData.php:34-40`). Das ist bewusst so
entschieden und im Code begründet; sobald 1b/1c den Newsroom zum Produkt
machen und die Aufrufzahlen steigen, gehört die Entscheidung noch einmal auf
den Tisch — eine gecachte Menge entfernter Slugs mit kurzer TTL erreicht
dasselbe ohne Query pro Aufruf.
---
## 7 Empfohlene Reihenfolge
**Vor dem nächsten Deploy** (betrifft laufenden Betrieb):
1. H1 — `Cache::forget()` in die beiden Such-Migrationen. Sonst kostet der
FULLTEXT-Umzug eine Minute 500er auf jeder Suche.
2. H2 — Flush aus der Schleife holen, `chunkById()` statt `get()`.
3. H3 + H4 — Umkehrbarkeit von `gone_at` geradeziehen, jeweils mit Test.
4. M5 — `Cache::forget()` in die Watermark-Migration.
**Direkt danach** (Werkzeug, größter Hebel auf alles Weitere):
5. P1 — `parseModelCastsMethod: true`, Scope-Annotationen, Baseline neu,
`composer analyse` ins CI.
**Vor dem ersten Roadmap-Arbeitspaket:**
6. M9 — Bounce-Suppression vervollständigen (Blocker für 1b.6/1d.3/3.2).
7. M4 — Aufbewahrung `press_release_events` entscheiden (blockiert 0.3/1c.1).
8. Index auf `companies.email` (Voraussetzung für 1a).
**Laufend, ohne Eile:**
9. M1, M2, M3, M6, M7, M8 sowie L1–L9.

View file

@ -0,0 +1,64 @@
VERP im Vollausbau (eine eigene Adresse pro Empfänger) brauchst du nicht. Ein einzelnes Bounce-Postfach plus ein eigener Header reichen.
## Der Aufbau
**1. Return-Path trennen**
Envelope-Sender auf `bounce@presseecho.de`, `From` bleibt `redaktion@`. Dann landen Rückläufer in einem eigenen Postfach statt in deinem Posteingang. Das ist eine Zeile, global über einen `MessageSending`-Listener:
```php
$event->message->returnPath('bounce@presseecho.de');
```
**2. Eigenen Header mitschicken**
```php
$event->message->getHeaders()->addTextHeader('X-Pk-Ref', "pm:{$pm->id}:contact:{$contact->id}");
```
Das ist der eigentliche Trick. Ein RFC-3464-DSN besteht aus drei Teilen, und der dritte trägt die Header der Originalnachricht, damit der Absender den Bounce dem Versand zuordnen kann. Dein Header kommt also zurück. Du weißt damit exakt, welche PM an welchen Kontakt ging, ohne aus dem Bounce-Fließtext Rückschlüsse ziehen zu müssen. Der Nutzen von VERP, ohne dessen Adressverwaltung.
**3. Abholen: `webklex/laravel-imap`**
Das ist der De-facto-Standard und wird aktiv gepflegt (letztes Update Juli 2026). Es bringt sowohl die native php-imap-Erweiterung als auch ein eigenes IMAP-Protokoll mit, du bist also nicht darauf angewiesen, dass Ploi dir die Extension kompiliert. IDLE brauchst du nicht, ein Scheduler alle zehn Minuten auf `UNSEEN` reicht bei zwei bis drei Mails am Tag.
**4. Parsen**
Kein Regex auf den Body. Der mittlere Teil `message/delivery-status` enthält die Felder strukturiert, Parser müssen nicht raten. Du brauchst genau vier Felder:
| Feld | Bedeutung |
|---|---|
| `Final-Recipient` | `rfc822; user@example.com` — die Adresse |
| `Action` | `failed` = Bounce, `delayed` = temporär, `delivered`/`relayed` = ignorieren |
| `Status` | `5.1.1` — Klasse 5 permanent, Klasse 4 temporär |
| `Diagnostic-Code` | `smtp; 550 ...` — nur für dein Log |
Feldnamen sind case-insensitive, `Status:` und `status:` sind beide gültig. Maßgeblich ist `Action`, nicht der Statuscode allein.
## Zu den Paketen
Fertige Bounce-Parser für PHP gibt es, aber keiner davon ist etwas, worauf ich ein Produktivsystem stellen würde:
| Paket | Einschätzung |
|---|---|
| `cfortune/PHP-Bounce-Handler` | Parst multipart/report nach RFC 1892 und 1894 — das sind die Vorgänger von 3461/3464. Stammt aus der phpclasses-Ära, kein Composer-Paket, keine Namespaces. Als Nachschlagewerk für Statuscodes brauchbar, als Fundament nicht. |
| `galata90/php-mail-bounce-handler` | Prüft IMAP-Postfach oder eml-Dateien und versucht bei fehlerhaften Bounces heuristisch den Status zu ermitteln. Näher dran, aber ebenfalls wenig gepflegt und setzt für den IMAP-Modus die php_imap-Extension voraus. |
| `molnix/bounced-mail-manager` | Liest IMAP, erkennt Bounces, arbeitet mit eigenen Custom-Headern in ausgehenden Mails — konzeptionell genau dein Ansatz. Aber ein Ein-Personen-Paket mit sehr geringer Verbreitung. Für einen Solo-Betreiber ist eine Abhängigkeit, die niemand außer dir benutzt, selbst ein Wartungsrisiko. |
Mein Vorschlag: `webklex/laravel-imap` für den Transport, dazu ein eigener DSN-Parser von etwa 100 bis 150 Zeilen. Sobald das MIME sauber zerlegt ist, ist der DSN-Teil banal — Teil mit Content-Type `message/delivery-status` suchen, an Leerzeilen in Recipient-Blöcke splitten, `Key: Value` einlesen. Die genannten Pakete nimmst du als Testkorpus, nicht als Dependency; die eml-Sammlungen darin sind für deine Tests mehr wert als der Code.
## Zweiter Pfad, der ohne IMAP läuft
Nicht jeder Fehlschlag erzeugt eine Rückläufermail. Manche Server weisen direkt während der SMTP-Verbindung mit 5xx ab, statt einen DSN zu erzeugen — die Behandlung muss beide Wege abdecken. Bei dir wirft Symfony dann eine `TransportException` und der Queue-Job schlägt fehl. Wenn du deine Mails über die Queue verschickst (was du solltest), fängst du in `failed()` einen Teil der Fälle sofort ab, ohne dass IMAP beteiligt ist. Das ist billiger als der IMAP-Pfad und sollte zuerst gebaut werden.
## Was du im Parser nicht vergessen darfst
**Zustellbestätigungen aussortieren.** Ein DSN mit `Action: delivered` oder `relayed` ist kein Bounce. Wer nur nach `Final-Recipient` greift, blockt Adressen, die funktioniert haben.
**Nicht-DSNs nicht raten lassen.** Handgeschriebene Ablehnungen und alte MTAs liefern Freitext. Was der Parser nicht sicher zerlegt, kommt in einen Ordner `Unparsed` und in eine Admin-Liste. Zwei Mails pro Woche manuell einordnen ist akzeptabel, eine falsch geblockte Adresse nicht.
**Idempotenz über `Message-ID`.** Wenn der Job zwischen Verarbeitung und Verschieben abbricht, liest der nächste Lauf dieselbe Mail erneut. Bounce-Zähler brauchen deshalb eine Unique-Prüfung auf die Message-ID des Rückläufers, sonst zählst du einen Soft Bounce dreimal und sperrst zu früh.
**Der Circuit Breaker aus dem letzten Durchgang bleibt Pflicht** — bei Eigenversand über Hetzner sogar dringender als bei einem Dienstleister, weil dort ein IP-Reputationsproblem sichtbar wird und du es hier selbst merken musst.
Ein Punkt noch, den du kennen solltest, auch wenn die Entscheidung steht: Beim Selbstversand ohne Dienstleister ist der Bounce-Parser nicht dein Hauptrisiko, sondern der Anteil an Mails, die stumm im Spam-Ordner verschwinden. Dafür siehst du weder Bounce noch Exception. Wenn du dir später Sichtbarkeit darauf verschaffen willst, ist ein DMARC-Report-Postfach der billigste Einstieg — gleicher IMAP-Mechanismus, anderes Format.

View file

@ -0,0 +1,257 @@
# Roadmap Nutzergewinnung und Rückgewinnung
**Stand:** 01.09.2026
**Gültig für:** presseecho.de, businessportal24.com, Hub pressekonto.com
**Zweck:** Reihenfolge, Abhängigkeiten und Abbruchbedingungen für den Ausbau nach dem Relaunch
---
## Ausgangsbefund
Das Portal hat Traffic, aber der Traffic besteht überwiegend aus Lesern fremder Meldungen. Zwischen "Meldung wird gelesen" und "jemand bucht" existiert keine Strecke. Alles, was den Trichter oben verbreitert, wirkt erst, wenn er unten geschlossen ist.
Der größte ungenutzte Bestand liegt bereits im System: Firmen, deren Meldungen über connektar eingehen und die kein Konto haben (openPR ist kein zweiter Kanal, sondern der Presse-API-Verteiler von connektar — derselbe Zulauf). Sie sind erfasst, ihre Kontaktadresse liegt vor, sie werden bereits transaktional benachrichtigt, und die Magic-Link-Verifikation dieser Adresse ist gebaut.
**Grundsatz für die Reihenfolge:** erst messen, dann den Trichter unten schließen, dann oben verbreitern, dann Partner.
---
## Aufwandsskala
| Kürzel | Bedeutung |
|---|---|
| S | ein Abend bis ein Tag |
| M | mehrere Tage, in Etappen lieferbar |
| L | mehrere Wochen oder abhängig von Dritten |
---
## Block 0 — Messbarkeit
Ohne definierte Konversionspunkte ist keiner der folgenden Blöcke bewertbar. Die Messung läuft nicht über Umami, sondern über eigene Ereignisdaten. Grund: Die zu messende Strecke führt über zwei Domains und einen Magic Link, und ein cookiefreies Analytics-Tool verliert die Sitzung genau an der Stelle, an der die Konversion passiert. Claim, Registrierung und Buchung sind außerdem keine Seitenaufrufe, sondern Zustandsänderungen in der Datenbank, bei denen die Identität ohnehin bekannt ist.
**Rollenteilung:** Ereignistabelle für den Konversionstrichter (Block 1). Umami für Seitenaufrufe, Suchbegriffe und Herkunft aus der Suche (Block 2). Keine Vermischung.
### Arbeitspakete
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| ~~0.1~~ | Basiswert Aufrufzahlen — **abgeschlossen 21.09.2026**: Kohorte „28–55 Tage" am Stichtag 21.09. = Median **122** (Presseecho, n 703) / **314** (Businessportal24, n 727); Fensterverteilung vom 01.09. und 21.09. in `basiswerte-block0.md` | S | keine |
| ~~0.2~~ | Basiswert Ablehnungsquote je Quelle — **erhoben 01.09.2026** (2,4 %, `press-releases:rejection-rate`, `basiswerte-block0.md`) | S | keine |
| ~~0.3~~ | Tabelle `conversion_events` — **gebaut, live seit 17.09.2026** (Protokoll Teil 2) | S | keine |
| ~~0.4~~ | Herkunft auf dem Magic-Link-Token — **gebaut** (`ConversionSource`, Protokoll Teil 2) | S | 0.3 |
| ~~0.5~~ | Schreibstellen an den Zustandsänderungen — **gebaut**, letzte Stelle mit Block 1b (Protokoll Teil 2 und 3) | M | 0.3, 0.4 |
| ~~0.6~~ | Trichter je Quelle und Zeitraum — **gebaut** (`conversion:funnel`) | S | 0.5 |
| ~~0.7~~ | Aufbewahrungsfrist für Ereignisdaten festlegen und in der Datenschutzerklärung führen — **395 Tage seit 01.09.2026; Datenschutz-Abschnitt 5a am 21.09.2026 eingebaut** (gilt für Aufrufe, Crawler- und Feed-Abrufe, Konversionsschritte) | S | 0.3 |
### Konversionsleiter
Fünf Stufen statt einer Liste gleichrangiger Punkte. Nur so ist erkennbar, an welcher Stelle es hakt.
| Stufe | Ereignis | Was ein Abbruch hier bedeutet |
|---|---|---|
| 1 | Mail-Link geklickt | Betreff oder Anlass trägt nicht |
| 2 | Magic Link eingelöst | Der Verwaltungszugang wird nicht verstanden |
| 3 | Newsroom bearbeitet | Kein Anreiz, das Profil zu pflegen |
| 4 | Konto angelegt | Der Nutzen des Kontos ist nicht erkennbar |
| 5 | Erste Buchung | Preis oder Angebot passt nicht |
Die Wiedereinreichung nach Ablehnung gehört nicht in diese Leiter. Sie misst, ob die Infoseite wirkt, und wird gegen den Basiswert aus 0.2 separat geführt.
### Quellen
Jeder Mail-Link trägt einen Herkunftswert, der auf dem Token gespeichert wird und damit Domainwechsel, Mailclient und Weiterleitung überlebt.
| Wert | Herkunft |
|---|---|
| `mail_publish` | Veröffentlichungsbenachrichtigung |
| `mail_reject` | Ablehnungsbenachrichtigung |
| `profile_claim` | Hinweis auf der Newsroom-Seite |
| `organic` | keine zugeordnete Herkunft |
### Reihenfolge
**Sofort, ohne Vorarbeit:** 0.1 und 0.2. Beides sind Datenbankabfragen. Die Verteilung der Aufrufe entscheidet E2, also ob die Absenderansicht Rohzahlen oder eine Einordnung zeigt. Die Ablehnungsquote je Quelle wird ohnehin für das connektar-Gespräch gebraucht.
**Vor der ersten Maßnahme aus Block 1:** 0.3 bis 0.5. Herkunftsdaten lassen sich nicht rückwirkend erzeugen. Wer hier zu spät anfängt, sieht später Buchungen, aber nicht ihren Ursprung.
**Basiswerte müssen vor dem Livegang von Block 1 stehen.** Ohne Vergleichspunkt wird jede spätere Bewertung eine Vermutung.
### Datensparsamkeit
Die Ereignistabelle referenziert Firmen- und Meldungs-IDs, keine Personen und keine Klartextadressen. Damit bleibt sie interne Statistik. Die Aufbewahrungsfrist wird in 0.7 gleich mitdefiniert und nicht nachgetragen.
**Abbruchbedingung:** keine. Block 0 ist Voraussetzung, nicht Wette.
---
## Block 1 — Konversion aus Bestand
Der Kern. Wandelt vorhandenen Zulauf in Konten und Kunden, ohne neuen Traffic zu benötigen.
### 1a Newsroom-Konsolidierung
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| ~~1a.1~~ | Trockenlauf-Bericht: wie viele Firmen fallen bei Zusammenführung über die Firmen-E-Mail zusammen, welche Adressen tragen mehrere Firmennamen — **live 11.09.2026** (`companies:merge-report`) | S | keine |
| ~~1a.2~~ | Sammeladressen-Erkennung: Adresse mit mehr als [Schwellwert] abweichenden Firmennamen wird markiert und von der automatischen Zusammenführung ausgenommen — **live 11.09.2026** (`companies:classify-addresses`, `company_email_groups`, E1 = 3) | S | 1a.1 |
| ~~1a.3~~ | Zusammenführung im Bestand mit gespeicherter Ursprungszuordnung, rückabwickelbar — **live 15.09.2026**: ~14 450 Adressen, ~18 100 Firmen aufgegangen, 13 236 Firmen `both`; 6 Slug-Kollisionen offen (`companies:merge`, `companies:unmerge`, `companies:merges`) | M | 1a.2 |
| ~~1a.4~~ | Laufende Zuordnung neuer API-Meldungen zu bestehendem Newsroom über die Firmen-E-Mail — **gebaut 15.09.2026** (`ApiNewsroomAssignment`), Wirkung nach Deploy über `companies:classify-addresses` messen | M | 1a.2 |
**Regelwerk Zusammenführung**
| Signal | Folge |
|---|---|
| Gleiche E-Mail, gleicher oder ähnlicher Firmenname | automatisch zusammenführen |
| Gleiche E-Mail, deutlich abweichende Firmennamen | nicht zusammenführen, als Sammeladresse markieren |
| Abweichende E-Mail, gleicher Firmenname und Ort | Vorschlag im Admin, keine automatische Aktion |
Schlüssel ist die **Firmen-E-Mail**, nicht die Pressekontaktadresse. Der Pressekontakt ist der Firma untergeordnet und wechselt; die Firmenidentität darf nicht an einer Person hängen. Der Magic Link kann trotzdem an beide gehen, das ist eine Berechtigungs- und keine Identitätsfrage.
**Entschieden 15.09.2026 (1a.3):** Es bleibt die Firma mit den meisten jemals veröffentlichten Meldungen, bei Gleichstand die ältere; leere Stammdaten werden aufgefüllt. Meldungen, Kontakte und Verknüpfungen wandern per `company_id`, jede Bewegung protokolliert (`company_merges`/`company_merge_moves`), rückabwickelbar mit `companies:unmerge`. Die wegfallende Firma wird soft-deleted, ihr Slug führt per 301 weiter. Der Überlebende wird `both`, wenn die Hälften auf verschiedenen Portalen lagen — das ist bei 77 % der Gruppen der Fall (Altsystem: eine Firma je Portal). Slug-Kollisionen werden übersprungen, nicht umbenannt. Details in `umsetzung-protokoll.md` Teil 3.
**Entschieden 15.09.2026 (1a.4):** Die Zuordnung greift im Alt-Vertrag `POST /service/users/me/companies` — connektar bekommt bei gleicher Firmen-E-Mail und gleichem Namen (Namensregel der Klassifizierung) die ID der bestehenden Firma zurück, der Vertrag (201 + Location) bleibt. Abweichende Namen unter einer Adresse bekommen wie bisher eine neue Firma (Agentur, neuer Kunde); nie-veröffentlicht-Adressen werden nie zugeordnet; bei Firma auf dem anderen Portal wird sie `both`, bei fremd belegtem Slug bleibt es beim alten Verhalten. Der API-Nutzer wird `member` der Firma. Nebenbefund: `status = rejected` im Altbestand heißt „offline genommen", nicht „abgelehnt" (83 000 davon mit Aufrufen) — Auswertungen dürfen das nicht als Ablehnung lesen.
Für selbst angelegte Firmendaten trägt der Nutzer die Verantwortung. Für die rückwirkende Zusammenführung im Altbestand trägt sie das Portal, weil dort nicht angelegt, sondern verschmolzen wird. Deshalb 1a.1 vor 1a.3.
### 1b Newsroom als Produkt
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| ~~1b.1~~ | Sichtbarkeitsregel: Newsroom öffentlich erst ab der ersten veröffentlichten Meldung, sonst nur Datensatz — **bestand; 15.09.2026 Rückbau im Cache vervollständigt** | S | keine |
| ~~1b.2~~ | Verwaltungszugang über Magic Link, unabhängig von der öffentlichen Sichtbarkeit — **gebaut 15.09.2026**: `/newsroom/{slug}/verwalten`, Link an die hinterlegte Firmen-E-Mail (maskiert), Herkunft `profile_claim`, Landung auf der Firmenseite; noch unverlinkt (→ 1b.4) | M | 1a.4 |
| ~~1b.3~~ | Kostenlose Profilpflege: Beschreibung, Logo, Bilder, Pressekontakte — **15.09.2026**: Beschreibung durch die Rot-Ebene, reiner Text ohne Links, Stufe 3 der Leiter wird hier geschrieben; **Bilder zurückgestellt** (BALD-Platzhalter) bis die Bild-Kennzeichnung angepasst ist | M | 1b.2 |
| ~~1b.4~~ | Hinweis "Diesen Newsroom verwalten" auf der Profilseite und in der Veröffentlichungsmail — **gebaut 15.09.2026** (E4: Hinweis unten in der Mail; Herkunft `mail_publish` läuft bis auf das Token durch) | S | 1b.2 |
| ~~1b.5~~ | Rückbau: fällt ein Newsroom durch Löschung oder Depublizierung auf null Meldungen, verschwindet die öffentliche Seite mit — **15.09.2026**: alle Admin-Wege (archivieren, Status ändern, löschen) leeren jetzt den Seiten-Cache | S | 1b.1 |
| ~~1b.6~~ | Nicht erreichbare Newsrooms aus der Bounce-Suppression im Admin markieren, nicht beanspruchbar, aus allen Mailstrecken ausgenommen — **gebaut 15.09.2026** inkl. der Suppression selbst: zentrale Sperrliste, Versand-Gate, SMTP-Pfad + Circuit Breaker, IMAP-Abholung mit DSN-Parser, Admin ▸ Mail-Sperrliste. Live, sobald `bounce@pressekonto.com` angelegt und in der `.env` eingetragen ist | S | Bounce-Suppression live |
**Leitplanken Profilpflege**
- Externe Links im Profiltext werden systemseitig gesetzt wie in Meldungen, nicht kundenseitig wählbar. Sonst wird das Profil ein Backlink-Automat.
- Beschreibungstext läuft mindestens durch die Rot-Ebene der bestehenden Prüfung.
- Bilder im Newsroom unterliegen derselben KI-Deklaration und demselben Audit-Log wie Bilder in Meldungen. Logos sind unkritisch, fotorealistische Motive nicht.
- Verwaltungszugang öffnet Profildaten. Veröffentlichte Meldungen bleiben in den bestehenden Änderungspfaden. Kein Bearbeitungszugriff auf Meldungen über den Umweg Profilverwaltung.
### 1c Sichtbarkeit belegen
*Abschnitt durch Anhang 1 (`roadmap-anhang1-2026.md`, 17.09.2026) ersetzt: 1c.0 Crawler-Protokoll, Stufe 1 ohne Google-Link, 1c.4 Search Console intern, 1c.6 KI-Abrufe. Stand dort.*
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| ~~1c.1~~ | Absenderansicht je Meldung: Aufrufe im Verlauf, Indexierungsstatus, Einordnung gegen Portaldurchschnitt — **gebaut 16.09.2026**: Panel „Sichtbarkeit" auf der Meldungsseite, Tagesbalken 30/90 Tage (Ereignisse seit 17.07.2026, davor „keine Messung"), Rohzahlen nach E2; Indexierung in **Variante B** („in der Sitemap seit", Link auf die Google-Suche) — Search-Console-Abgleich (Variante A) bewusst ans Ende geschoben | M | 0.1 |
| ~~1c.2~~ | Zugang zur Ansicht über Magic Link, auch ohne Konto — **16.09.2026**: der Claim-Weg aus 1b.2 trägt jetzt die Meldung (`pr`) bis auf das Token, der Login landet auf ihr; Mail-Hinweis um „Aufrufe einsehen" erweitert, bleibt ein Element (E4) | S | 1b.2 |
| ~~1c.3~~ | Newsroom-Übersicht mit allen Meldungen der Firma und Summenwerten — **16.09.2026**: Firmenseite zeigt alle Meldungen der Firma (vorher nur eigene — Beansprucher sahen eine leere Liste), Aufrufe je Meldung und 30 Tage, Statistik-Panel echt | M | 1a.3, 1c.1 |
### 1d Infoseite und Prüfwerkzeug
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| ~~1d.1~~ | Hilfeseite ohne kommerziellen Anteil — **gebaut 16.09.2026**: `/{edition}/gute-pressemitteilung` (DE/EN), Anlass, Aufbau, Sprache, Prüfablauf, sieben Qualitätsdimensionen offen, Pressebilder; Sitemap + Hilfe-Center | S | keine |
| ~~1d.2~~ | Textbaustein in die Ablehnungsmail — **16.09.2026**: Link auf dem Portal der Meldung, Herkunft `mail_reject`, nichts Kommerzielles (E6) | S | 1d.1 |
| ~~1d.3~~ | Abmeldemöglichkeit für Benachrichtigungen — **16.09.2026**: für Empfänger ohne Konto (Pressekontakt, Firmen-E-Mail) adressbezogener Abmeldelink, Zustand als `Unsubscribed` in der Sperrliste; selbst angeforderte Anmeldelinks gehen weiter durch. Vorher zeigte der Link in API-Benachrichtigungen auf das Konto des Einlieferers | S | Bounce-Suppression live |
| ~~1d.4~~ | Prüfwerkzeug — **E3 entschieden 16.09.2026: kein eigenständiges Werkzeug.** Prüfung nur beim Freigeben, Ergebnis am Entwurf; „Zur Prüfung freigeben" mit Erklärung, was danach passiert; automatische Prüfung je Meldung `max_automatic_attempts` (3), danach geblockt in die Admin-Queue („Prüfung nötig"), Freischalten oder Kommentar, Automatik für die Meldung ab dann aus; Autor erhält beim Block die Mail „wird persönlich geprüft" (Nachtrag 16.09.) | M | 1d.1 |
| ~~1d.5~~ | Bewertung einer bestehenden Meldung über Magic Link — **mit 1c.2 erledigt**: der Beansprucher sieht Ablehnungsgrund, KI-Stufe und Content-Score auf der Meldungsseite | S | 1d.4, 1b.2 |
| ~~1d.6~~ | Partnerbereich am Seitenende als BALD — **16.09.2026** am Ende von 1d.1 | S | 1d.1 |
**Offenlegungsgrenze**
| Ebene | Nach außen | Begründung |
|---|---|---|
| Rot-Kriterien | nur als Kategorie, ohne Detail | wer weiß, wie er durchrutscht, rutscht durch |
| Content-Dimensionen (Aufbau, Konkretheit, Anlass, Zitat, Substanz) | offen erklärbar | Optimierung auf diese Dimensionen ist tatsächliche Verbesserung |
| Gewichtung, Schwellen, Kalibrierung | nie | Rekonstruierbarkeit des Gates |
**Qualitätsstufen statt Punktzahl**
| Stufe | Bedeutung für den Autor |
|---|---|
| Nicht veröffentlichbar | verletzt eine Grundbedingung, muss neu geschrieben werden |
| Veröffentlichbar, schwach | erscheint, wird kaum wirken, Hauptschwäche wird benannt |
| Solide | erfüllt die Anforderungen, zwei bis drei konkrete Hebel |
| Stark | trägt von selbst |
Die Stufe sieht der Autor, nicht der Leser der Meldung. Kein öffentliches Qualitätssiegel auf Meldungen, weil es die Meldungen zahlender Kunden entwertet, sobald eine davon nur "solide" ist. Die portalseitige Konsequenz aus der Qualität besteht bereits über Boost-Fähigkeit und Platzierung.
**Kostenmodell Prüfwerkzeug:** [Anzahl] kostenlose Prüfung je Meldung und [Zeitraum], danach über Credits. Der kostenlose Weg muss echt und ausreichend sein. Die Prüfung ist vom Veröffentlichungsvorgang entkoppelt, verbraucht keinen Slot und ändert keinen Status; die Entscheidung gegen einen Re-Check-Loop in Phase 2 bleibt davon unberührt.
**Nebeneffekt:** Prüfläufe erzeugen Divergenzdaten für `scoring:divergence-report` und schärfen die Rubrik.
**Abbruchbedingung Block 1:** Wenn nach [Zeitraum] weniger als [Schwellwert] Newsrooms beansprucht wurden und keine Wiedereinreichung nach Ablehnung messbar ist, trägt die Konversion aus dem Bestand nicht. Dann Block 1 einfrieren und Gewicht auf Block 3 verlagern.
---
## Block 2 — Organische Sichtbarkeit
Verbreitert den Trichter oben. Sinnvoll erst, wenn Block 1 unten geschlossen ist.
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| ~~2.1~~ | NewsArticle-Schema auf allen Meldungen, Organization-Schema auf Newsrooms — **Grundgerüst seit Juli (379688f), 17.09.2026 ergänzt:** gemeinsame `@id` zwischen `NewsArticle.author` und Newsroom-Organization, Publisher mit `@id` und Logo (`App\Support\StructuredData`) | M | 1b.1 |
| 2.2 | Aufnahme in Google News und Publisher Center prüfen und beantragen — **technischer Teil 17.09.2026:** News-Sitemap `/sitemap-news.xml` je Spoke (48 h, ≤ 1.000), in `robots.txt`; Publisher-Center-Anmeldung liegt beim Betreiber (Protokoll Teil 7) | M | 2.1 |
| ~~2.3~~ | Branchen- und Kategorie-Landingpages aus dem Archivbestand — **17.09.2026:** Jahresarchiv je Branche `/category/{slug}/archiv/{jahr}` (34.600 Meldungen intern verlinkt, in der Sitemap), Jahresleiste auf der Branchenseite, Beschreibungen für alle 14 Kategorien (`categories:seed-descriptions`), Portalname im Titel | M | keine |
| ~~2.4~~ | Transparenzseiten — **17.09.2026 nach Briefing, drei Seiten:** `was-kostet-eine-pressemitteilung` (Modellvergleich ohne Namen, Prüfliste Folgekosten, eigene Zusagen, Rechenbeispiel mit gemischtem Ausgang), `pressemitteilung-veroeffentlichen-ohne-abo`, `pressemitteilung-loeschen-oder-aendern-lassen` (Meldeverfahren + Absatz für fremde Portale); keine fremden Zahlen | S | keine — **Nachtrag 17.09.2026 (Anhang 1):** vierte Seite „kostenlos veröffentlichen" |
| ~~2.5~~ | Konsolidierung der Ratgeberseiten unter `/de/presse/` — **geschlossen 17.09.2026:** Strecke existiert seit R3.2 nicht mehr, 301 auf `/faq` bleibt; Hilfe-Center trägt die Inhalte, jetzt auch in der Topleiste neben FAQ | S | 1d.1 |
| 2.6 | Outreach an Betreiber von Presseportal-Listen und Vergleichsartikeln — **extern, in Arbeit (Betreiber, Stand 18.09.2026)**; Arbeitsregel, Ziele und Mustertext in Anhang 1, Abschnitt 5; nicht Teil der Umsetzung | M | 2.4 |
| ~~2.7~~ | Auffindbarkeit für KI-Systeme: strukturierte Daten, klare Entitätszuordnung, `llms.txt` — **17.09.2026:** `/llms.txt` je Host (Hub verweist auf beide Portale), Entitätszuordnung über 2.1 | M | 2.1 |
**Formulierungsgrenze bei 2.7:** Die technischen Voraussetzungen zu beschreiben ist haltbar. Eine Nennung in KI-Antworten zu versprechen ist es nicht. Vor Veröffentlichung auf einer Preisseite sauber formulieren.
**Abbruchbedingung Block 2:** Wenn nach [Zeitraum] die Transparenzseiten und der Listen-Outreach keine messbaren Registrierungen erzeugen, bleibt Block 2 auf dem technischen Teil (2.1 bis 2.3) beschränkt und der Marketing-Teil wird nicht weiter ausgebaut.
---
## Block 3 — Reichweite und Partner
Braucht Betreuungszeit und ist teilweise von Dritten abhängig.
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| ~~3.1~~ | RSS-Feeds je Branche — **gebaut 18.09.2026**: `/{edition}/feed` und `/{edition}/category/{slug}/feed` (RSS 2.0, jüngste 50, inkl. Unterkategorien), Autodiscovery auf Start-, Branchen- und Archivseiten, Link in der Archiv-Leiste, Block im Panel „Auslieferung", Hub leitet aufs Portal; **21.09.2026:** Feed je Newsroom (`/newsroom/{slug}/feed`), Übersichtsseite `/rss` (Was ist RSS, wie abonnieren, alle Feeds), „RSS" in der Topleiste; Abruf-Protokoll `feed_fetches` (Reader-Dienst, gemeldete Abonnenten, keine IP) mit Block im Admin-Bericht und Abrufzahl im Panel (Protokoll Teil 11) | S | 2.3 |
| 3.2 | Opt-in E-Mail-Digest je Branche, Double-Opt-in | M | 3.1, Bounce-Suppression live |
| 3.3 | Agenturmodell: mehrere Firmen unter einem Konto, aus den Sammeladressen aus 1a.2 abgeleitet | L | 1a.2 |
| 3.4 | Gezielte Ansprache kleiner PR-Agenturen und freier Texter | L | 3.3 |
| 3.5 | Syndikationsvertrag als Standarddokument fertigstellen | M | Anwaltstermin |
| 3.6 | IMKIS: Pilot nach Treffen am 01.10., Partnerplatzierung erst nach ausgehandelter Gegenleistung | L | 1d.6 |
**Zur Reihenfolge von 3.3:** Der Trockenlauf 1a.1 sagt, wie viele Sammeladressen im Bestand liegen. Ist die Zahl hoch, rückt das Agenturmodell nach vorn, weil es dann kein Zukunftsfeature ist, sondern ein vorhandenes Datenproblem.
**Abbruchbedingung Block 3:** Wenn der Leser-Layer nach [Zeitraum] unter [Schwellwert] Abonnenten je Branche bleibt, wird er nicht als Verkaufsargument verwendet und nicht weiter ausgebaut.
---
## Feste Abhängigkeiten außerhalb der Blöcke
| Vorgang | Blockiert | Grund |
|---|---|---|
| Bounce-Suppression produktiv — **Code fertig 15.09.2026**, produktiv mit Postfach `bounce@pressekonto.com` + `MAIL_RETURN_PATH` + `BOUNCE_IMAP_*` | 1b.6, 1d.3, 3.2, jede weitere Mailwelle | Zustellbarkeit; ein beschädigter Absenderruf trifft zuerst die Magic Links |
| connektar `ai_declaration` verpflichtend, ~Oktober 2026 | nichts direkt | Termin steht, gehört in die Planung |
| Anwaltstermin, Fünf-Punkte-Agenda | 3.5 | Syndikationsvertrag und Art. 50 Abs. 4 |
---
## Offene Entscheidungen
| Nr. | Frage | Betrifft |
|---|---|---|
| ~~E1~~ | **Entschieden 11.09.2026: 3** (drei Live-Läufe, siehe `basiswerte-block0.md` und `umsetzung-protokoll.md` Teil 3). Konfiguriert in `config/newsroom.php`. | 1a.2 |
| ~~E2~~ | **Empfehlung 01.09.2026: Rohzahlen** — kein Bodensatz, Verteilung symmetrisch, Einordnung gegen den Schnitt wäre wegen der Wachstumskurve falsch (siehe `basiswerte-block0.md`). | 1c.1 |
| ~~E3~~ | **Entschieden 16.09.2026: keine Credits.** Prüfung ist Teil der Freigabe, nicht ein Werkzeug; N automatische Läufe je Meldung (einstellbar, 3), danach Mensch, Automatik für die Meldung aus. Gelb geht weiter automatisch online, `yellow_to_manual_queue` bleibt als Schalter (Vorgabe aus). | 1d.4 |
| ~~E4~~ | **Entschieden 15.09.2026: der Magic-Link-Hinweis, am Ende der Mail, als Hinweis, nicht als Angebot.** Umgesetzt in 1b.4. Der Link auf die Infoseite (1d.2) bekommt damit keinen Platz in der Veröffentlichungsmail — ein Element pro Mail. | 1b.4, 1d.2 |
| ~~E5~~ | **Geschlossen 17.09.2026: bleibt bei FAQ, kein Slug-Mapping.** Werden die bestehenden Ratgeberseiten konsolidiert oder ergänzt — **Befund 17.09.2026:** `/de/presse/*` leitet seit R3.2 auf `/faq`; offen ist nur, ob Schreib-Slugs stattdessen auf `/gute-pressemitteilung` zeigen sollen (Protokoll Teil 7) | 2.5 |
| ~~E6~~ | **Entschieden (umgesetzt 16.09.2026, abgehakt 18.09.2026): ja, dauerhaft.** Hilfeseite und Ablehnungsmail ohne kommerziellen Anteil; Partnerbereich nur als BALD am Seitenende. | 1d.1, 1d.6 |
| ~~E7~~ | **Entschieden 17.09.2026 (Anhang 1):** keine kostenfreie Einreichung, dauerhaft | 2.4, 2.6 |
| E8 | Kundenansicht des Indexierungsstatus — gebaut, zu (`GSC_CUSTOMER_VISIBLE`); **Prüfung läuft seit 17.09.2026 täglich, Entscheidung, sobald die Verteilung belastbar ist** (Anhang 1) | 1c.5 |
| ~~E9~~ | **Entschieden 18.09.2026:** Zuordnung je Kategorie wie vorgeschlagen, Mechanismus aktiv (Anhang 2, Protokoll Teil 10) | 1c.8 |
| ~~E10~~ | **Entschieden 18.09.2026: Portale führen** (presseecho), API-Doku beim Hub, Rechtstexte je Host (Anhang 2, Protokoll Teil 10) | 2.4 |
---
## Leitplanken, die für alle Blöcke gelten
- Benachrichtigungen bleiben transaktional. Der Charakter der Mail wird rückwirkend von der Zielseite bestimmt: wird die Seite überwiegend Angebot, wird die Mail zur Anbahnung.
- Kein kostenpflichtiges Angebot im Moment der Ablehnung.
- Kostenpflichtige Inhaltsänderung nicht für syndizierte Meldungen, solange connektar write-once bleibt und kein kostenfreier Alternativweg existiert.
- Kein Verweis auf connektar als kostenfreier Änderungsweg.
- Bewertungslogik: Kategorien erklären, Gewichtungen und Schwellen nie.
- Noch nicht verfügbare Angebote werden als "BALD" gekennzeichnet, nicht weggelassen.
- Jede Maßnahme wird gegen die sieben Anti-Zombie-Zusagen geprüft, bevor sie live geht.

View file

@ -0,0 +1,160 @@
# Anhang zur Roadmap Nutzergewinnung — Änderungen vom 17.09.2026
*Ergänzung zur Fassung vom 01.09.2026. Die Abschnitte unten ersetzen oder erweitern die jeweils genannte Stelle.*
---
## Überblick
| Änderung | Betrifft | Art |
|---|---|---|
| Crawler-Abruf-Protokoll als eigenes Arbeitspaket | 1c.0 | neu |
| Absenderansicht in Stufen zerlegt, Indexierungsstatus zunächst intern | 1c.1, 1c.4 bis 1c.6 | ersetzt |
| Erstquelle gegen Kopie als Argument für die Direktveröffentlichung | 1d | neu |
| Transparenzseiten ausformuliert, kein Wettbewerbervergleich | 2.4 | ersetzt |
| Zuschriften an Portalübersichten ausformuliert, zwei konkrete Ziele | 2.6 | ersetzt |
| 2.7 rückt vor, Begründung ergänzt | 2.7 | ergänzt |
| Zwei neue offene Entscheidungen | E7, E8 | neu |
**Anlass der Änderung:** Recherche zum Marktstand am 17.09.2026. Zwei Befunde haben die Priorisierung verschoben — die Belegbarkeit von KI-Abrufen über eigene Serverlogs, und die Tatsache, dass die GEO-Positionierung seit Ende August 2026 besetzt ist.
---
## 1. Block 1c — ersetzt den bisherigen Abschnitt vollständig
### 1c Sichtbarkeit belegen
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| ~~1c.0~~ | Crawler-Abruf-Protokoll — **gebaut 17.09.2026**: `press_release_crawler_hits`, 15 Dienste in `config/crawlers.php`, keine IP, Bereinigung mit dem Ereignis-Log; `CRAWLER_HITS_RECORDED_SINCE` beim Deploy setzen | S | keine |
| ~~1c.1~~ | Absenderansicht Stufe 1 — **umgebaut 17.09.2026**: Panel heißt „Auslieferung", Google-Suchlink entfernt, Sitemap-Datum bleibt; keine Feeds vorhanden, daher kein Feed-Eintrag | M | 0.1 |
| ~~1c.2~~ | Zugang zur Ansicht über Magic Link, auch ohne Konto — **16.09.2026** (Hauptdokument) | S | 1b.2 |
| ~~1c.3~~ | Newsroom-Übersicht mit allen Meldungen der Firma und Summenwerten — **16.09.2026** (Hauptdokument) | M | 1a.3, 1c.1 |
| ~~1c.4~~ | Indexierungsstatus — **gebaut 17.09.2026**: `press-releases:check-index-status` (täglich, Kontingent nach Alter gestaffelt), `press_release_index_checks` als Momentaufnahmen, Admin ▸ Reports ▸ Indexierung getrennt nach direkt/API; **live seit 17.09.2026** (Service-Konto eingerichtet, täglicher Lauf 05:10, Abfrage auf `en-US`) | M | GSC-Property je Portal verifiziert |
| 1c.5 | Entscheidung über die Kundenansicht des Indexierungsstatus, auf Basis der Verteilung aus 1c.4 — Schalter `GSC_CUSTOMER_VISIBLE` ist vorbereitet; Altbestand ohne Status-Log zählt in der Verteilung als „direkt". **Wartet auf Daten (Stand 18.09.2026): der tägliche Lauf sammelt, Bericht unter Admin ▸ Reports ▸ Indexierung** | S | 1c.4 |
| ~~1c.6~~ | Abrufe durch KI-Dienste — **gebaut 17.09.2026**: Block im Panel je Dienst mit letztem Abruf und Anzahl, Satz „Ein Abruf ist kein Zitat" | S | 1c.0, 1c.1 |
**Stufe 1 verspricht nichts, was von Dritten abhängt.** Überschrift "Auslieferung", nicht "Sichtbarkeit bei Google". Keine Schaltfläche, die eine `site:`-Suche öffnet — sie liefert dem Kunden ein unvollständiges Ergebnis ohne Einordnung und ist schlechter als keine Angabe.
**Warum 1c.4 erst intern läuft:** Google News spielt bei mehrfach veröffentlichten Texten nur eine Quelle aus, und syndizierte Meldungen liegen gleichzeitig auf vielen Portalen. Die Indexierungsquote des syndizierten Bestands wird deshalb niedrig ausfallen, aus einem Grund, der nicht am Portal liegt. Erst die Verteilung kennen, getrennt nach Direkteinreichung und Syndikation, dann über die Kundenansicht entscheiden. Fällt der Unterschied deutlich aus, ist er das stärkste sachliche Argument für die Direktveröffentlichung.
**Technische Hinweise zu 1c.4**
- URL Inspection API, 2.000 Abfragen pro Tag und Property, also je Portal getrennt
- nach Alter gestaffelt prüfen statt täglich alles; ein festes Tageskontingent auf die jüngsten Meldungen bleibt weit unter der Quote
- jede Antwort mit Zeitstempel in eigener Tabelle speichern; die API liefert eine Momentaufnahme, der Verlauf lässt sich nicht nachträglich erzeugen
- "noch nicht gecrawlt" und "gecrawlt, aber nicht indexiert" sind verschiedene Befunde und müssen in jeder Anzeige unterschieden werden
**Grenze bei 1c.6:** Ein Abruf ist kein Zitat. Die Anzeige belegt, dass der Crawler eines Dienstes die Seite geholt hat, mehr nicht. Genau so formulieren. Erfasst werden User-Agent, Zeitpunkt und Meldung, keine IP-Adressen.
**Warum 1c.0 sofort kommt:** Die Daten entstehen erst ab dem Einschalten des Protokolls. Jeder Tag ohne Protokoll ist ein Tag ohne Beleg, und rückwirkend lässt sich nichts erzeugen. Das Paket ist klein und unabhängig von allem anderen.
---
## 2. Block 1d — neuer Absatz, direkt nach "Nebeneffekt"
**Erstquelle gegen Kopie** (*eingebaut 17.09.2026: Absatz in der Veröffentlichungsmail, nur bei API-Meldungen, ohne Preis und Angebot*)**:** Eine über einen Verteiler ausgespielte Meldung liegt gleichlautend auf vielen Portalen und hat für Such- und KI-Systeme keine erkennbare Erstquelle. Eine direkt eingereichte Meldung kann als Erstquelle mit strukturierten Daten, geprüften Angaben und stabiler Adresse erscheinen. Das ist eine technische Tatsache und lässt sich in den Benachrichtigungen ohne Verkaufsdruck erklären. Es ist die erste sachliche Begründung dafür, dass die Direktveröffentlichung mehr wert ist als die Syndikation.
---
## 3. Block 2 — Tabellenzeilen 2.4, 2.6, 2.7 ersetzt
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| ~~2.4~~ | Transparenzseiten — vier Seiten, die vierte (`/pressemitteilung-kostenlos-veroeffentlichen`) **gebaut 17.09.2026**; Liste kostenloser Portale in `config/transparency.php` vorbereitet, leer bis geprüfte Einträge vorliegen | S | keine |
| 2.6 | Zuschriften an Betreiber von Portalübersichten — **extern, in Arbeit (Betreiber, Stand 18.09.2026)**; nicht Teil der Umsetzung | S | 2.4 |
| ~~2.7~~ | Auffindbarkeit für KI-Systeme: strukturierte Daten, klare Entitätszuordnung, `llms.txt` — **17.09.2026** (`/llms.txt` je Host, Entitäten über 2.1; Hauptdokument) | M | 2.1 |
**2.7 rückt vor.** Das Feld ist nicht mehr leer: seit Ende August 2026 besetzt mit Leitquelle ein kuratiertes Portal genau diese Positionierung, betrieben von einer Berliner PR-Agentur, 89 Euro je Meldung. Der Bestand dort ist zum Stichtag klein und die Aufbereitung Handarbeit; die These wird damit bestätigt, nicht der Markt besetzt. Der Vorteil liegt bei Archiv, Zulauf und Automatisierung — nicht beim Vokabular. Begriffe wie "KI-lesbar" oder "Referenzversion" sind belegt und werden nicht übernommen.
**Formulierungsgrenze bei 2.7:** Die technischen Voraussetzungen zu beschreiben ist haltbar. Eine Nennung in KI-Antworten zu versprechen ist es nicht. Belegbar ist allein der Abruf durch einen Crawler (1c.0, 1c.6).
---
## 4. Neuer Unterabschnitt: 2.4 im Einzelnen
Kein Vergleich mit Wettbewerbern. Eine Tabelle mit fremden Preisen veraltet und wird damit zur unrichtigen Tatsachenbehauptung über einen Mitbewerber; Nachpflege ist für ein Solo-Projekt der falsche Aufwand. Die Argumentation läuft über das Abrechnungsmodell, nicht über die Zahl.
| Seite | Suchintention |
|---|---|
| Was kostet eine Pressemitteilung | Preis, Budget, Vergleich |
| Pressemitteilung veröffentlichen ohne Abo | gezielte Ablehnung eines Modells |
| Pressemitteilung löschen oder ändern lassen | akutes Problem, oft bei einem fremden Portal |
| Pressemitteilung kostenlos veröffentlichen | sucht kostenlos, bekommt die Begründung, warum es das hier nicht gibt |
**Aufbau der ersten Seite**
1. Preis im ersten Bildschirm
2. Modellvergleich ohne Anbieternamen: Abo, verfallendes Guthaben, Einzelabrechnung, kostenlos mit Auflagen
3. Prüfliste zu Folgekosten, auf jedes Angebot anwendbar — öffentlicher Preis, automatische Verlängerung, Guthabenverfall, Kosten für Korrektur und Löschung, Verbleib nach Vertragsende
4. Die eigenen Zusagen mit konkreter Einlösung
5. Rechenbeispiel mit den eigenen Optionen
Gewinnt im Rechenbeispiel in jeder Zeile dasselbe Produkt, ist es ein Verkaufsargument und verliert seine Wirkung.
Abschnitt 3 ist der Teil, der die Seite verlinkenswert macht: er hilft auch dem, der bei einem anderen Anbieter bleibt.
Die dritte Seite ist die stärkste und bisher unbesetzt, führt aber Anfragen zu fremden Portalen herbei. Ein Absatz dazu, an wen man sich dort wendet, fängt das auf.
**Zur vierten Seite:** "Pressemitteilung kostenlos veröffentlichen" ist ein gesuchter Begriff, und die Seite beantwortet ihn ehrlich, statt ihn zu meiden. Inhalt: was kostenlose Portale tatsächlich leisten, warum Links dort als `nofollow` oder `sponsored` gekennzeichnet werden und kaum tragen, warum kostenfreie Annahme und Qualitätsprüfung sich ausschließen, und wer die Kosten bei kostenlosen Angeboten am Ende trägt. Am Ende eine kurze Liste kostenloser Portale für alle, die genau das suchen. Wer bleibt, bleibt informiert.
**Falls doch fremde Zahlen:** nur öffentlich einsehbare Listenpreise, jeder Eintrag mit Stichtag und Link zur Preisseite des Anbieters, sichtbarer Erhebungshinweis. Keine Durchschnittspreise aus Sekundärquellen, weil die Erhebungsmethode nicht verteidigt werden kann.
---
## 5. Neuer Unterabschnitt: 2.6 im Einzelnen
**Arbeitsregel:** nur dort anschreiben, wo entweder ein Eintrag zum eigenen Portal bereits besteht und sachlich falsch ist, oder wo die Redaktion ausdrücklich um Zuschriften bittet. Eine Bitte um Neuaufnahme ohne solchen Anlass zielt auf Absatzförderung und fällt unter die Einwilligungspflicht bei elektronischer Post, die auch gegenüber Unternehmen gilt. Eine Korrektur einer bestehenden Angabe tut das nicht.
**Listen kostenloser Presseportale entfallen als Ziel.** Beide Portale nehmen keine kostenfreien Einreichungen an (E7). Eine Aufnahme dort wäre nicht nur unpassend, sondern schädlich, weil sie Einreicher mit falscher Preiserwartung schickt. Damit fällt der größte Teil der vorhandenen Übersichtsartikel weg.
| Ziel | Befund zum 17.09.2026 | Anlass |
|---|---|---|
| PR-Gateway, Top-30-Übersicht | beide Portale unter *kostenlose* Presseportale geführt | Korrektur einer Angabe, die falschen Zulauf erzeugt |
| pronline.de und vergleichbare Listen kostenloser Portale | beide Portale nicht gelistet | kein Anlass, gehört dort nicht hin |
| Übersichten kuratierter oder kostenpflichtiger Portale | zu erheben | Aufnahme sachlich passend |
**Die PR-Gateway-Korrektur ist kein Marketingvorgang.** Sie verhindert Einreichungen, die an der Preiserwartung scheitern, und damit Ablehnungen, Rückfragen und Frust. Vor dem Schreiben den konkreten Eintrag selbst prüfen.
**Eigener Beitrag statt Bitte:** Portalübersichten verfallen schnell, und der Zustand vieler angeschlossener Portale ist aus dem Verteilerbetrieb bekannt. Eine Rückmeldung mit geprüften Einträgen und Prüfdatum ist ein redaktionsfähiger Beitrag und ein Anlass, der nichts mit dem eigenen Eintrag zu tun hat. Zweiter Beitrag: die Kriterienliste aus 2.4.
**Bezahlte Platzierungen in redaktionellen Ranglisten werden nicht gekauft.** Sie verstoßen gegen Googles Richtlinien zu Linkspam und untergraben die eigene Qualitätsargumentation. Ein klar gekennzeichneter Anzeigenplatz ist etwas anderes und wird als Werbung behandelt.
**Aufwandsdeckel:** eine Welle, höchstens acht Kontakte, eine Mail je Kontakt, kein Nachfassen, Zielliste mit Datum dokumentiert. Messung über Referrer in Umami, nicht über die Ereignistabelle.
**Mustertext für den Korrekturfall**
> Betreff: Korrektur zu Ihrer Portalübersicht — Eintrag [Portal]
>
> Sehr geehrte/r [Name],
>
> in Ihrer Übersicht [Titel, URL] ist [Portal] mit [falsche Angabe] aufgeführt. Das trifft seit [Zeitpunkt] nicht mehr zu. Richtig ist: [korrekte Angabe]. Öffentlich einsehbar unter [Link].
>
> Beim Durchsehen ist mir außerdem aufgefallen, dass [Anzahl] der genannten Portale am [Prüfdatum] keine Einreichung mehr angenommen haben: [Liste]. Falls Sie die Angaben nachhalten wollen, schicke ich Ihnen die Prüfnotizen gern.
>
> Freundliche Grüße
> [Absender]
Kein Anhang, kein zweiter Link auf eine Verkaufsseite, keine Nachfassmail.
---
## 6. Offene Entscheidungen — zwei neue Zeilen
| Nr. | Frage | Betrifft |
|---|---|---|
| ~~E7~~ | **Entschieden 17.09.2026:** keine kostenfreie Einreichung, dauerhaft. Listen kostenloser Portale entfallen als Ziel. | 2.6 |
| E8 | In welcher Form geht der Indexierungsstatus in die Kundenansicht? **Teilentscheidung 17.09.2026:** wird gleich mitgebaut, Anzeige beim Kunden zunächst zu. *Umgesetzt: Block „Google-Index" im Panel hinter `GSC_CUSTOMER_VISIBLE` (false).* | 1c.5 |
---
## 7. Marktbefunde, auf denen diese Änderungen beruhen
*Stand 17.09.2026, zur Nachvollziehbarkeit festgehalten.*
- Beide Portale fehlen in der geprüften Portalliste von pronline.de (Prüfdatum 09.09.2026) vollständig. Sie sind nicht gestrichen worden, sie waren nie gelistet. Auf Platz 7 steht pr-echo.de, ein anderes Portal.
- Auf der Top-30-Liste von PR-Gateway sind beide Portale unter "kostenlose Presseportale" geführt.
- Leitquelle (leitquelle.de), betrieben von PRonline Media UG, Berlin, veröffentlicht seit 27.08.2026 kuratierte Meldungen als KI-lesbare Referenzversionen mit Faktenbox, FAQ und JSON-LD. Preis 89 Euro, Beta mit kostenlosen Plätzen. Bestand zum Stichtag rund ein Dutzend Meldungen.
- Leitquelle protokolliert Abrufe durch ChatGPT, Bing, Copilot, Google und Claude öffentlich mit Datum und Uhrzeit je Meldung und formuliert dazu ausdrücklich, dass kein Portal eine Zitierung garantieren kann. Das ist die Vorlage für 1c.0 und 1c.6.
- Grundsatzentscheidung 17.09.2026: keine kostenfreie Einreichung. Kostenfreie Annahme zieht Texte an, deren Prüfung und Betrieb Geld und Zeit kosten, ohne Gegenwert, und steht im Widerspruch zum Qualitätsgate.
- Branchenbefund: eine über einen Verteiler ausgespielte Meldung erscheint gleichlautend auf vielen Portalen, ohne erkennbare Erstquelle. Links aus Pressemitteilungen sollen als `sponsored` oder `nofollow` gekennzeichnet werden und tragen als SEO-Argument kaum noch.

View file

@ -0,0 +1,111 @@
# Anhang 2 zur Roadmap Nutzergewinnung — Doppelveröffentlichung und Quellenauskunft
**Stand:** 17.09.2026
**Ergänzt:** Block 1c und 1d
**Anlass:** Über die API wird für beide Portale eingereicht, häufig dieselbe Meldung. Die Search-Console-Properties sind seit 17.09.2026 für presseecho.de, businessportal24.com und pressekonto.com bestätigt; damit wird der Effekt erstmals messbar.
---
## 1. Sachlage
Liegt derselbe Text auf zwei Adressen, wählt Google eine davon als maßgeblich und filtert die andere aus dem Index. Das ist kein Nachteil im Sinne einer Abwertung, sondern eine Auswahl. Die Folge betrifft zwei Seiten unterschiedlich stark:
| Betroffen | Auswirkung |
|---|---|
| Die Portale | Crawl-Aufwand für Seiten, die nicht erscheinen; über den Bestand ein hoher Anteil ausgefilterter URLs |
| Die einreichende Firma | Die Meldung existiert in der Suche einmal statt zweimal, ohne Einfluss darauf, welche Fundstelle das ist |
Für Leser, Feeds, interne Suche und Newsroom zählen weiterhin beide Fassungen. Nur in der Suchmaschine zählt eine.
**Derzeitiger Zustand:** Die Auswahl trifft der Algorithmus. Das Portal erfährt nicht, wie sie ausgefallen ist.
---
## 2. Sichtbar wird es über zwei Felder
Die URL-Prüfung der Search Console liefert:
| Feld | Bedeutung |
|---|---|
| `userCanonical` | die Adresse, die das Portal als maßgeblich angibt |
| `googleCanonical` | die Adresse, die Google als maßgeblich behandelt |
Weichen beide voneinander ab, hat Google eine andere Quelle gewählt. Das kann die eigene zweite Fassung sein oder ein fremdes Portal aus derselben Verteilerausspielung.
Schneller als über die API geht es zunächst über eine Bestandsabfrage nach identischen Titeln oder Textprüfsummen auf beiden Portalen. Das beantwortet die Mengenfrage, ohne auf Crawl-Daten zu warten.
---
## 3. Neue Arbeitspakete in 1c
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| ~~1c.7~~ | Doppelveröffentlichung erheben — **gebaut 17.09.2026**: `content_hash`, `press-releases:hash-content`, `press-releases:duplicates-report`. Befund Dev: 9.092 Paare = 53 % des Archivs (Protokoll Teil 9); **Live 21.09.2026: 9.221 Paare = 18.442 URLs, 1.468 davon seit 07/2026** | S | keine |
| ~~1c.8~~ | Kanonische Zuordnung — **gebaut 17.09.2026, seit E9 aktiv, Bestand live zugeordnet 21.09.2026 (9.221 Paare; Protokoll Teil 11, Nachtrag Live-Deploy)**: `CanonicalAssigner`, `canonical_press_release_id`, Zuordnung bei Veröffentlichung + `press-releases:assign-canonicals`; rel=canonical, mainEntityOfPage, Sitemaps nur Erstquelle. Schalter `CANONICAL_ASSIGNMENT_ENABLED` | M | 1c.7 |
| ~~1c.9~~ | Quellenauskunft — **gebaut 17.09.2026, hinter `GSC_CUSTOMER_VISIBLE`**: `user_canonical` gespeichert, `sourceVerdict()` (eigene / andere eigene Fassung / fremdes Portal), Text im Block „Google-Index" | S | 1c.4, 1c.8 |
### Zur Auswahl in 1c.8
| Weg | Wirkung | Preis |
|---|---|---|
| Nur ein Portal je Meldung, Zuordnung über die Kategorie | nichts wird ausgefiltert | eine Veröffentlichung weniger |
| **Beide Portale, zweite Fassung mit `rel=canonical`** | Portal bestimmt die Erstquelle, Leser sehen weiterhin beide | Umsetzungsaufwand |
| So lassen | Google entscheidet, ohne Rückmeldung | Kontrollverlust |
Empfohlen ist der mittlere Weg. Die Zuordnung folgt der Kategorie: Wirtschafts- und Unternehmensthemen kanonisch auf businessportal24.com, breite Themen auf presseecho.de, die jeweils andere Fassung verweist darauf.
Technisch am Import ansetzen: Prüfsumme über den Text bilden, bei Treffer wird die zweite Fassung mit Verweis angelegt. Wirkt auch rückwirkend über den Bestand.
**Offene Entscheidung E9:** welche Kategorie welchem Portal kanonisch zugeordnet wird. *Vorschlag in `config/canonical.php`; „sonstiges" (3.087 Paare) entscheidet das Verhältnis.*
---
## 4. Reihenfolge ist nicht verhandelbar
1c.8 steht vor 1c.9 und vor jeder Auskunft an Kunden. Solange im eigenen Haus zwei identische Fassungen ohne Zuordnung stehen, kann kein Hinweis an Einreicher gegeben werden, ohne unglaubwürdig zu wirken.
---
## 5. Folge für die Beschreibung des Angebots
Zwei Portale bedeuten zwei Fundstellen für Leser, nicht zwei Fundstellen in der Suche. Wo das bisher anders dargestellt ist — Preisseite, Leistungsbeschreibung, Benachrichtigungstexte —, gehört es korrigiert, bevor die Quellenauskunft live geht.
---
## 6. Die Auskunft als Service (Ergänzung zu 1d)
**Quellenauskunft als Service:** Der Abgleich von `googleCanonical` und `userCanonical` zeigt nicht nur, welche der eigenen Fassungen gewonnen hat, sondern auch, wenn ein fremdes Portal als Quelle geführt wird. Diese Auskunft gibt sonst niemand und beantwortet die Frage, warum eine Meldung trotz vieler Fundstellen nicht auffindbar ist.
**Wo sie steht**
| Ort | Inhalt |
|---|---|
| Absenderansicht | welche Adresse Google als maßgeblich führt, mit Datum |
| Hilfeseite (1d.1) | Abschnitt: warum eine Meldung mehrfach im Netz steht und nur einmal in den Ergebnissen |
| Benachrichtigungsmail | nichts davon, sonst kippt der Charakter der Mail |
**Die Empfehlung, die dazugehört:** Die Erstveröffentlichung gehört auf die eigene Website des Unternehmens; die eigene Domain ist die stabilste Quelle, die eine Firma hat. Portale kommen danach. Das schmälert das eigene Geschäft nicht, weil über die eigene Website niemand Verbreitung bekommt.
**Grenze der Auskunft**
- Keine Aussage darüber, *warum* Google eine bestimmte Fassung gewählt hat. Das ist nicht bekannt.
- Keine Formulierung, die nahelegt, dass eine Direktveröffentlichung das Problem löst. Sie verbessert die Ausgangslage, weil eine geprüfte Erstfassung mit strukturierten Daten und stabiler Adresse bessere Voraussetzungen hat als eine Verteilerkopie. Garantiert ist nichts.
- Der offene Satz, dass sich das nicht garantieren lässt, macht die Auskunft glaubwürdiger, nicht schwächer.
---
## 7. Nebenbefund: pressekonto.com
Die Property ist angelegt, wird für die Indexierungsprüfung aus 1c.4 aber nicht gebraucht — das Tageskontingent geht an die beiden Portale. Zwei Dinge sind dort trotzdem zu prüfen:
- ~~Stehen Konto- und Login-Bereiche auf `noindex`?~~ **17.09.2026: ja, jetzt per Meta-Tag in allen Portal-Layouts.** Die Indexierungsübersicht der neuen Property zeigt, was Google von dort erfasst hat.
- ~~**Offene Entscheidung E10**~~ **Entschieden 18.09.2026: Portale führen, presseecho als Standard; die API-Doku bleibt beim Hub, Rechtstexte je Host.** Dabei aufgefallen und behoben: `/de/preise`, `/de/veroeffentlichen`, `/de/newsrooms` und `/de/suche` wurden unter pressekonto.com mit dem Branding von businessportal24 ausgeliefert — jetzt 301 auf presseecho. ~~Offen: Kategorie-, Archiv- und Meldungsadressen sind auf dem Hub weiterhin erreichbar~~ — **erledigt 18.09.2026 nach Freigabe (Commit 24c1bbf):** 301 aufs Portal des Eintrags, bei gleichem Slug auf die Erstquelle, unbekannter Slug bleibt 404, nur GET (Protokoll Teil 10). Feeds (3.1) leiten genauso.
---
## 8. Neue offene Entscheidungen
| Nr. | Frage | Betrifft |
|---|---|---|
| ~~E9~~ | **Entschieden 18.09.2026:** Zuordnung wie vorgeschlagen (businessportal24: finanzen, handel, it, recht, wissenschaft-forschung, motor-verkehr; presseecho: alle übrigen inkl. sonstiges). Zuordnung ist aktiv, `CANONICAL_ASSIGNMENT_ENABLED` steht standardmäßig auf true | 1c.8 |
| ~~E10~~ | **Entschieden 18.09.2026: Portale führen** — presseecho für faq, hilfe, gute-pressemitteilung und die vier Transparenzseiten, pressekonto für die API-Doku. Rechtstexte bleiben bewusst je Host (eigene Anbieterkennzeichnung, eigener Meldeweg). Sitemap und llms.txt nennen nur die führende Adresse (Protokoll Teil 10) | 2.4 |

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,196 @@
# Deploy v1.1.0 — Checkliste
> **Live seit 05.10.2026** — Abschluss vom Betreiber bestätigt. Protokoll am Ende.
**Stand:** 05.10.2026 · **Live bisher:** Stand `98c465b` (Deploy 28.09.2026, ca. 14:34)
**Inhalt:** alles danach — Blöcke 9 und 10 aus dem Audit
([`ENTWICKLUNGSPLAN.md`](../2026-09-audit/ENTWICKLUNGSPLAN.md), Deploy-Plan
dort), Betriebsüberwachung Phase 1 ([`monitoring-plan.md`](../../betrieb/monitoring-plan.md)),
Composer-Update, Versionsnummer.
Umfang gegenüber Live: 8 additive Migrationen, 2 neue Composer-Pakete
(`sentry/sentry-laravel`, Abhängigkeiten), 48 Paket-Updates, neue `.env`-Werte,
neue Assets (beide Builds), 2 neue Scheduler-Tasks (21 statt 19). Keine
gelöschten Code-Dateien.
Die Schritte aus dem Deploy-Plan der Blöcke 9/10 sind hier eingeordnet und
mit **(B9/10 Nr.)** gekennzeichnet.
**Auf dem Server wird kein Git gebraucht.** Git läuft nur lokal (Abschnitt A):
Tag, Versionsdatei, Paket. Der Server bekommt das fertige Paket und braucht
nur `tar`, `php` und `composer`.
## A — Lokal vorbereiten
1. Arbeitskopie committen, `main` per Fast-Forward nachziehen, Tag setzen:
`git tag -a v1.1.0 -m "v1.1.0 — Audit-Blöcke 9/10, Betriebsüberwachung Phase 1"`.
Push von `main` und Tag nach `origin`.
2. Auf dem Tag: `composer install`, `npm ci && npm run build`, volle Testsuite
(`php artisan test --compact --parallel`), Larastan.
3. Versionsdatei und Paket bauen (im Projektverzeichnis, auf dem Tag):
```bash
git describe --tags --always --dirty > RELEASE # muss genau "v1.1.0" ergeben
git -c core.quotepath=off diff --name-only --diff-filter=AMR 98c465b v1.1.0 \
-- . ':!dev' ':!docs' ':!tests' ':!.mcp.json' ':!.codex' ':!phpunit.xml' ':!phpstan-baseline.neon' \
> deploy-files.txt
tar czf deploy-v1.1.0.tgz -T deploy-files.txt public/build RELEASE
```
Ergibt `RELEASE` etwas anderes als `v1.1.0` (z. B. `-dirty`), ist die
Arbeitskopie nicht sauber — erst klären. Im Devcontainer fehlt `docs/`
(nicht eingebunden), das allein macht `-dirty`; dort nach Prüfung mit
`git describe --tags --always` schreiben.
**Erledigt 05.10.2026:** Paket `deploy-v1.1.0.tgz` gebaut (148 Dateien +
`public/build` + `RELEASE` = `v1.1.0`), liegt im Projektverzeichnis.
4. Paket per SFTP nach `~/` auf den Server laden.
## B — Live vorbereiten (vor dem Wartungsfenster)
5. **DB-Backup** ziehen; `ARCHIVE_KEY` liegt getrennt gesichert vor. (B9/10 Nr. 4)
6. **Dateibackup** für den Rollback (inkl. `vendor/`, ohne `storage/`):
```bash
cd ~ && tar czf backup-vor-v1.1.0.tgz --exclude=./storage --exclude=./node_modules -C ~/pressekonto.com .
```
7. **`.env` auf Live ergänzen**:
| Variable | Wert | Herkunft |
|---|---|---|
| `NEWSROOM_API_ASSIGNMENT_USER_IDS` | `13993` (vorher prüfen: `select id from users where email = 'info@connektar.de'`) | E19 |
| `NEWSLETTER_SENDING_ENABLED` | `false` | E23 |
| `BOUNCE_UNSIGNED_KIND` | `policy` | E25/O01 |
| `SENTRY_LARAVEL_DSN` | DSN Projekt 1 (errors.adametz.media) | M1 |
| `SENTRY_BROWSER_DSN` | DSN Projekt 2 | M1 |
| `SENTRY_ENVIRONMENT` | `production` | |
| `SENTRY_RELEASE` / `APP_VERSION` | **leer lassen** — kommt aus `RELEASE` | |
| `MONITORING_NTFY_URL` | `https://push.adametz.media` | M3 |
| `MONITORING_NTFY_TOPIC` | `pressekonto-ops` | |
| `MONITORING_NTFY_TOKEN` | Token von `pressekonto-alerts` | |
| `MONITORING_HEALTH_TOKEN` | **neuer** Zufallswert, nicht der lokale (`openssl rand -hex 24`) | 1.5 |
| `MONITORING_ALERT_EMAIL` | Ops-Postfach prüfen (leer = `LEGAL_CONTACT_EMAIL`) | |
| `UMAMI_PORTAL_ENABLED` | `false` — erst nach DSE-Anpassung | M6 |
| `UMAMI_IDENTIFY_USERS` | `false` — erst nach DSE-Anpassung | M6 |
Optionale Werte (Standards greifen) siehe B9/10 Nr. 1.
8. **Hauptkategorien** gegen `config/canonical.php` prüfen:
`select id, slug from category_translations where locale = 'de' and category_id between 1 and 14`. (B9/10 Nr. 2)
9. **Uploadgrenzen** PHP `upload_max_filesize`/`post_max_size` und nginx
`client_max_body_size` ≥ 20M (Ploi: PHP-Einstellungen bzw. nginx-Konfiguration). (B9/10 Nr. 3)
10. **Ausgehend erreichbar** vom Server: `curl -sI https://errors.adametz.media` und
`curl -sI https://push.adametz.media` (beide antworten).
## C — Deploy (SSH, Wartungsfenster ca. 3–5 Minuten)
```bash
cd ~/pressekonto.com
php artisan down --retry=60
tar xzf ~/deploy-v1.1.0.tgz
composer install --no-dev --optimize-autoloader --no-interaction
php artisan migrate --force # 8 Migrationen, additiv (B9/10 Nr. 6)
[ -d public/vendor/livewire ] && php artisan vendor:publish --tag=livewire:assets --force
php artisan config:cache
php artisan route:cache
php artisan view:clear && php artisan view:cache
php artisan event:cache
php artisan schedule:clear-cache # alte 24-h-Sperren (B9/10 Nr. 7)
php artisan queue:restart
# PHP-FPM neu laden (Ploi: Server ▸ PHP ▸ Restart) — OPcache hält sonst alten Code
php artisan up
```
Schlägt `composer install` oder `migrate` fehl: **nicht** `up`, sondern Rollback (Abschnitt G).
## D — Direkt danach (Prüfungen)
11. `php artisan about --only=pressekonto` → Version `v1.1.0`.
12. Admin ▸ Reports ▸ Betriebsüberwachung: Version `v1.1.0`; nach 1–2 Minuten
Scheduler grün, Plattenplatz grün; `archive_key`, `archive_pending`,
`archive_sweep` grün (nach Nr. 14).
13. `curl -s -H "Authorization: Bearer <MONITORING_HEALTH_TOKEN>" https://pressekonto.com/health`
→ `"status":"ok"`, `"version":"v1.1.0"`. Direkt nach dem Deploy kann der
Scheduler noch fehlen (503) — eine Minute warten.
14. `php artisan archive:retry-pending` einmal von Hand. (B9/10 Nr. 8)
15. `php artisan company-memberships:api-assignments` sichten; `--remove` nur bei
Treffern ohne Freischaltung. (B9/10 Nr. 9)
16. `php artisan press-releases:assign-canonicals`. (B9/10 Nr. 10)
17. `php artisan press-release-images:sweep-orphan-originals` (Trockenlauf) und
`php artisan archive:sweep-orphans --dry-run`. (B9/10 Nr. 11, 12)
18. `php artisan schedule:list` → **21** Tasks, darunter `monitoring:heartbeat`
(minütlich), `queue:prune-failed` (03:55), `press-release-images:sweep-orphan-originals`
(So 04:45), `press-releases:purge-events` (03:50). (B9/10 Nr. 13)
19. **GlitchTip:** `php artisan tinker --execute 'report(new RuntimeException("Deploy-Test v1.1.0"));'`
→ erscheint in Projekt 1 mit Umgebung `production`, Release `v1.1.0`.
20. **ntfy:** `php artisan tinker --execute 'App\Support\OperationalAlert::send("Deploy v1.1.0 live");'`
→ Push auf dem Handy (und Mail an das Ops-Postfach).
21. `storage/logs/laravel-*.log` und `monitoring-*.log` auf neue Fehler prüfen.
## E — Abnahme mit dem Testkonto
22. Anmelden per Passwort, Magic Link und Google; danach einige Seiten
aufrufen. Ein eingeloggter Nutzer, der einen eigenen Magic Link öffnet,
landet auf dem Ziel. (B9/10 Nr. 14)
23. Einmal absichtlich falsches Passwort → Admin ▸ Reports ▸ Verlauf zeigt
„Anmeldung fehlgeschlagen … falsches Passwort“. (1.7)
24. Titelbild mit 13–15 MiB hochladen. (B9/10 Nr. 15)
25. `sitemap.xml`: englische Meldungen unter `/en/press-release/…`;
`article:modified_time` = Veröffentlichung/letzte Textänderung. (B9/10 Nr. 16)
26. Admin ▸ Newsletter: Sperrhinweis, „Senden“ aus. (B9/10 Nr. 17)
27. Nach dem nächsten `mail:fetch-bounces`: unsignierte Rückläufer als Richtlinie
„(unsigniert)“, ohne Sperre. (B9/10 Nr. 18)
28. Der Smoke-Test aus dem Runbook (Abschnitt 8): drei Domains, robots/sitemap,
Test-PM durch die Klassifikation, Formulare mit Mail.
## F — Danach
29. **Erledigt 06.10.2026 (status.adametz.media).** **Externer Monitor** (Uptime Kuma o. Ä.) auf `https://pressekonto.com/health`,
Header `Authorization: Bearer …`, 60 s, 2 Wiederholungen, Benachrichtigung über
ntfy (`push.adametz.media`, `pressekonto-ops`). (1.5)
30. ~~**KAS-Passwort rotieren**~~ (B9/10 Nr. 19) — erledigt 06.10.2026, SSH-Zugang deaktiviert.
31. **Datenschutzerklärung** (Betreiber/Anwalt): Rückläufer-Protokoll (B9/10 Nr. 20),
Fehlerprotokollierung GlitchTip (User-ID, URL, Browser, Fehlermeldung, 90 Tage),
fehlgeschlagene Anmeldungen im Verlauf (IP nur als HMAC). Danach — und erst
dann — `UMAMI_PORTAL_ENABLED`/`UMAMI_IDENTIFY_USERS` per `.env` + `config:cache`.
32. Restore-Übung (B9/10 Nr. 21).
33. Protokolle ergänzen: Deploy-Datum und Ergebnisse in `ENTWICKLUNGSPLAN.md`
(B9/10 Nr. 22) und `monitoring-plan.md`.
34. Backups `~/deploy-v1.1.0.tgz` und `~/backup-vor-v1.1.0.tgz` nach ein paar
stabilen Tagen löschen.
## G — Rollback
```bash
cd ~/pressekonto.com
php artisan down
tar xzf ~/backup-vor-v1.1.0.tgz -C ~/pressekonto.com # alter Code inkl. vendor/
php artisan config:cache && php artisan route:cache && php artisan view:clear && php artisan view:cache && php artisan event:cache
php artisan queue:restart # + PHP-FPM neu laden
php artisan up
```
Die Migrationen sind additiv; der alte Code läuft mit den neuen Spalten und
Tabellen. Nur bei Datenproblemen das DB-Backup aus Nr. 5 einspielen und danach
die Archiv-Schritte aus Runbook Abschnitt 9 befolgen.
## Protokoll
**05.10.2026 — Deploy v1.1.0 live** (Paket aus Abschnitt A, Commit `df7ebdc` = Tag `v1.1.0`).
Abschnitt D, Ausgaben auf Live:
| Nr. | Ergebnis |
|---|---|
| 11 | Zunächst Version „—“: Datei `RELEASE` fehlte bzw. Config-Cache war älter. Behoben mit `echo v1.1.0 > RELEASE` + `config:cache`. Eine lokal neu erzeugte `RELEASE` hatte `v1.1.0-2-g8a64bd7-dirty` ergeben (Doku-Commits nach dem Tag, `docs/` nicht eingebunden) — Lehre: `RELEASE` aus dem Paket nehmen, nicht neu erzeugen |
| 14 | `archive:retry-pending`: 0 Aufträge, 0 offen |
| 15 | `company-memberships:api-assignments`: 3 Mitgliedschaften (connektar, User 13993), alle freigeschaltet — kein `--remove` |
| 16 | `assign-canonicals`: 9.887 Paare, 342 geändert, 13 veraltete Verweise zurückgesetzt, Caches geleert |
| 17 | `sweep-orphan-originals` (Trockenlauf): 6 geprüft, 0 verwaist; `archive:sweep-orphans --dry-run`: 0 |
| 18 | `schedule:list`: 21 Tasks inkl. `monitoring:heartbeat`, `queue:prune-failed` 03:55, `sweep-orphan-originals` So 04:45 |
| 19, 20 | GlitchTip- und ntfy-Test ausgeführt; nach `config:cache` wiederholt |
Abschnitt E (Abnahme Testkonto) vom Betreiber nicht einzeln gemeldet.
Abschnitt F offen: externer Monitor auf `/health` (29),
Datenschutzerklärung (31), Restore-Übung (32); dazu Context7-Schlüssel rotieren
(stand bis `df7ebdc` im Repository). KAS-Passwort (30) erledigt 06.10.2026.

View file

@ -0,0 +1,8 @@
presseecho
<script defer src="https://sys.adametz.media/config.js" data-website-id="3ea8a7b0-105d-4519-b200-138cadc0fbdc"></script>
businessportal24
<script defer src="https://sys.adametz.media/config.js" data-website-id="f03a5fc3-ac44-488f-9118-84004861099f"></script>
pressekonto
<script defer src="https://sys.adametz.media/config.js" data-website-id="ca440b65-a5bd-42c1-8cdb-96f34734003c"></script>

View file

@ -0,0 +1,348 @@
# Legacy-Rechnungen per Überweisung: Versand, Zahlungseingang, Bestandsschutz-Ende
Stand: 16.09.2026 (Analyse auf DEV-DB vom 15.09.2026) · Branch `technik/betriebsfixes-2026-09`
## Status 16.09.2026: umgesetzt (Phase B, 0, 1, 2, 3)
- Branch `technik/betriebsfixes-2026-09`, alle 1670 Tests grün.
- Entscheidungen wie vorgeschlagen; E7 = Archiv **behalten und ausblenden**.
- `legacy:settle-unpaid` auf der DEV-DB gelaufen: 9 Bündel gekündigt, MAN-00001/00002 storniert,
6 Vorgänge zurückgedreht, 7 Einzelkäufe abgeschrieben. **Nur 1 der 6** offenen Vorgänge konnte
sofort neu ausgestellt werden (MAN-00003 ersetzt 43541-7) — die anderen **5 haben keine
Rechnungsadresse** (in der Alt-Rechnung stand nur der Name) und stehen im Panel „Blockierte
Vereinbarungen": Adresse im User-Profil nachtragen → „Jetzt abrechnen" → „Rechnung senden".
- Auf Live nach dem Deploy: `php artisan migrate`, dann `php artisan legacy:settle-unpaid --dry-run`
prüfen und ohne `--dry-run` ausführen.
- Phase 4 (Automatisierung) bewusst offen.
## 0. Worum es geht
Bestandskunden aus dem Altsystem zahlen weiter per **Überweisung** auf das eigene
Konto. Neue Abschlüsse laufen ausschließlich über Stripe. Für die Bestandskunden
fehlt heute der gesamte Bearbeitungsweg *nach* der Rechnungserzeugung:
> Rechnung fällig → **Knopfdruck**: Mail mit Rechnung (PDF), Zahlungsbitte und Hinweis
> „ohne Zahlung endet der Bestandsschutz in 14 Tagen" → Zahlung eingegangen: **als bezahlt
> markieren** → keine Zahlung: **Bestandsschutz beenden** (Rechnung stornieren, Bündel kündigen).
---
## 1. Ist-Zustand (was schon da ist, was fehlt)
### 1.1 Drei Datenwelten — nur eine davon ist relevant
| Tabelle | Inhalt | Rolle in diesem Vorhaben |
|---|---|---|
| `legacy_invoices` (887 Zeilen, 91 ohne `paid_at`) | Read-only-Archiv der Alt-Rechnungen (D-12), eigener PDF-Renderer | Historie, bleibt read-only. Die „unbezahlten" teilen sich in 94 `hold` (nie bezahlt → erledigt, s. 1.4) und 13 `pending`/`reminder` (offene Vorgänge 2026, die sauber abzuarbeiten sind). |
| `user_payment_options` mit `status = grandfathered` (22 laufend) | Die Bestandsschutz-Vereinbarungen (je Portal ein Posten, nur als **Bündel** kündbar – `LegacySubscriptionService::cancelBundleFor()`) | Das ist der „Bestandsschutz", der bei Nichtzahlung beendet wird. |
| `invoices` ohne `stripe_invoice_id` = **MAN-Kreis** (2 Zeilen) | Neue Rechnungen, die der 04:30-Lauf `billing:generate-manual-invoices` beim Erreichen von `current_period_end` erzeugt (`ManualInvoiceService`) | **Hier setzt alles an.** Die Rechnung existiert bereits als Datensatz — aber sonst nichts. |
### 1.2 Was der MAN-Kreis heute kann
- Fälligkeit erkennen, Rechnungsadresse einfrieren, USt via `VatResolver` (DE 19 %, EU mit USt-ID
befreit, Drittland befreit), MAN-Nummer vergeben, `due_date = invoice_date + 14 Tage`
(`billing.manual_due_days`), Periode um ein Jahr weiterschalten.
- Anzeige in `/admin/payments` → Tabelle „Rechnungsausgang (STR/MAN)" (nur lesen).
- Anzeige im Kundenkonto `/admin/me/invoices` (Spalte „Beleg" zeigt für MAN nur „–").
### 1.3 Was fehlt (Lücken, die der Plan schließt)
| # | Lücke | Konsequenz heute |
|---|---|---|
| L1 | **Kein PDF** für MAN-Rechnungen (`LegacyInvoicePdfRenderer` kann nur `LegacyInvoice`) | Kunde bekommt keinen Beleg, Admin kann nichts verschicken |
| L2 | **Keine Mail** – weder bei Erzeugung noch per Hand | Rechnungen MAN-00001/MAN-00002 sind seit **07.08.2026** offen, seit 21.08. überfällig und wurden **nie zugestellt** |
| L3 | **Kein Link Rechnung → Vereinbarung** (`invoices.user_payment_id` ist NULL, zeigt ohnehin auf `user_payments`, nicht auf `user_payment_options`) | Beim „Bestandsschutz beenden" lässt sich nicht sicher sagen, zu welchem Posten die Rechnung gehört; man muss über den User gehen |
| L4 | **Keine Admin-Aktionen**: senden, erinnern, bezahlt markieren, stornieren | Zahlungseingänge lassen sich nicht verbuchen → Kunden bleiben ewig „offen" |
| L5 | **Keine Versand-/Mahnhistorie** (kein `sent_at`, `reminded_at`) | Man sieht nicht, was der Kunde wann bekommen hat |
| L6 | **Blockierte Vereinbarungen sind unsichtbar**: Posten ohne Rechnungsadresse werden täglich übersprungen (nur Log-Warning) | UPO #15 (User 59128) und #18 (User 62129, netto 1.000 €) hängen seit **02./14.08.2026** fest — und weil `current_period_end` in der Vergangenheit liegt, gelten sie per `scopeGrandfatheredNow()` als **nicht mehr aktiv** („Buchung erforderlich"), obwohl nie eine Rechnung gestellt wurde |
| L7 | **Rechnungsaussteller** ist nirgends konfiguriert; der Legacy-Renderer hat adametz.media (BP24) und Stern Consulting (Presseecho) inkl. IBAN **hartcodiert** | Für neue Rechnungen braucht es eine Quelle für Absender, Bankverbindung, Steuernummer |
### 1.4 Befund 16.09.: Der Bestandsschutz wurde aus **unbezahlten** Rechnungen abgeleitet
Einordnung (Kevin, 16.09.): Legacy-Status `hold` = nie bezahlt, überfällig, Kunde oft nicht mehr
erreichbar → **ersatzlos erledigt**. Nur `pending` und `reminder` sind noch offene, sauber
abzuarbeitende Vorgänge.
`legacy:grandfather-subscriptions` hat diese Unterscheidung **nicht** gemacht: Es filtert nur auf
`payment_option.type = recurring` und `user_payment_option.status = active`, nicht auf den
Zahlungsstatus der Quellrechnung. Ergebnis über die 22 laufenden Bestandsschutz-Posten:
| Quellrechnung | Posten | Bewertung |
|---|---|---|
| `paid` | 7 (#2, #3, #5, #6, #7, #11, #22) | **Echte Bestandskunden.** Regelbetrieb über den MAN-Kreis, nächste Fälligkeit Okt. 2026 – Jun. 2027. |
| `hold` | **9** (#4, #8, #10, #14, #15, #16, #17, #18, #19) | **Karteileichen.** Zahlungshistorie durchgehend `hold` seit 2018–2022 (z. B. #8: letzte Zahlung 12/2017, seither 8× unbezahlt). Darunter genau die beiden Kunden, an die **MAN-00001/00002** gestellt wurden (User 59455: 5× hold seit 2021, User 60911: nur hold, nie gezahlt) – und die beiden „blockierten" Posten #15/#18 aus L6. |
| `reminder` | 5 (#12, #13, #20, #21, #23) | **Offene Vorgänge 2026.** Rechnungen vom 30.06.2026, im Altsystem bereits gemahnt, fällig 14.07.2026, Leistungszeitraum bis Mitte 2027. Gemischte Historie (#13: 2024/25 bezahlt; #12: seit 2020 hold, 2026 trotzdem gemahnt). |
| `pending` (recurring) | 1 (#1, User 9338) | **Offener Vorgang.** 49 € Presseecho, Rechnung 07.07.2026, Kunde seit 2011, zuletzt wechselnd. |
Die sechs offenen Vorgänge haben eine Tücke: Ihre Legacy-Rechnung (Juni/Juli 2026) ist **unbezahlt**,
aber die Migration hat `current_period_end` bereits auf **2027** gesetzt (= `next_due_date` der
Rechnung). Der MAN-Kreis würde sie also erst 2027 wieder abrechnen – die offene 2026-Forderung
kennt das neue System nicht.
Daneben gibt es **7 `pending`-Einzelkäufe** (19,95 €, Juli 2026, BP24): Gmail-/SEO-Accounts, PM ist
jeweils `published`, nie bezahlt. Kein Bestandsschutz betroffen, aber Teil des „sauber abarbeiten".
Die 94 `hold`-Archivrechnungen (17.299,93 €, 2018–2026) haben alle eine User-Zuordnung.
Was bereits **mitgenutzt** werden kann (kein Neubau nötig):
- Globales Mail-Gate `GateOutgoingMail` (Bounce-Sperrliste) → jede neue Mailable ist automatisch geschützt.
- Mail-Layout `x-emails.layout` / `x-emails.button`, Muster `BookingPaymentConfirmed` (BCC an `legal.contact_email`).
- `ActivityLog` für den Verlauf, Permission `billing:manage`, Confirmation-Modals im Payments-Panel (Vorbild: „Bündel kündigen").
- PDF-Primitiven (`text()`, `line()`, `buildPdf()`) im `LegacyInvoicePdfRenderer` – ohne externe PDF-Library.
---
## 2. Zielbild: der Workflow in vier Schritten
```
04:30-Lauf Admin (Knopf) Admin (Knopf) Admin (Knopf)
─────────── ───────────── ───────────── ─────────────
Periode endet ──▶ MAN-Rechnung ──▶ „Rechnung senden" ──▶ „Als bezahlt markieren" oder
(status open) Mail + PDF-Anhang paid_at, status paid „Bestandsschutz beenden"
due_date = +14 Tage status void + Bündel gekündigt
sent_at gesetzt (cancelBundleFor)
│
└──▶ optional „Erinnerung senden"
(gleiche Mail, Mahn-Ton, reminded_at)
```
Rechnungen werden **nicht gelöscht**, sondern auf `void` („Storniert") gesetzt: die
MAN-Nummernfolge bleibt lückenlos, Buchhaltung/GoBD sind sauber, und der Vorgang bleibt
nachvollziehbar. Löschen im Wortsinn passiert beim **Bestandsschutz** (Posten → `cancelled`),
nicht bei der Rechnung.
---
## 3. Entwicklungsplan
Vier Phasen, jede für sich testbar und deploybar. Phase 0–3 ergeben zusammen den
gewünschten Knopfdruck-Workflow; Phase 4 ist optionale Automatisierung.
### Phase B — Bereinigung der Altlast (zuerst, unabhängig vom Rest) · Aufwand: klein
Ein einmaliger, wiederholbarer Befehl `legacy:settle-unpaid` (`--dry-run`, JSON-Report wie die
anderen Migrationsbefehle), der die Befunde aus 1.4 auflöst:
1. **`hold` → erledigt.**
- Die **9 aus `hold` abgeleiteten Bestandsschutz-Posten** werden gekündigt
(`cancelBundleFor()` je User, mit `ActivityLog`-Eintrag „Bestandsschutz beendet: Quellrechnung
nie bezahlt"). Damit fallen auch die „blockierten" Posten #15/#18 aus L6 weg – die brauchen
keine Rechnungsadresse mehr.
- **MAN-00001/00002** werden storniert (`void`, Grund „Kunde hat Vorjahresrechnung nie bezahlt") —
nicht versendet. Damit erledigt sich E6.
- Die 94 `hold`-Archivrechnungen: Vorschlag **im Archiv belassen** (read-only, D-12; Löschen von
Rechnungsdatensätzen ist auch bei nie bezahlten unschön), aber im Admin-Archiv standardmäßig
ausblenden (Filter „ohne hold"), und in der Kundenansicht `/admin/me/invoices` nicht mehr als
„offen" anzeigen. Wenn du sie wirklich weg haben willst: `--delete-hold` als bewusste Option,
löscht die 94 Zeilen (→ E7).
2. **`reminder`/`pending` (recurring, 6 Posten) → in den neuen Workflow überführen.**
Einfachster Weg: die Periode der 6 Posten **um ein Jahr zurückdrehen** (`current_period_start/end`
= Werte der offenen Legacy-Rechnung, also Ende = 30.06./07.07.2026). Dann stellt der nächste
04:30-Lauf ganz normal eine MAN-Rechnung für 2026/27 aus, und ab da gilt der Knopfdruck-Workflow
aus Phase 2/3 (senden → 14 Tage → bezahlt oder beenden). Die Legacy-Rechnung wird im Archiv als
`superseded` markiert (neue Status-Zeichenkette, verweist auf die MAN-Nummer), damit nicht zwei
Forderungen für denselben Zeitraum nebeneinander stehen. Kein zweiter Rechnungsweg, kein
Sonderfall im Code.
3. **7 `pending`-Einzelkäufe (19,95 €).** Fachliche Entscheidung (→ E8): entweder als `uncollectible`
abschreiben (Vorschlag – Gmail-/SEO-Accounts, Aufwand > Ertrag) oder die PM depublizieren.
Technisch ist beides ein Status-Update im Archiv bzw. ein Aufruf der bestehenden Depublikation.
4. **Ursache beheben:** `GrandfatherLegacySubscriptions` bekommt einen Filter auf den Zahlungsstatus
der Quellrechnung (`paid` → Bestandsschutz; `pending`/`reminder` → Bestandsschutz **mit** offener
Forderung, Periode = Rechnungsperiode; `hold` → überspringen). Der Befehl ist laut Doku
replay-fähig; so kann das nicht noch einmal passieren.
**Tests:** `LegacySettleUnpaidCommandTest` (hold-Posten gekündigt, MAN-Rechnungen void, reminder-Posten
zurückgedreht, paid-Posten unangetastet, dry-run schreibt nichts); `GrandfatherLegacySubscriptionsTest`
um die drei Status-Fälle erweitern.
**Ergebnis nach Phase B:** 7 gesunde Bestandskunden + 6 offene Vorgänge, die der MAN-Kreis binnen
eines Tages als Rechnungen ausstellt. Erst dann lohnen sich die Knöpfe.
### Phase 0 — Datenmodell & Konfiguration (Fundament) · Aufwand: klein
1. **Migration `invoices`** (Tabelle ist winzig, kein FULLTEXT → unkritisch):
- `user_payment_option_id` (nullable FK) — Link zur Vereinbarung (schließt L3)
- `sent_at`, `reminded_at`, `reminder_count` (tinyint) — Versandhistorie (L5)
- `voided_at`, `void_reason` (string, nullable) — Nachvollziehbarkeit beim Storno
2. **`ManualInvoiceService::invoiceFor()`** setzt künftig `user_payment_option_id`.
3. **Backfill** für MAN-00001/00002 in derselben Migration (User 59455 → UPO #14, User 60911 → UPO #16;
eindeutig, weil je genau ein Grandfathered-Posten) – auch wenn beide in Phase B storniert werden,
soll der Storno an der richtigen Vereinbarung hängen.
4. **`config/billing.php` → Block `issuer`** (Absender der MAN-Rechnungen, alles per `.env` überschreibbar):
Firmenname, Inhaber, Straße, PLZ/Ort, Telefon, E-Mail, Web, IBAN, BIC, Bank, Steuernummer,
USt-ID (letztere existiert schon als `own_vat_id`). Schließt L7 — und ist gleichzeitig die
Datenquelle für die Mail (Bankverbindung) und das PDF.
5. **`InvoicePolicy`** (analog `LegacyInvoicePolicy`): `view`/`downloadPdf` für Eigentümer und Admin,
`manage` = `billing:manage`.
**Tests:** Migration + Backfill (Pest, `RefreshDatabase`), `invoiceFor()` schreibt `user_payment_option_id`.
### Phase 1 — PDF für MAN-Rechnungen · Aufwand: mittel
1. PDF-Primitiven aus `LegacyInvoicePdfRenderer` in einen Trait `App\Services\Billing\Concerns\DrawsPdf`
(oder abstrakte Basisklasse) ziehen — reine Verschiebung, Legacy-Renderer bleibt funktional identisch.
2. **`ManualInvoicePdfRenderer`** (für `Invoice`): Absender aus `billing.issuer`, Empfänger aus
`invoice_billing_addresses`-Snapshot, Leistungsname aus `legacy_conditions.name` bzw. `payment_option`,
Leistungszeitraum aus der Vereinbarung (`current_period_start/end` **vor** dem Weiterschalten —
dafür genügt: Periodenstart = `invoice_date`, Ende = +1 Jahr/Monat), Netto/USt/Brutto,
`tax_note` (Reverse-Charge/Drittland), Zahlungsziel `due_date`, Bankverbindung.
Ergebnis wird **nicht** gespeichert (wie beim Archiv on-demand gerendert); `pdf_path` bleibt frei
für eine spätere Ablage.
3. **Routen**: `admin/invoices/{invoice}/pdf` (Admin) und `me/invoices/{invoice}/pdf` (Kunde),
Controller `InvoicePdfController`, Policy-Check; für STR-Rechnungen 404 (dort bleibt Stripe der Beleg).
4. **Kundenkonto**: in `/admin/me/invoices` Spalte „Beleg" für MAN → Button „PDF öffnen" statt „–".
**Tests:** Renderer liefert gültiges PDF (`%PDF-`-Header, Rechnungsnummer/Beträge im Stream), Route
403/404-Fälle, Legacy-Renderer-Snapshot unverändert (bestehende Tests grün).
### Phase 2 — Mail & Service (die eigentliche Logik) · Aufwand: mittel
1. **`ManualInvoiceDunningService`** (neu, `App\Services\Billing`), kapselt die vier Aktionen —
jede transaktional, jede mit `ActivityLog`-Eintrag (neuer `ActivityType::ManualInvoice`):
- `send(Invoice, User $actor)` — nur `status = open` und ohne `stripe_invoice_id`;
setzt `sent_at`, **`due_date = heute + manual_due_days`** (die 14 Tage zählen ab Zustellung,
nicht ab dem 04:30-Lauf — s. Entscheidung E1), verschickt Mailable.
- `remind(Invoice, User $actor)` — nur wenn `sent_at` gesetzt; `reminded_at`, `reminder_count++`,
gleiche Mailable im Mahn-Ton (Variante über Konstruktor-Flag).
- `markPaid(Invoice, Carbon $paidAt, User $actor)` — `status = paid`, `paid_at`.
- `terminateForNonPayment(Invoice, User $actor, ?string $note)` — `status = void`, `voided_at`,
`void_reason`; ruft `LegacySubscriptionService::cancelBundleFor($invoice->user)`; **weitere offene
MAN-Rechnungen desselben Users** werden ebenfalls storniert (ein Kunde, ein Bündel, ein Vorgang).
2. **Mailable `ManualInvoiceIssued`** (queued, `ShouldQueue`, BCC an `legal.contact_email` wie
`BookingPaymentConfirmed`), PDF als **Anhang** (`attachData` aus dem Renderer), View
`emails/billing/manual-invoice-issued.blade.php`:
- Anrede, Bezug auf Bestandsschutz-Leistung und Portal, Rechnungsnummer/-datum, Netto/USt/Brutto
- **Bankverbindung** (aus `billing.issuer`) und Verwendungszweck (= Rechnungsnummer)
- Zahlungsziel als Datum (`due_date`)
- Klar formulierter Hinweis: *„Geht die Zahlung nicht bis zum {due_date} ein, endet Ihr
Bestandsschutz (…) zu diesem Datum. Ihre bisherigen Pressemitteilungen bleiben online; neue
Veröffentlichungen sind dann nur über die aktuellen Tarife möglich."* (Wortlaut → E3)
- Button „Rechnung im Konto ansehen", Hilfe-Link
- Mahn-Variante: Betreff „Zahlungserinnerung …", Einleitung „wir haben noch keinen Zahlungseingang
festgestellt", Frist identisch (`due_date`), sonst gleicher Inhalt.
- Transaktionsmail → kein Opt-out; Bounce-Sperrliste greift automatisch über `GateOutgoingMail`.
3. **Kunden-Mail bei Beendigung?** Vorschlag: **ja, eine kurze Bestätigung** („Bestandsschutz beendet,
Rechnung storniert, so buchen Sie neu"). Ist eine zweite kleine Mailable `GrandfatheringTerminated`.
Kann in Phase 3 mit einer Checkbox im Modal optional gemacht werden (→ E4).
**Tests (`tests/Feature/Billing/ManualInvoiceDunningTest.php`):** `Mail::fake()`; send setzt `sent_at`
und neue `due_date`, Mail an User mit PDF-Anhang und BCC; send auf `paid`/STR-Rechnung wirft
Exception; remind ohne send verweigert; markPaid; terminate → Rechnung `void`, alle Grandfathered-Posten
`cancelled`, zweite offene MAN-Rechnung ebenfalls `void`; gesperrte Adresse → Mail wird vom Gate
gestoppt, `sent_at` bleibt trotzdem gesetzt? → **nein**: Service prüft vorher
`EmailSuppressionService::isSuppressed()` und verweigert mit Hinweis (Admin soll es sehen).
### Phase 3 — Admin-Oberfläche · Aufwand: mittel
Alles in der bestehenden Volt-Seite `livewire/admin/payments/index.blade.php`, kein neuer Screen:
1. **Tabelle „Rechnungsausgang (STR/MAN)"** — neue Spalte „Aktionen" (nur MAN-Zeilen):
- „PDF" (öffnet Phase-1-Route)
- Status `open`, noch nicht gesendet → **„Rechnung senden"** (Modal: Empfänger-Adresse, Betrag,
neues Zahlungsziel, Warnung falls Adresse gesperrt)
- Status `open`, gesendet → **„Erinnerung senden"**, **„Als bezahlt markieren"** (Modal mit
Datumsfeld, Default heute), **„Bestandsschutz beenden"** (Danger-Modal wie „Bündel kündigen",
zeigt die betroffenen Posten + weitere offene Rechnungen des Users, optional Checkbox „Kunde
informieren")
- Zusatzinfos in der Status-Zelle: „gesendet 17.09.", „erinnert 01.10. (1×)", Badge **„überfällig"**
wenn `due_date < heute` und `open`, „storniert" mit Grund-Tooltip.
2. **Filter/Tab „nur MAN offen"** oberhalb der Tabelle (Standardansicht dieses Vorgangs), KPI-Karte
**„Offene MAN-Rechnungen"** (Anzahl + Summe, davon überfällig).
3. **Panel „Blockierte Vereinbarungen"** (schließt L6; nach Phase B aktuell leer, bleibt aber als
Frühwarnung sinnvoll): Grandfathered-Posten mit `current_period_end <
heute`, für die keine offene MAN-Rechnung existiert — mit Grund („keine Rechnungsadresse") und
Link auf `admin.users.edit`. Sobald die Adresse gepflegt ist, stellt der nächste 04:30-Lauf die
Rechnung aus. Alternativ Button **„Jetzt abrechnen"** (ruft `invoiceFor()` sofort auf) — spart das
Warten bis zum nächsten Morgen.
4. **Abrechnungs-Sektion im Bestandsschutz-Panel**: pro Posten die letzte MAN-Rechnung (Nummer, Status)
inline anzeigen, damit man vom Kunden zur Rechnung kommt, nicht nur umgekehrt.
**Tests (`AdminPaymentsPageTest` erweitern):** Buttons nur für `billing:manage`; `send` aus der Seite
ruft Service (Mail::fake); `markPaid`, `terminate` mit Erfolgsmeldung; Blockiert-Panel listet UPO ohne
Rechnungsadresse.
### Phase 4 — Optional: Automatisierung · Aufwand: klein, aber Entscheidungssache
Erst nach ein paar Wochen Handbetrieb sinnvoll, wenn der Wortlaut und der Ablauf stehen:
- `billing:generate-manual-invoices` versendet direkt nach Erzeugung (`--send`), statt auf den Knopf
zu warten.
- Scheduler-Task `billing:remind-manual-invoices`: automatische Erinnerung z. B. 7 Tage nach
Zahlungsziel, **nie** automatisches Beenden — das bleibt bewusst ein Klick.
- Tägliche Admin-Notiz (bestehende Monitoring-Mail-Infrastruktur) „N MAN-Rechnungen > 14 Tage
überfällig".
---
## 4. Entscheidungen, die ich brauche
| # | Frage | Vorschlag |
|---|---|---|
| **E1** | Läuft die 14-Tage-Frist ab **Rechnungsdatum** (04:30-Lauf) oder ab **Versand**? Der Knopf kann Tage später gedrückt werden. | Ab Versand: beim „Senden" `due_date = heute + 14`. Rechnungsdatum bleibt das Erzeugungsdatum. |
| **E2** | Aussteller: **adametz.media für beide Portale**? (Presseecho-Altrechnungen liefen auf Stern Consulting GmbH.) | Ja, ein Aussteller, in `billing.issuer` konfiguriert. Bitte Bankverbindung/Steuernummer bestätigen — ich übernehme sonst die Werte aus dem Legacy-Renderer (Sparkasse Bielefeld, DE96 …, St.-Nr. 349/5001/4350, USt-ID DE298729654). |
| **E3** | Wortlaut des Bestandsschutz-Hinweises — insbesondere: bleiben veröffentlichte PMs online? Was passiert mit der API (Bestandskunden mit Token)? | Mein Entwurf in Phase 2; du korrigierst den Text im Blade. |
| **E4** | Soll der Kunde bei „Bestandsschutz beenden" eine Mail bekommen? | Ja, optional per Checkbox im Modal (Default: an). |
| **E5** | Bestätigung Phase B: alle **9** aus `hold` abgeleiteten Bestandsschutz-Posten kündigen – auch #13-artige Grenzfälle gibt es dort nicht, alle haben mehrjährige hold-Ketten. Betroffen sind u. a. #18 (1.000 € netto, seit 2022 nie bezahlt). | Ja, alle 9 kündigen. |
| **E6** | MAN-00001/00002 gehören zu zwei dieser hold-Kunden. | Stornieren, nicht senden (erledigt sich mit E5). |
| **E7** | Die 94 `hold`-Archivrechnungen: im Archiv behalten und ausblenden, oder physisch löschen? | Behalten + ausblenden. Löschen nur auf ausdrücklichen Wunsch (`--delete-hold`). |
| **E8** | 7 unbezahlte Einzelkäufe (19,95 €, Juli 2026, PMs online): abschreiben (`uncollectible`) oder PM depublizieren? | Abschreiben; optional zusätzlich depublizieren, wenn du das Signal setzen willst. |
| **E9** | Die 6 offenen Vorgänge (`reminder`/`pending`): Periode zurückdrehen und über den MAN-Kreis neu ausstellen (Rechnungsdatum = jetzt, Zahlungsziel +14 Tage ab Versand) — auch wenn der Kunde im Altsystem schon eine Rechnung vom 30.06. hat? | Ja: eine saubere neue Rechnung mit Hinweis „ersetzt Rechnung Nr. … vom 30.06.2026" im Text, statt zwei Rechnungswege zu pflegen. |
---
## 5. Reihenfolge & Aufwand (grob)
| Phase | Inhalt | Aufwand | Abhängig von |
|---|---|---|---|
| B | `legacy:settle-unpaid` (hold kündigen/stornieren, offene Vorgänge zurückdrehen), Grandfather-Filter | ~3 h | E5, E7, E8, E9 |
| 0 | Migration, Backfill, `billing.issuer`, Policy | ~2 h | E2 |
| 1 | PDF-Renderer, Routen, Kundenkonto-Button | ~4 h | 0 |
| 2 | Service + Mailable(s), Tests | ~4 h | 0, 1, E1, E3 |
| 3 | Admin-Aktionen, Blockiert-Panel, KPI | ~4–5 h | 2, E4 |
| 4 | Automatisierung | ~2 h | Erfahrung aus Handbetrieb |
**Einfachster Weg zum Ziel = Phase B → 0 → 1 → 2 → 3**, etwa 2 Arbeitstage inkl. Tests. Phase B
ist dabei die wichtigste: ohne sie würden die neuen Knöpfe Rechnungen an neun Kunden schicken, die
seit Jahren nicht zahlen, und die sechs echten offenen Forderungen aus 2026 blieben unsichtbar. Alles läuft
in bestehenden Dateien/Mustern (Volt-Seite, Mail-Layout, PDF-Primitiven, ActivityLog), keine neue
Abhängigkeit, keine neue Tabelle — nur fünf Spalten auf `invoices`.
Nach dem Deploy: Doku `docs/user-admin/Billing-und-Rechnungskreise.md` Abschnitt 3 und 7 (Punkt 4
„PDF-Erzeugung") aktualisieren.
---
## 6. Betroffene Dateien (Überblick)
```
neu
app/Console/Commands/SettleUnpaidLegacyInvoices.php (Phase B, `legacy:settle-unpaid`)
tests/Feature/Billing/LegacySettleUnpaidCommandTest.php
app/Services/Billing/ManualInvoicePdfRenderer.php
app/Services/Billing/ManualInvoiceDunningService.php
app/Services/Billing/Concerns/DrawsPdf.php (aus LegacyInvoicePdfRenderer extrahiert)
app/Mail/ManualInvoiceIssued.php
app/Mail/GrandfatheringTerminated.php (E4)
app/Http/Controllers/InvoicePdfController.php
app/Policies/InvoicePolicy.php
database/migrations/2026_09_xx_add_dunning_columns_to_invoices_table.php
resources/views/emails/billing/manual-invoice-issued.blade.php
resources/views/emails/billing/grandfathering-terminated.blade.php
tests/Feature/Billing/ManualInvoiceDunningTest.php
tests/Feature/Billing/ManualInvoicePdfTest.php
geändert
app/Console/Commands/GrandfatherLegacySubscriptions.php (Filter auf Zahlungsstatus der Quellrechnung)
resources/views/livewire/admin/invoices/index.blade.php (hold standardmäßig ausblenden)
app/Models/Invoice.php (fillable, casts, Relation userPaymentOption, Scopes manual/openManual)
app/Services/Billing/ManualInvoiceService.php (user_payment_option_id setzen)
app/Services/Billing/LegacyInvoicePdfRenderer.php (Primitiven in Trait)
app/Enums/ActivityType.php (ManualInvoice)
config/billing.php (issuer-Block)
routes/admin.php, routes/customer.php (PDF-Routen)
resources/views/livewire/admin/payments/index.blade.php
resources/views/livewire/customer/invoices.blade.php
tests/Feature/Billing/AdminPaymentsPageTest.php
tests/Feature/Billing/ManualInvoiceGenerationTest.php
```

View file

@ -0,0 +1,81 @@
# Roadmap / Offene Punkte
Stand: 20.07.2026 (Go-Live-Tag). Kurzfristiges zuerst; Details zu erledigten
Phasen im Archiv (`dev/archive/`).
> **Hinweis 29.09.2026:** Die laufende Planung steht in
> `dev/2026-09-01/roadmap-2026.md`, der Entwicklungsstand in
> `dev/2026-09-11-audit/ENTWICKLUNGSPLAN.md` und im jüngsten Umsetzungsaudit.
> Die Punkte unten stammen vom Go-Live-Tag; ihr Stand ist dort nicht
> nachgeführt und wird im Umsetzungsaudit vom 29.09. (Abschnitt 6.4) als „auf
> Live zu bestätigen" geführt.
## Nachlauf Go-Live (kurzfristig)
- [ ] **Go-Live-Mails** an Bestandskunden: erst `php artisan
auth:send-go-live-mails --dry-run` (Anzahl prüfen), dann scharf.
Massenversand läuft über die Queue → Worker-Status vorher grün,
Betriebsüberwachung während des Versands im Blick.
- [ ] **API-Kunden-Mailing** (Connektar & Co.) mit Hinweis auf neue Doku
(`/docs/api/v1`) und Token-Migration — Texte [EXTERN].
- [x] **Git-Hygiene (R6.5):** erledigt 20.07. — `fix/zeitzone-europe-berlin`
nach `main` gemerged (`7b27ec5`), Release-Tag **v1.0.0**, beides auf
origin. Künftig Feature-Branches von `main`.
- [ ] **Stripe-Test-Artefakte bereinigen:** Test-`stripe_id` am Testuser,
Test-Subscription-Zeilen, STR-Testrechnungen — damit die
Rechnungsnummern-Sequenz sauber ist.
- [ ] **Monitoring scharf:** `MONITORING_ALERT_EMAIL` gesetzt? Cron/Scheduler
+ Queue-Worker in Ploi überwacht?
- [ ] **Umami-Snippets** je Domain einbinden (`dev/betrieb/umami.md`).
- [ ] **Connektar-API-Keys rotieren** (mittelfristig — die Legacy-Keys sind
über diverse Test-Kanäle gelaufen; Rotation über `/customer/tokens`).
- [ ] **Alt-System endgültig abschalten**, sobald ein paar Tage stabil:
Webspace und Alt-DBs auf dem KAS stilllegen; vorher letztes Backup der
Alt-DBs und Alt-Uploads sichern. Danach kann auch `dev/migration/` (7 GB
lokales Medien-Staging) auf ein Backup-Medium und vom Arbeitsrechner
runter. **Das KAS-Hosting selbst bleibt** (E-Mail-Postfächer, Stand
06.10.2026); der SSH-Zugang des Altsystems ist geändert und deaktiviert.
## Beobachten (erste Wochen)
- [ ] Betriebsüberwachung täglich: failed_jobs, Worker, Medien-Privatisierung.
- [ ] `storage/logs/laravel.log` auf neue Fehlermuster.
- [ ] Search Console: Indexierung der neuen Domains + 301-Ketten der
Legacy-URLs (www-Redirects, .html-Redirects, presskit→Newsroom).
- [ ] MySQL-Auslastung (die Filesort-Spirale ist behoben; bei neuen
Slow-Queries → Slow-Query-Log).
## Nächste Entwicklungen / Optimierungen (Ideen-Backlog)
- [x] **Volt entfernen, auf Livewire-4-Single-File-Components umstellen**
— **erledigt 06.10.2026** (Branch `technik/volt-entfernen`). 83
Komponenten auf `Livewire\Component`, 73 Routen auf
`Route::livewire()`, 88 Testdateien auf `Livewire::test()`/
`Livewire::actingAs()`; `VoltServiceProvider` gelöscht,
`livewire/volt` entfernt (sonst keine Paketänderung).
`config/livewire.php` auf v4-Schlüssel: `component_layout`,
`component_placeholder`, `component_locations`, `make_command` (SFC
ohne ⚡). Befund dabei: Der alte Schlüssel `layout` war wirkungslos,
Livewire 4 legte seinen Standard `layouts::app` darüber; alle 73
gerouteten Komponenten setzen ihr Layout aber selbst. CLAUDE.md,
AGENTS.md, `boost.json`, `.cursorrules` und der Skill
`volt-development` angepasst bzw. entfernt (von Hand —
`boost:update` hätte nebenbei alle Skills auf eine neue
Boost-Fassung gehoben, das ist ein eigener Schritt). Suite 2.325
grün, einer übersprungen; Larastan und Pint ohne Meldung.
**Larastan erfasst die Komponenten weiterhin nicht**: Es analysiert
nur `.php`-Dateien, Single-File-Komponenten sind Blade-Dateien.
Abhilfe wäre die Umwandlung einzelner großer Komponenten in
Klassen-Komponenten (`php artisan livewire:convert`), kein Muss.
- [ ] Frontend-Audit-Reste aus `dev/archive/frontend-audits/
frontend-optimize-protokoll.md` sichten — P2/P3-Punkte, die bewusst
liegen geblieben sind.
- [ ] Offene [EXTERN]-Entscheidungen aus dem Release-Plan prüfen
(`dev/archive/release/entwicklungsplan-release.md`): Preisfreigabe,
Refund-Policy.
- [ ] Cache-Warmer im Scheduler (Startseite + Top-Kategorien alle 5 Min
vorbauen), falls Kaltaufbauten nach Deploys stören — Option B aus der
Performance-Session vom 20.07.; aktuell nicht nötig.
- [ ] Redis als Cache-/Queue-Store erwägen, wenn die Last wächst
(aktuell `database`-Store, läuft stabil).