dev: Umsetzungsaudit vom 18.09. abgelegt, Block 7 mit Abgleich, Entscheidungen E7–E9 und Protokoll im Entwicklungsplan

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-18 12:22:43 +00:00
parent 082e6bb76c
commit 1c421c0377
2 changed files with 539 additions and 8 deletions

View file

@ -177,6 +177,60 @@ Beide latent. Aufnehmen, weil die Fixes klein sind und der Fehler beim ersten Da
--- ---
## 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, sobald `portal` oder `category_id` im Request steht. |
| 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. Empfehlung: automatische Zuordnung beim Wegfall der Grundlage entfernen statt senken. |
**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.
---
## Nicht aufgenommen ## 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. - **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.
@ -196,17 +250,19 @@ Tatsächlich umgesetzt am 18.09.2026 in fünf Commits auf `technik/betriebsfixes
| C | Block 1, Punkte 1.3, 1.4, 1.5, 1.7 | `62202a4` | `company_user.granted_via` | | 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) | | 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` | | E | Block 5 und Block 6 | `375f4df` | `user_payment_options.billing_hold_reason/-at` |
| F | Block 7 (ohne 7.13) | `235d392`, `1cd9fd1`, `ea68362` | `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. Migrationen berühren `press_releases` und `press_release_search` nicht.
### Live-Schritte nach dem Deploy ### Live-Schritte nach dem Deploy
1. `php artisan migrate --force` (sechs Migrationen, alle kurz; `users` als einziges COPY, nachts einspielen). 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`, damit erklärbare Rechte als `auto` gelten und der Login-Sync sie zurücknehmen kann. 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. 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). 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. 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. 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 ## Entscheidungen
@ -220,17 +276,28 @@ Am 18.09.2026 alle sechs nach Empfehlung entschieden. Umsetzung ab demselben Tag
| E4 | Newsletter-Versandreihenfolge fachlich relevant | nein | | E4 | Newsletter-Versandreihenfolge fachlich relevant | nein |
| E5 | Kontingentperiode | Kalendermonat | | E5 | Kontingentperiode | Kalendermonat |
| E6 | Letzte MAN-Periode am Vertragsende | keine Rechnung mehr | | E6 | Letzte MAN-Periode am Vertragsende | keine Rechnung mehr |
| E7 | U01: automatische Zuordnung bei Wegfall der Grundlage | **offen.** Empfehlung: Zuordnung entfernen. `member` ist die normale Teamrolle mit PM-Schreibrechten; eigene PMs bleiben über die Autorenschaft erreichbar. Alternative Leserolle wäre eine neue Rolle (L). |
| 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) |
## Umsetzungsprotokoll ## Umsetzungsprotokoll
| Datum | Punkte | Commit | Bemerkung | | Datum | Punkte | Commit | Bemerkung |
|---|---|---|---| |---|---|---|---|
| 18.09.2026 | 1.1, 1.2, 1.6, 1.8, 1.9, 1.10 | `0ab53bf` | Fortify-Profilendpunkt aus, Sync verlangt verifiziertes aktives Konto, Google-2FA, `#[Locked]`, Gate nur für aktive Super-Admins, Route unter `can:users:manage` | | 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 | 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`, Entzug beim Login, TOTP-/Recovery-Nachweis; passwortlose Konten (66.000 im Bestand) können die Adresse weiter ändern | | 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)`, 39 Ausnahmen per Architekturtest eingefroren; `EmailQuery` (MySQL direkt, SQLite LOWER); Firmen-Observer; `ReportedUrlResolver` | | 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 | 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 | | 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 | 6.1–6.4 | `375f4df` | `pint --test`, `dev:all`, Doku-Einstieg, Fokusführung ohne neue Abhängigkeit |
Offen aus dem Plan: 6.6 (MySQL-/Worker-Integrationstests) bleibt aufgeschoben. 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. | 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 |
Gesamtsuite nach Block 7: 1.930 Tests grün, einer übersprungen; PHPStan ohne Meldung; Pint sauber. Ein Test (`ApiPortalResolutionTest`) brauchte wegen der neuen Kategorieprüfung eine gemeinsame Kategorie statt einer zufälligen.
Offen: 7.13 (U01) wartet auf E7; U05–U07 nach E9 aufgeschoben; 7.6 (Fokusführung) manuell im Browser abnehmen.
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.

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.