Report · Architektur-Audit · Deepening + Schwachstellen · 2026-06-19

Projektportal Architektur-Audit: Schwachstellen und Deepening-Opportunities

Code-Architektur-Audit des gesamten Projektportals (PPP) — die beiden Modulverbünde solcom_project_offer (Salesforce-/RSM-Projektangebot-Schiene) und solcom_projektpartner (Registrierung, Profil, Login, Neos-Migration) inklusive aller Submodule. Diese Achse ergänzt die fünf bestehenden Projektportal-Reports, die funktionale Parität und Journey-Gaps abdecken: hier geht es um Architektur-Tiefe, Drupal-Idiomatik, Security und Test-Oberfläche. Jeder Befund ist gegen den echten Code mit file:line belegt und durch einen adversarialen Verifikations-Pass gegangen (real / nicht durch accepted ADR entschieden / besteht den Deletion-Test).

Branch: feature/fix-rsm-profile-application Commit: 516984a Stand: 19.06.2026 Go-Live: 08.07.2026 (MVP) Scope: solcom_project_offer (+_alert/_feeds/_rsm_import), solcom_projektpartner (+_alert)
33 bestätigte Befunde Nach adversarialer Verifikation. 8 weitere Audit-Verdachtsfälle wurden verworfen (Deletion-Test, accepted ADR oder falsche Prämisse) — siehe §7.
Schwerpunkt: SF-Sync & Security Die schwersten Befunde sitzen in der Salesforce-Sync-Schiene (Hard-Delete-Reconciliation, high_water) und in 6 Security-/Datenschutz-Punkten (XSS, Mail-Bombing, Private-File-Owner, IDOR).
1 critical, 8 high Ein kritischer ungetesteter Mass-Delete-Pfad, acht hochpriore Defekte. 15 defect-now, 2 security, 8 deepening-postmvp, 7 idiom (Kategorien überschneiden sich teils).
Architektur ist im Kern gesund Die Watchlist-Trias, der Profil-Datensatz als eigene Entity und die Migrate-Plugin-Trennung sind solide. Die meisten Deepening-Punkte sind Locality-Konsolidierungen, kein Rewrite.

1. Methodik & Vertrauensgrad

Der Audit lief als orchestrierter Workflow in drei Phasen. Phase 1 (Digest) hat die fünf bestehenden Projektportal-Reports plus die projektportal-relevanten ADRs zu einem Exclusion-Brief verdichtet (damit dieser Audit keine bereits gefundenen oder bewusst auf Post-MVP verschobenen Gaps neu meldet) und drei gezielte Drupal-11-Idiomatik-Recherchen gefahren (Migrate für laufende Sync, Private-File-Owner-Zugriff, QueueWorker-Idempotenz). Phase 2 (Audit) hat zehn Analyse-Slices über das Projektportal aufgefächert — sechs davon mit den spezialisierten Drupal-Reviewer-Personas (Security, Performance, Config, Test-Coverage, Migration-Safety, Architektur) als Regel-Korpus. Phase 3 (Verify) hat jeden Befund einem skeptischen Verifikations-Pass unterzogen: drei Checks gegen den echten Code — (1) ist das Problem an der file:line real, (2) ist es durch einen accepted ADR bereits entschieden, (3) besteht der Deletion-Test (konzentriert die vorgeschlagene Änderung Komplexität, oder verschiebt sie nur?).

Was die Verifikation geleistet hat — nicht nur bestätigt, sondern kalibriert: Der Verify-Pass hat Schweregrade korrigiert (eine angebliche „critical"-Cron-Schleife auf medium heruntergestuft, weil der Save bereits geguarded ist und Cron 3-stündlich läuft), gefährliche Fixes markiert (der naive Fix für die Hard-Delete-Reconciliation würde unter high_water fast den ganzen Katalog löschen) und einen Stil-Befund zum echten Defekt hochgestuft (CDATA-Breakout ist XML-Injection, nicht bloß Idiomatik). Ungeprüfte Audit-Schwere ist regelmäßig falsch kalibriert — die acht verworfenen Befunde (§7) zeigen den Filter in Aktion.

Vokabular (aus dem improve-codebase-architecture-Skill): Modul = Interface + Implementation; Seam = Ort, an dem Verhalten geändert werden kann, ohne dort zu editieren; tief = viel Verhalten hinter kleinem Interface; shallow = Interface fast so komplex wie Implementation; Deletion-Test = Modul gedanklich löschen — verschwindet Komplexität (Pass-Through) oder taucht sie bei N Callern wieder auf (verdient seinen Platz)?

2. Defekte vor Go-Live

Korrektheits-, Datenverlust- und Security-Befunde, die vor dem MVP-Go-Live (08.07.2026) adressiert gehören. Sortiert nach Schwere. Die Salesforce-Sync-Schiene trägt den schwersten Cluster — und die drei Hard-Delete-Befunde sind gekoppelt (siehe §6).

2.1 Sicherheit & Datenschutz

#BefundDatei(en)SchwereAufwand
S1
PP-PROFILE-1
≡ SEC-03
Migrierte Privatdokumente bekommen keinen uid — Owner kann eigene Dokumente nicht laden. NeosPrivateDocuments::transform() erzeugt die File-Entity nur mit uri/filename/status, ohne uid → Drupal setzt uid=0. Der Owner-only-Download-Hook fällt für uid=0 bewusst fail-closed aus. Folge: jeder aus Neos migrierte Projektpartner ist von seinen eigenen historischen Unterlagen ausgesperrt (nur Admins kommen ran). Von zwei Dimensionen unabhängig gefunden (Profil + Security) — hohe Konfidenz.
Fix: den per migration_lookup bereits aufgelösten Owner-uid beim create() der File durchreichen. Re-Import der bereits migrierten uid=0-Files nötig.
NeosPrivateDocuments.php:96-101 · neos_profile.yml:55-92 · solcom_projektpartner.module:1094-1111 high S
S2
SEC (Lauf 1)
JSON-LD-Block: JSON_UNESCAPED_SLASHES entfernt den eingebauten </script>-Breakout-Schutz — stored XSS. Der JobPosting-JSON-LD jeder Projektangebot-Seite wird mit json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) serialisiert und roh als #value ins <script type="application/ld+json"> geschrieben (kein Twig-Autoescape). Ein SF-Feldwert (Titel, Branche, Beschreibung) mit </script>...</script> wird verbatim ausgeführt. Jeder mit SF-Schreibzugriff kann stored XSS auslösen.
Fix: JSON_HEX_TAG ergänzen (oder JSON_UNESCAPED_SLASHES streichen). Gleiche Flags auch in der zweiten json_encode-Stelle (:322) prüfen. Reproduktions-Test mit </script> im Titel.
solcom_project_offer.module:47 (auch :322) high S
S3
SEC-01
Kein Flood-Schutz auf dem öffentlichen Alert-Subscribe-Formular — Mail-Bombing. submitForm() legt bei jedem anonymen Request sofort eine project_offer_alert-Entity an und verschickt eine Double-Opt-In-Mail an die frei wählbare Adresse — ohne Rate-Limiting, Honeypot oder CAPTCHA. Ein Angreifer kann beliebige Drittadressen mit Bestätigungsmails bombardieren und die Alert-Tabelle mit Junk füllen.
Fix: Core-FloodInterface in validateForm()/submitForm() (Fenster pro IP und pro E-Mail), idiomatisch und ohne neue Infrastruktur.
ProjectOfferAlertSubscribeForm.php:200-251 · *.routing.yml · user.role.anonymous.yml:18 high S
S4
SEC (Lauf 1)
CDATA-Breakout in den XML-Vendor-Feeds — jobTitle sogar ungefiltert. freelancermap- und freelance.de-Feeds wrappen Job-Felder in <![CDATA[ {{ p.feld|raw }} ]]>. Ein SF-Wert mit der Sequenz ]]> schließt den CDATA-Block vorzeitig → Folgeinhalt wird vom Vendor-Parser als Markup gelesen (XML-Injection). jobTitle (VendorFeedBuilder:301) läuft komplett ohne Sanitisierung; stripTagsKeepBr() macht htmlspecialchars (innerhalb CDATA semantisch falsch) und neutralisiert ]]> nicht.
Fix: ]]> in allen CDATA-Feldern inkl. jobTitle splitten (]]>]]]]><![CDATA[>) oder CDATA fallenlassen und Twig-XML-Autoescape nutzen. Well-formedness per DOMDocument::loadXML testen.
solcom-feed-freelancermap.html.twig:21-24 · solcom-feed-freelance.html.twig:25-28 · VendorFeedBuilder.php:301,351 medium S
S5
SEC-04
PppAlert-Entity nutzt Core-Default-AccessControlHandler ohne Owner-Enforcement — latentes IDOR. Heute kein aktuelles Risiko (Entity nur über das owner-scoped PppAlertForm erreichbar, admin_permission keiner Rolle zugewiesen). Wird latent zum IDOR, sobald ein generischer Endpoint (JSON:API, View, Route) hinzukommt.
Fix: dedizierten EntityAccessControlHandler mit Owner-checkAccess() registrieren — idiom-konform zum Schwester-Modul.
PppAlert.php:33-35 · solcom_projektpartner.module:1042-1066 low S
S6
CONFIG-02
Drei fcsb-Secret-Config-Keys sind tot und ein gefährlicher Footgun. fcsb_client_id/_secret/_refresh_token sind in Schema + settings.yml deklariert, werden aber nirgends gelesen (Secrets kommen aus dem key-Modul). Die Labels „FCSB Client Secret"/„Refresh Token" laden einen Maintainer ein, echte Geheimnisse nach config/sync/ (= Git) zu schreiben.
Fix: die drei Keys aus Schema + install + sync entfernen; nur fcsb_token_url behalten (der einzige real aus Config gelesene Wert).
solcom_project_offer.schema.yml:5-13 · solcom_project_offer.settings.yml · SalesforceClient.php:134-138 low S

2.2 Salesforce-Sync & Datenverlust

Gekoppelter Cluster — in dieser Reihenfolge fixen: Die Hard-Delete-Reconciliation ist heute ein stiller No-op (S7), und selbst nach dem String-Fix wäre die Vergleichslogik strukturell falsch (S8). Wer nur S7 fixt, aktiviert einen kaputten Sweep. Beide zusammen, plus der Empty-Feed-Test (T1), gehören als eine Einheit angefasst.
#BefundDatei(en)SchwereAufwand
S7
PO-SYNC-01
HardDeleteSubscriber und push-urls fragen das nicht existierende Bundle sf_publication ab — beide Operationen sind permanente No-ops. Die Migration schreibt nach Bundle project_offer; ein node.type.sf_publication.yml existiert nicht. Die Entity-Query liefert immer null Knoten → der Reconciliation-Sweep hat seit der Bundle-Umbenennung nie einen Stale-Knoten gelöscht, und drush solcom-sf:push-urls queued nichts. Beide melden Erfolg.
Fix: 'sf_publication''project_offer' an beiden Stellen — aber nur zusammen mit S8.
HardDeleteSubscriber.php:82 · SfPublicationCommands.php:68 high S
S8
PO-SYNC-02
Hard-Delete-Reconciliation ist strukturell inkompatibel mit den high_water-Inkrementalläufen. Der Sweep liest die kumulative migrate_map-Tabelle als „aktuelles SF-Set". Da high_water ohne --sync läuft, wächst die Map nur — ein SF-seitig gelöschter Record behält seine sourceid1 für immer, der in_array-Guard schlägt nie an. Stale-Knoten häufen sich unbegrenzt an. Der im Docblock behauptete Zwei-Bedingungs-Guard existiert im Code nicht.
Fix (und Achtung): NICHT gegen das „aktuelle Run-Set" vergleichen — unter high_water ist das nur eine Handvoll geänderter Records, das würde fast den ganzen Katalog löschen. Stattdessen gegen einen autoritativen Id-only-Vollpull (SELECT Id ... WHERE RecordType='Publication', kein high_water-Filter) reconcilen, plus Empty-Set-Abort.
HardDeleteSubscriber.php:67-76 · migrate_plus.migration.solcom_project_offer.yml:95-96 high M
S9
PO-SYNC-03
SOQL hat kein ORDER BY LastModifiedDate ASChigh_water kann geänderte Records still überspringen. Migrate erwartet die Quelle aufsteigend nach dem high_water-Feld sortiert. Bei ungeordneten SF-Ergebnissen kann der gespeicherte Mark unter das echte Maximum der Charge fallen; Records mit früherem Timestamp werden bei späteren Inkrementalläufen nie wieder importiert — stiller Datenverlust nach dem ersten Cron-Pull.
Fix: +ORDER+BY+LastModifiedDate+ASC an die SOQL-URL anhängen. Einzeiler.
migrate_plus.migration.solcom_project_offer.yml:11, 95-96 high S
S10
PO-SYNC-06
GeocodeCity cached negative Nominatim-Ergebnisse 30 Tage — ein transienter Ausfall vergiftet das Geocoding. Der contrib-Geocoder fängt transiente Exceptions (429, Timeout) und gibt NULL zurück; das Plugin cached dieses NULL mit derselben 30-Tage-TTL wie einen Treffer. Ein Nominatim-Ausfall während eines Bootstraps cached betroffene Städte einen Monat als „nicht geocodierbar" → field_geo bleibt leer, Standort-Filter brechen. (Das 1,1s-Rate-Limit-usleep ist korrekt, kein Defekt.)
Fix: Negativ-Resultate nicht oder nur mit kurzer TTL (z. B. 1h) cachen, damit ein transienter Ausfall beim nächsten Lauf selbst heilt.
GeocodeCity.php:132-141 medium S
S11
PO-SYNC-04
SalesforceUrlWriteback wirft bei jedem HTTP-Fehler eine RuntimeException — ein permanenter 4xx wird zum ewig loopenden Poison-Item. Core re-queued bei Exception ohne Retry-Limit/Backoff. Ein permanenter 404 (SF-Job gelöscht) oder 403 feuert jeden Cron-Lauf erneut und flutet das Watchdog-Log.
Fix: bei 4xx (permanent) loggen + return (Item acken/droppen), nur bei 5xx/Netzwerk werfen. 429 (Rate-Limit) vom Drop ausnehmen. Kein neues contrib-Modul.
SalesforceUrlWriteback.php:98-106 medium S

2.3 Alert / Suchagent — Korrektheit

#BefundDatei(en)SchwereAufwand
S12
PO-SUB-2
Alert-Matcher liest field_sf_utilisation (Auslastung) als Remote-Signal — „Remote ausschließen" kippt jeden SF-Offer aus dem Digest. Das Feld ist laut Config „Auslastung" (SC_Utilisation__c, z. B. '80'), nicht Remote-Anteil. matchesExcludedLocation() behandelt jeden Offer mit Auslastung > 0 als „remote". Hakt ein Abonnent „Remote ausschließen" an, bekommt er still einen leeren Digest.
Fix: den Remote-Zweig entfernen, bis ein echtes Remote-Anteil-Feld existiert (Layer-B P2503-148); bis dahin die Checkbox als No-op behandeln oder entfernen.
ProjectOfferAlertMatcher.php:259-262 · SubscribeForm.php:138-159 · field_sf_utilisation.yml medium S
S13
PERF-03
Alert-Cron-Digest: range(0,50) ohne orderBy — stille Starvation der Alerts jenseits 50. Die Fälligkeitsprüfung läuft in PHP nach dem Range, also verbrauchen nicht-fällige Alerts Slots der 50; ohne deterministische Sortierung werden Alerts bei Wachstum > 50 nie zuverlässig zugestellt.
Fix: ->sort('last_digest_sent','ASC') (NULLs zuerst) + ->sort('id','ASC') als Tiebreaker. Additiv, kein gemeinsamer Node-Pool (der verschöbe nur Komplexität).
solcom_project_offer_alert.module:99-104, 118 medium S
S14
PP-PROFILE-2
PppAlertCronWorker setzt den High-Water-Timestamp VOR dem Matching/Mailing — stiller, permanenter Verlust von Treffern. Der Mark wird auf now gesetzt, dann erst gematcht/gemailt (Mail-Fehler werden geschluckt). Bei Mail-Fehler, mid-loop Fatal oder Timeout sind alle Treffer im Fenster dauerhaft verloren (At-most-once statt At-least-once).
Fix (mit Vorsicht): now einmal als feste Obergrenze einfrieren und an findNewMatches als Ceiling übergeben (Methode braucht einen Upper-Bound), Mark erst nach durablem Versand setzen. Der naive „Mark nach Erfolg" allein erzeugt eine mid-run-Verlust-Regression.
PppAlertCronWorker.php:46-102 · PppAlertMatcher.php medium M
S15
PPP-PROFILE-1
Konto-Löschen entfernt den ppp_alert-Suchagenten nicht — persönliche Suchkriterien überleben den Account dauerhaft (DSGVO-Aufbewahrung). ProjektpartnerAccountPurger::purge() räumt Profil, Dokumente und das Private-Verzeichnis ab, fasst den Suchagenten aber nie an; solcom_projektpartner_alert implementiert kein hook_user_predelete/_delete. Region, Skills und Auslastungsschwellen des gelöschten Partners bleiben als ppp_alert-Entity zurück — verwaiste Personendaten ohne Eigentümer.
Fix: hook_user_predelete() im Alert-Modul, das die zum uid gehörenden ppp_alert-Entities löscht (Drupal-idiomatischer Ort für eigentümergebundenes Cleanup); alternativ den Purge-Service um den Alert-Pfad erweitern. Vor dem Migrations-Cutover, da migrierte Partner sonst beim ersten Löschvorgang Datenreste hinterlassen.
ProjektpartnerAccountPurger.php · solcom_projektpartner_alert.module medium S

2.4 Config-Hygiene & Test-Treue

#BefundDatei(en)SchwereAufwand
S16
CFG-01 (≡ CFG-02)
Die config/install-Seeds divergieren vom exportierten config/sync-Zustand — ein Fresh-Install (und jeder installConfig()-Kernel-Test) baut eine andere Konfiguration als die Produktion. Drei Instanzen, gleiche Wurzel (Seed nie aus dem Export nachgezogen): (1) user.role.projektpartner — Sync trägt die solcom_projektpartner_alert-Dependency + das Permission 'manage own ppp alert', der Install-Seed beide nicht; auf einem Fresh-Install ohne nachfolgendes cim fehlt dem Projektpartner-Role das Alert-Recht. (2) password_policy.projektpartner — Sync hat send_reset_email/show_policy_table etc., der Seed nicht. (3) easy_email.easy_email_type.ppp_verify — Seed deklariert eine nicht-enforced dependencies.module, Sync hat dependencies: {} (Orphan-Verhalten beim Uninstall divergiert). Gefährlich ist, dass installConfig()-Kernel-Tests gegen den veralteten Seed assertieren und so genau die Permission-Lücke maskieren, die ein echter Fresh-Install hätte.
Fix: Die drei Install-Seeds aus dem aktuellen config/sync-Export neu materialisieren (oder, wo der Seed nur Minimal-Bootstrap braucht, die produktiv geltende Config bewusst dort spiegeln). Danach config:status clean halten.
solcom_projektpartner/config/install/{user.role.projektpartner,password_policy.password_policy.projektpartner,easy_email.easy_email_type.ppp_verify}.yml vs. config/sync/… medium S

3. Test-Oberfläche

Im Sinne von „das Interface IST die Test-Oberfläche": Wo wurden reine Funktionen nur zur Testbarkeit extrahiert, während die echten Bugs in der Verdrahtung sitzen — und welche kritischen, irreversiblen Seams sind ganz ungetestet? Diese Befunde sind defect-now, weil sie genau die Pfade absichern, die beim Go-Live nicht brechen dürfen.

#BefundDatei(en)SchwereAufwand
T1
TEST-2
HardDeleteSubscriber: Empty-Feed-Schutz und Reconciliation-Sweep des SF-Primärimports sind komplett ungetestet. Der einzige Schutz gegen Mass-Delete des kompletten Live-Katalogs bei leerem SF-Response lebt in einem einzigen if — und kein Test übt ihn je aus. Der kritischste irreversible Pfad des Moduls.
Fix: Kernel-Test mit befüllter migrate_map: (1) Empty-Map → löscht nichts, loggt Warnung; (2) Teilmenge → nur fehlende Knoten; (3) Vollabdeckung → keine Löschung.
HardDeleteSubscriber.php:54, 73, 90 critical M
T2
TEST-4
Neos-Profil-Migration: nur Source-Iterator, Process-Plugins und Config-YAML getestet — die Pipeline-Verdrahtung wird nie als Migrate-Run ausgeführt. Der einmalige Go-Live-Cutover für alle Bestandsnutzer ist nur an den Rändern getestet. Kein Test ruft MigrateExecutable->import() für die Neos-Migration; die echten Bugs sitzen im Source→Process→Destination-Mapping (User+Profil-Erzeugung, PPP-ID, Passwort-/Skill-/File-Felder).
Fix: Kernel-Migrate-Run-Test, der die Neos-User- und -Profile-Migration end-to-end fährt und die entstehenden Entities samt field_ppp_id assertiert.
NeosSqlSourceTest.php · NeosDoctrineArrayTest.php · NeosPrivateDocumentsTest.php · NeosMigrationConfigTest.php high L
T3
TEST-1
Alert-Digest-Cron testet an der Logik vorbei: hook_cron wird nie aufgerufen, der Test re-implementiert nur die Cleanup-Query inline. Ungetestet bleiben das Frequency-Gate (daily/weekly), das last_digest_sent-Update, der Mailversand und eine continue-Stelle, die Matcher-Exceptions still überspringt (silent failure).
Fix: Kernel-Test, der solcom_project_offer_alert_cron() real aufruft (test_mail_collector), zwei Alerts mit verschiedener Frequency, assertiert wer eine Digest-Mail bekommt und dass der Mark aktualisiert wird.
ProjectOfferAlertSubscriptionTest.php:155, 182 · solcom_project_offer_alert.module:74, 113, 123 high M
T4
TEST-3
SalesforceUrlWriteback-QueueWorker: Outbound-REST-Fehlerpfad und NULL-url-Clear ungetestet. Der einzige Outbound-Schreibpfad nach Salesforce. Ungetestet: der ≥400-Wurf, der Unpublish-Clear (NULL-url) und der korrekte PATCH-Body.
Fix: Worker-Test mit gemocktem SalesforceClient (Stub existiert bereits): 200-string-url, NULL-url-Clear, 400-Wurf.
SalesforceUrlWriteback.php:80, 98, 105 medium S
T5
TEST-5
Test-Surface-Inversion: die SF-Primärquelle (ADR-0048) ist nur an Fetcher-Pagination + Config-YAML getestet, während der RSM-Fallback den vollen Pipeline-Test besitzt. Der laut ADR-0048 wertvollere Primärpfad ist schwächer abgesichert als sein Fallback. Kein Test fährt die solcom_project_offer-Migration als Migrate-Run gegen gemockte SF-Responses.
Fix: Kernel-Migrate-Run-Test mit gemockten SF-JSON-Responses (Fixtures aus SfHttpFetcherTest wiederverwendbar): Feld-Mapping, idempotenter Re-Import, Zusammenspiel mit T1.
ProjectOfferMigrationConfigTest.php · SfHttpFetcherTest.php · RsmImportCronTest.php:134 medium L

4. Deepening-Opportunities

Architektur-Refactors, die shallow Stellen vertiefen oder Locality zurückgewinnen — post-MVP, kein Go-Live-Blocker. Das sind die Kandidaten für die Grilling-Schleife: Hier lohnt es, das Ziel-Interface gemeinsam durchzudenken, bevor Code entsteht.

D1 — Watchlist: Controller↔PageBuilder duplizieren Persist-Parse + Empty-State (PO-SVC-1)

WatchlistController.php:186-271, 279-293 · WatchlistPageBuilder.php:100-153, 166-241
mediumdeepeningAufwand M

Problem: Die Persist-Mode-/IDs-CSV-Parse-Logik und das resolve()→empty-state-Muster (inkl. wörtlicher Copy der Empty-State-Meldung) existieren doppelt — einmal im Controller, einmal im PageBuilder. Ein Maintainer muss beide Dateien konsistent halten; die Locality ist gebrochen.

Seam & Deletion-Test: Die geteilten Stücke extrahieren, NICHT die beiden Builder mergen — applyAll() fügt legitim Webform-Form, Request-Attribut und Login-Choice hinzu, ein Full-Merge würde Komplexität nur verschieben. Extraktion der geteilten Geschäftsregel hinter ein Interface besteht den Deletion-Test; Full-Merge nicht.

Nutzen: Persist-Verhalten und Empty-State-Copy ändern sich an einem Ort; der Seam zwischen Controller und PageBuilder wird dicht und hinter dem Interface testbar (Voraussetzung: D2).

D2 — WatchlistPageBuilder hat keinen dedizierten Test (PO-SVC-3)

WatchlistPageBuilder.php:41-241
mediumdeepeningAufwand M

Problem: Die verzweigteste Klasse der Watchlist-Trias ist die einzige ohne eigenen Test (Storage und Resolver haben je einen). Die Trias ist test-asymmetrisch.

Interface = Test-Oberfläche: Ein Kernel-Test kann direkt auf das zurückgegebene Render-Array assertieren ($build['page']['banner'] bei stale>0, Cards bei visible≠[], apply-all-URL je Persist-Mode) — das Array ist der Interface-Rückgabewert, kein DOM nötig. Macht D1 sicher refaktorierbar.

D3 — RSM-Cron-Import dupliziert die komplette Migration (PO-SUB-1)

solcom_rsm_import.module:32-221, 257-294 · RsmXmlSource.php:162-201 · migrate_plus.migration.solcom_rsm_import.yml:20-115
mediumdeepeningAufwand L

Problem: Drei vollständige Implementierungen desselben Imports nebeneinander — die deklarative Migration (YAML), der RsmXmlSource-SourcePlugin und der hook_cron, der den XML-Parser als Near-Verbatim-Kopie hält und jedes Feld-Mapping in PHP nachbaut. Da ADR-0048 den Cron zum primären Live-Pfad macht, ist die Parallel-Pipeline die belastete Variante; jede Feld-Änderung muss an drei Stellen gepflegt werden.

Seam & Deletion-Test: Cron über programmatisches MigrateExecutable laufen lassen — Parse-Logik bleibt im SourcePlugin, Mapping im YAML-Process-Block. Löscht man den Cron-Duplikat-Pfad, verschwindet die Komplexität ersatzlos (sie ist anderswo bereits da). Orchestrierung (Fallback-Flag-Guard, Stale-Unpublish, XML-Cleanup) bleibt im hook_cron.

Risiko: Das heutige Cron-Verhalten muss exakt erhalten bleiben (No-Revision-Save, Lookup über field_rsm_lfd_nr, Stale-Unpublish). Regression-Test gegen echten OpenPos-XML-Fixture nötig.

D4 — hook_cron lädt alle project_offer-Nodes ohne Chunking (PO-SYNC-05)

solcom_project_offer.module:837-849
mediumdeepeningAufwand S

Problem: Die Status-Reconciliation lädt jeden Knoten in einen PHP-Array pro Cron-Request und setzt den Entity-Static-Cache nie zurück — Speicher wächst linear mit der Knotenzahl. Volumen-Prämisse unverifiziert: beim aktuellen Katalog evtl. nie ein Problem, daher Härtung statt akuter Defekt.

Fix: array_chunk + loadMultiple pro Chunk mit resetCache() dazwischen. Kein $sandbox (den hat hook_cron nicht — das ist Batch/Update-API).

D5 — Exposed-Form feuert drei ungecachte SELECT DISTINCT-Queries (PERF-02)

solcom_project_offer.module:1010-1022, 1062-1075, 1119-1131
mediumdeepeningAufwand S

Problem: Für die drei Filter-Selects (Einsatzort, Skills, Remote-Anteil) laufen bei jedem Form-Build drei rohe Multi-JOIN-Queries außerhalb jedes Cache-Layers. Auf Page-Cache-Hit (anonym) greift das nicht — aber auf jedem Cache-Miss (eingeloggt, abgelaufen) trifft es den Listing-Pfad.

Fix: die drei Option-Listen über cache() mit node_list-/taxonomy_term_list-Cache-Tags memoizen (automatische Invalidierung).

D6 — Vier Profil-Blocks duplizieren Owner-Resolution + Portal-Zugriffsregel verbatim (PP-PROFILE-3)

ProjektpartnerProfileViewBlock / ProfileEditBlock / PortalCockpitBlock / AccountSettingsBlock (je :~120-180)
lowdeepeningAufwand S

Problem: Die Regel in_array('projektpartner', $account->getRoles()) || hasPermission('administer users') plus identische Cache-Contexts und die Helfer loadAccount()/loadOwnProfile() stehen Wort für Wort in allen vier Blocks. Eine Änderung der Zugriffsregel muss vier Stellen treffen.

Seam: Geteilte Regel + Helfer in eine abstrakte Basis-Block-Klasse ohne #[Block]-Attribut (von der Plugin-Discovery ignoriert) oder einen Trait heben — etabliertes Contrib-Idiom, kein Kampf gegen die Discovery. Optional die Rollen-String-Prüfung durch eine echte Permission ersetzen (konsistent zum bereits sauberen Alert-Block). Der Alert-Block nutzt eine andere Permission und bleibt bewusst separat.

D7 — PppAlertMatcher::detectCountry() klassifiziert jede unerkannte Region als 'DE' (PPP-PROFILE-4 · Alert-Modul)

PppAlertMatcher.php:142-183
lowdeepeningAufwand S

Problem: Der Matcher leitet das Land eines Offers aus einem String-Muster ab und fällt für alles, was kein erkennbares AT/CH-Muster trägt, auf 'DE' zurück (Zeile 183). Ein Abonnent, der „nur DE" wünscht, bekommt damit auch Offers ohne sauberes Länder-Signal — eine Matching-Korrektheits-Kante, die heute mangels strukturiertem Länder-Feld auf dem Offer nicht sauber lösbar ist (upstream-abhängig). Bei einem deutschen Kernmarkt ist der Fehlerfall selten, daher low.

Seam: Sobald ein strukturiertes Länder-/Region-Feld am project_offer existiert (Layer-B), die heuristische String-Erkennung durch eine Feldlesung ersetzen; bis dahin den Fallback explizit als „unbekannt" statt „DE" führen, damit ein striktes DE-Abo nicht übermatcht. Kein eigenständiger Go-Live-Blocker.

5. Idiom-Verstöße

Low-Effort-Bereinigungen, die den „Drupal-Way" wiederherstellen. Opportunistisch (z. B. wenn die Datei ohnehin angefasst wird).

#BefundDatei(en)Fix
I1
PO-SVC-2
Publishing-Window-Prädikat in _expected_status() ist Byte-für-Byte-Reimplementierung von ProjectOfferWindowAccess::isInWindow() statt es aufzurufen. module:955-971 An isInWindow($node,$now) delegieren; lokale strtotime-Kopie entfällt. (Der Views-Query-SQL ist bewusste Layer-Differenz — nicht anfassen.)
I2
PO-SVC-4
SfPublicationStatusController liest migrate_map-Status über Magic-Index $result[0]/$result[3] statt benannter Konstanten. SfPublicationStatusController.php:62-77 MigrateIdMapInterface::STATUS_IMPORTED/_FAILED verwenden.
I3
PP-AUTH-02
createBlockedUser() speichert ohne Entity-Validation — Email-Uniqueness nur im Form-Layer abgesichert. Ein zweiter Caller (CLI, Batch) bräche die Invariante lautlos. RegistrationSubmitter.php:106 · Handler:109 Uniqueness-Check (loadByProperties(['mail'=>…])) oder gezielter validate()-Pass vor save() im Submitter.
I4
PP-AUTH-03
Passwort-Equality-Prüfung doppelt: einmal im WebformElement-Plugin, einmal im Handler::validateForm() (liest rohe getUserInput()). WebformElement…PasswordConfirm.php:41 · Handler:87 Handler-Block entfernen; Element als Single-Source-of-Truth für die Equality.
I5
PP-PROFILE-4
NeosSkillReview meldet unbekannte Skills über einen bespoke State-Key statt über die native Migrate-Message-Schiene. NeosSkillReview.php:76-95, 163-176 Auf $migrate_executable->saveMessage() umstellen (sichtbar via drush migrate:messages); State-Key entfällt.

6. Empfohlene Reihenfolge

  1. Security-Quick-Wins zuerst (alle S): S2 (JSON-LD-XSS), S3 (Flood), S6 (Secret-Keys). Kleine, isolierte Einzeiler/Methoden ohne Architektur-Risiko.
  2. Vor dem Migrations-Cutover (S1 + S15): S1 (Privatdokument-Owner inkl. Re-Import-Plan) blockiert den Self-Service-Download für alle migrierten Partner; S15 (Suchagent-Cleanup beim Konto-Löschen) verhindert, dass migrierte Partner beim ersten Löschvorgang DSGVO-Datenreste hinterlassen.
  3. SF-Sync-Cluster als Einheit: S9 (ORDER BY) sofort; dann S7 + S8 + T1 zusammen (Bundle-String, korrekte Reconciliation gegen autoritativen Id-Pull, Empty-Feed-Test). Niemals S7 ohne S8.
  4. Alert-Korrektheit: S12 (Remote-Fehlinterpretation), S13 (Starvation), S10 (Geocode-Negativ-Cache), S11 (Poison-Item) — alle S, je ein Reproduktions-Test.
  5. Config-/Test-Treue (S16): die drei config/install-Seeds aus dem aktuellen config/sync-Export neu materialisieren — sonst prüfen installConfig()-Kernel-Tests gegen veraltete Config und maskieren die Permission-Lücke. Voraussetzung für verlässliche Test-Aussagen der folgenden Schritte.
  6. Migrations-Test-Sicherheit vor Go-Live: T2 (Neos-Pipeline-Run-Test) und T3 (Alert-Cron-Test) — der Cutover und der Digest sind die irreversiblen/geschäftskritischen Pfade.
  7. Deepening (post-MVP): D1+D2 zusammen (Watchlist), dann D3 (RSM-Migration-Konsolidierung), D6 (Profil-Block-Basis), D4/D5 (Cron-/Query-Härtung) nach Volumen-Bestätigung; D7 (detectCountry) erst nach Einführung eines strukturierten Länder-Felds (upstream-abhängig).

7. Verworfene Befunde

Acht Audit-Verdachtsfälle haben den Verifikations-Pass nicht bestanden — aufgeführt zur Transparenz und damit künftige Audits sie nicht erneut aufwerfen.

Audit-IDVerdachtWarum verworfen
PO-SUB-3Alert-Entity trägt tote Filter-Felder (PLZ/Radius)ADR-0036 [accepted] etabliert den Präzedenzfall für Phase-2-Stub-Felder — nicht re-litigieren.
PP-AUTH-01MailManager vereint drei VerantwortlichkeitenDeletion-Test fällt: jeder Auth-Flow hat genau einen Caller, die Komplexität ist bereits konzentriert — Split verschöbe sie nur.
PP-AUTH-04AccountPurger löscht file_usage per Raw-SQLBefund-Prämissen gegen den echten Code falsch (keine IN-Clause; FileUsageInterface gar nicht im Konstruktor) — wie beschrieben nicht real.
PP-AUTH-05Legacy @WebformHandler-Annotations statt PHP-8-AttributesFix nicht umsetzbar: webform-contrib bietet keine #[WebformHandler]-Attribut-Klassen; Docblock-Annotation ist hier die einzig gültige Form.
SEC-02Alert-Token verfallen niecreated-Feld existiert; reales Risiko gering, Befund-Kern (kein Expiry-Feld) nicht hoch genug für defect-now.
PERF-01hook_cron lädt alle Nodes (als „critical" gemeldet)Deletion-Test: die Reconciliation MUSS jeden Knoten besuchen; der Save ist geguarded, Cron läuft 3-stündlich. Als Härtung in D4 (PO-SYNC-05) überführt, nicht critical.
PERF-04DurationBucket-Filter nutzt REGEXPDeletion-Test schwach: ein numerisches Feld verschöbe die Parse-Komplexität in die Migration; bei ~871 Zeilen kein realer Perf-Defekt.
CONFIG-01SOQL ohne ORDER BYReal — aber als S9 / PO-SYNC-03 bereits in §2.2 geführt (Doppelfund zweier Dimensionen, dort konsolidiert).

8. Quellen & Evidence

Hinweis zur Vollständigkeit: Die Liste umfasst 33 verifizierte Befunde. Der Audit lief in zwei Durchläufen (Lauf 1 pausiert, Lauf 2 fortgesetzt), die je Dimension durch die stochastische Variation teils unterschiedliche Befunde zogen. Die finale Liste ist die Vereinigung beider Läufe: Über einen Titel-Diff (nicht ID — die kollidieren über Läufe hinweg) wurden die Residuen ermittelt, gegen die finale Kill-Liste geprüft und einzeln direkt gegen den Code bestätigt, bevor sie aufgenommen wurden. Betroffen waren drei Dimensionen: Security (S2 JSON-LD-XSS, S4 CDATA), Profil (S15 DSGVO-Suchagent-Retention, D7 detectCountry) und Config (S16 install↔sync-Divergenz). Bewusste Entscheidung für Vollständigkeit gegenüber Lauf-Reproduzierbarkeit.