Implementation Plan · SOLCOM Karriere · F-100 / P2503-90 · Pfad A

Bewerber-Flow — Drupal-native Bewerbung mit direktem Personio-API-POST

Kontext Tracer-Bullet-Zerlegung des Pfad-A-PRD (docs/prds/2026-05-29-vacancy-application-personio-api-post-prd.html), Architektur-Source-of-truth ADR-0050. Supersedes den C+-Plan 2026-05-25-002-feat-vacancy-application-form.html (Email-Inbox-Bridge + Webhook-ACK, obsolet). Die vier Build-Slices mappen 1:1 auf die bereits refined Jira-Sub-Tasks P2503-184 / 186 / 187 / 188 — kein Jira-Push aus diesem Plan. Build-Charakter: Refactor-mit-Kill-Liste am bestehenden Modul solcom_vacancy_application (kein Rewrite).
Build-Slices
4 · AFK
Architektur
Pfad A · async Core-Queue
Modul
solcom_vacancy_application
Deep Modules
2 neu (Client + Purger)
01

Slices

Vier dünne vertikale Schnitte durch alle Layer (Webform-Config, Sub-Route, Queue, Service, Tests). Jeder Slice ist eigenständig demoable und mappt auf einen bestehenden Sub-Task. Die nicht-Code-Vorbedingungen P2503-189 (Tenant-Config), P2503-190 (AVV) und P2503-191 (Multiposting-Scope) sind bereits Fertig und liegen außerhalb der Build-Slices.

PhaseTitleSub-TaskTypeBlocked by
1Submit landet in Personio (Tracer-Bullet, e2e demoable)P2503-184AFK— (P2503-189 done)
2Idempotenz + GDPR-Purge bei API-2xxP2503-186AFK1
3DLQ-Recovery für HRP2503-187AFK1
4Smoke-Test + Kernel/Functional-TestsP2503-188AFK1, 2, 3

Phase 1 — Submit landet in Personio (Tracer-Bullet)

P2503-184 · AFK · User Stories 1–7, 10

Den bestehenden solcom_vacancy_application-Modul auf Pfad A umbauen. Neu: das Deep Module PersonioApplicationClient mit schmalem Interface submit(WebformSubmissionInterface): SubmitResult — Field-Mapping (Webform → API-Body inkl. custom_attributes), Multipart-/File-Assembly, HTTP-POST /v1/recruiting/applications, Response-Parsing (201 + application_id), injizierter HTTP-Client (gegen Mock testbar; gebaut analog zum Salesforce-SfClient). Refactor: VacancyApplicationController (Sub-Route /karriere/<slug>/bewerben im byte_theme, Kontext-Header mit Stellentitel, Danke-Seite), ApplicationQueueHandler (Enqueue beim Initial-Submit, Guard), ApplicationQueueWorker (Delegation an Client; RuntimeExceptionDelayedRequeueException). Löschen: die C+-Kill-Liste (siehe Sektion 03). Webform-State von _meta-Array → separate skalare Elemente. End-to-end: Submit auf der Sub-Route → ein Queue-Item → Worker POSTet → 201 → status=sent + application_id persistiert → Danke-Seite.

  • Sub-Route /karriere/<slug>/bewerben rendert im byte_theme-Branding mit sichtbarem Stellentitel.
  • Gültiger Submit (inkl. CV-Upload + Datenschutz-Checkbox) erzeugt genau ein Queue-Item + private://applications/<uuid>/.
  • QueueWorker delegiert an PersonioApplicationClient; bei 201 wird application_id persistiert und status=sent gesetzt.
  • C+-Kill-Liste entfernt: kein PersonioMailBuilder, kein PersonioWebhookController, keine /personio/webhook/*-Route in routing.yml.
  • Webform-State als separate skalare Elemente (status, retry_count, sent_at, personio_application_id, tracking_hash; je '#access': false), kein _meta-Array.

Phase 2 — Idempotenz + GDPR-Purge bei API-2xx

P2503-186 · AFK · User Stories 9, 14, 15, 16 · blocked by Phase 1

Deterministischer Idempotency-Key (= recomputeter tracking_hash aus Submission-UUID + Site-Salt) als Header an jedem POST; Personio dedupliziert server-seitig. Fallback bei fehlendem Server-Support: Pre-POST-Existence-Check via GET /v2/recruiting/applications, nur auf Retries. Neu: das Deep Module ApplicationPurger mit Interface purge(WebformSubmissionInterface): void — GDPR-Lösch-Sequenz (FileUsageInterface::delete()$file->delete()$submission->delete() → Verzeichnis-Removal) plus PII-freier Watchdog-Audit-Eintrag. Verdrahtung in den 2xx-Pfad des Workers.

  • Jeder POST trägt einen deterministischen Idempotency-Key-Header aus Submission-UUID + Site-Salt; identische Submission → identischer Key.
  • Bei 2xx entfernt ApplicationPurger Submission-Entity + Files + Verzeichnis.
  • Ein PII-freier Watchdog-Eintrag bleibt (nur tracking_hash + application_id + Timestamp).
  • Retry-nach-Timeout (erster POST OK, Response verloren) legt keine zweite Application an.

Phase 3 — DLQ-Recovery für HR

P2503-187 · AFK · User Stories 8, 11, 12, 13, 17 · blocked by Phase 1

Retry-/DLQ-State-Machine im Worker: retry_count wird per $submission->save() vor dem Werfen der DelayedRequeueException persistiert (der Zähler darf nie in der Queue-Payload liegen — delayItem() friert die Payload ein). Backoff-Schedule [60, 300, 900, 3600, 21600] s, max 5 Versuche. Failure-Klassifizierung: 2xx → sent; 429 → Retry-After respektieren; 5xx/Netzwerk → Backoff; 4xx außer 429 → sofort dlq_persistent (kein Retry-Burn) + HR-Notification. DLQ-Übersicht als View auf webform_submission (Filter status='dlq_persistent') via webform_views + views_bulk_operations; state-ändernde Aktionen (Re-Queue/Resolve) nur über POST + CSRF (VBO-Actions); Zugang nur mit Permission manage karriere dlq, anonym → 403.

  • Worker erhöht retry_count und persistiert ihn via save() vor der DelayedRequeueException; kein Re-Enqueue-Loop (Enqueue-Guard hält).
  • Backoff [60,300,900,3600,21600], max 5 Retries; 4xx (außer 429) → sofort dlq_persistent.
  • Dauerhafter Fehler löst eine HR-Notification aus.
  • DLQ-View listet status='dlq_persistent'; Re-Queue/Resolve sind POST + CSRF; anonym → 403, nur manage karriere dlq sieht die Liste.

Phase 4 — Smoke-Test + Kernel/Functional-Tests

P2503-188 · AFK · User Stories 16, 18, 19, 20 · blocked by Phase 1–3

Drush-Smoke-Test-Command, das eine markierte Test-Bewerbung end-to-end durchschickt und in Personio per API-Read-back gegenprüft (Umbau des alten Inbox-Smoke-Tests auf API-Read-back). Kernel-Tests für PersonioApplicationClient (Field-Mapping, Idempotency-Key, Status-Klassifizierung gegen gemockten HTTP-Client — Vorlage: SfClient-Kernel-Tests) und ApplicationPurger (nach purge(): Submission + Files weg, Verzeichnis weg, Watchdog-Eintrag PII-frei). Functional-Tests für VacancyApplicationController (Sub-Route rendert, gültiger Submit → 1 Queue-Item + private-Verzeichnis) und DLQ-View-Access (HR sieht Liste, anonym → 403). API-Credentials via settings.local.php (außerhalb VCS).

  • Drush-Smoke-Test sendet eine markierte Test-Bewerbung und verifiziert sie per API-Read-back.
  • Kernel: PersonioApplicationClient Field-Mapping + Idempotency-Key + Status-Klassifizierung (2xx/429/5xx/4xx → Result-Typ) gegen Mock-HTTP-Client.
  • Kernel: ApplicationPurger entfernt Submission + Files + Verzeichnis, lässt PII-freien Watchdog-Eintrag.
  • Functional: Sub-Route rendert + gültiger Submit → genau ein Queue-Item + private-Verzeichnis; DLQ-View HR-sichtbar, anonym → 403.
  • API-Credentials werden aus settings.local.php gelesen, nicht committet.
02

Data flow

Synchron für den Bewerber (Submit → Danke-Seite), asynchron für die Übermittlung (Core-Queue → Worker → Client → Personio). Die Webhook-/ACK-Schicht aus Pfad C+ entfällt — die API liefert die application_id synchron in der Response.

Bewerber füllt Formular Webform-Sub-Route /karriere/<slug>/bewerben byte_theme · Danke-Seite synchron private://applications/<uuid>/ + Queue-Item solcom_personio_application_queue · {submission_id} ApplicationQueueWorker (Cron) PersonioApplicationClient.submit() Field-Map + Idempotency-Key + Multipart Personio Recruiting API POST /v1/recruiting/applications 2xx → ApplicationPurger GDPR delete + PII-frei Audit 429/5xx → Backoff DelayedRequeueException · max 5 4xx → dlq_persistent + HR-Mail

Backoff-Pfad reiht das Item nach Schedule erneut ein; der Enqueue-Guard verhindert, dass das Worker-save() ein zweites Item erzeugt. 4xx (außer 429) brennt keine Retries.

03

Code-Disposition (Refactor-mit-Kill-Liste)

Der existierende C+-Code unter web/modules/custom/solcom_vacancy_application/ trägt alle drei FAIL-Findings (S-5/S-6/S-7) noch live und ist ungebaut für Pfad A. Die zu löschenden Services sind keine zu bewahrenden Best-Practice-Patterns, sondern Artefakte der durch ADR-0050 superseded Architektur — plus eine offen exponierte Webhook-Route. Das Scaffolding bleibt: Refactor, kein Rewrite.

DispositionArtefaktGrund
DELETEsrc/Service/PersonioMailBuilder.phpSMTP-Body, C+-only — Pfad A POSTet direkt.
DELETEsrc/Controller/PersonioWebhookController.php + Route /personio/webhook/application-createdExponiert (_access:'TRUE') — Security-Gap; ACK entfällt mit Pfad A.
DELETEsrc/Commands/PersonioWebhookCommands.phpWebhook-Tooling, obsolet.
DELETEtests/.../PersonioMailBuilderTest.php, PersonioWebhookControllerTest.phpTests für gelöschten Code.
REFACTORsrc/Controller/VacancyApplicationController.phpSub-Route + Kontext-Header + Danke-Seite bleiben, Inbox-Bezug raus.
REFACTORsrc/Plugin/WebformHandler/ApplicationQueueHandler.phpEnqueue-Guard (nur Initial-Submit).
REFACTORsrc/Plugin/QueueWorker/ApplicationQueueWorker.phpRuntimeException → DelayedRequeueException, SMTP → Client-Delegation, Scalar-State.
REFACTORconfig/install/webform.webform.vacancy_application.yml_meta-Array → separate Scalars (fix S-6).
REFACTORsrc/Commands/PersonioSmokeTestCommands.phpInbox-Check → API-Read-back.
REFACTORsrc/Controller/DlqAdminController.phpErsetzen durch webform_views/VBO-View, sofern beta-kompatibel (fix S-7).
NEUsrc/Service/PersonioApplicationClient.phpDeep Module: Submit-Logik isoliert testbar.
NEUsrc/Service/ApplicationPurger.phpDeep Module: GDPR-Lösch-Sequenz + PII-freier Audit.
04

State, Retry & Idempotenz

Die Wurzeln der drei FAIL-Findings adressiert, nicht umgangen.

State-Store (fix S-6): Lifecycle-State liegt co-located an der Submission als separate skalare Webform-Elemente (status, retry_count, sent_at, personio_application_id, tracking_hash), je '#access': false — kein _meta-Array. Die S-6-Wurzel war der Array-Typ im Hidden-Element, nicht der Speicherort; Co-Location liefert die GDPR-Kopplung gratis (State stirbt/überlebt mit der Submission, kein Orphan-State).

Retry ohne Re-Enqueue-Loop (fix S-5): Worker erhöht retry_count und schreibt ihn per $submission->save() vor der DelayedRequeueException (der Throw verlässt processItem(); delayItem() friert die Payload ein — der Zähler darf nie in der Payload liegen). Der ApplicationQueueHandler reiht nur beim Initial-Submit ein (Guard: kein Enqueue, wenn status bereits gesetzt), sonst triggert jedes Worker-save() den Handler → Endlosschleife. Backoff via Core DelayedRequeueException($delay), Schedule [60, 300, 900, 3600, 21600] s, max 5 (Core-verifiziert: Cron::processQueue() ruft delayItem() bei dieser Exception).

Idempotenz: deterministischer Idempotency-Key (= recomputeter tracking_hash aus Submission-UUID + Site-Salt) als Header; Personio dedupliziert server-seitig. Fallback bei fehlendem Server-Support: Pre-POST-Existence-Check via GET /v2/recruiting/applications, nur auf Retries.

DLQ-UI-Sicherheit (fix S-7): State-ändernde Aktionen (Re-Queue, Resolve) laufen ausschließlich über POST + CSRF (VBO-Actions oder Fallback ConfirmFormBase) — nie über state-ändernde GET-Routen. Zugang nur mit Permission manage karriere dlq (HR-Rolle), anonym → 403.

05

Risks & mitigations

Risk
Sev
Mitigation
API-Request-/Response-Schema unbekannt (multipart? 201+id? Idempotency-Key-Support?) — Spec dokumentiert oft nur den empfohlenen Pfad.
HIGH
Live-Probe gegen Personio mit den Multiposting-Credentials zum Implementation-Start (siehe Open Questions); Client-Interface kapselt die Antwort-Unsicherheit hinter SubmitResult.
Re-Enqueue-Loop durch Handler-Re-Trigger beim Worker-save() (S-5-Wiederkehr).
HIGH
Enqueue-Guard (Handler reiht nur ein, wenn status leer); Functional-Test, der genau ein Queue-Item pro Submit assertet.
hook_webform_submission_delete-Cascade löscht Files unerwartet doppelt oder gar nicht.
MED
Cascade-Verhalten beim Sequenz-Entwurf des ApplicationPurger prüfen; Kernel-Test über die volle Lösch-Sequenz.
webform_views / views_bulk_operations nicht beta-kompatibel für die DLQ-View.
MED
Fallback auf Custom ConfirmFormBase-Action (POST + CSRF) statt VBO, gleiche Sicherheits-Semantik.
06

QA-Plan

Click-Pfade pro Akzeptanzkriterium — Quelle der Wahrheit für die Pre-PR-Acceptance-Stage (ADR-0037) und die Mensch-QA nach PR-Open. Host https://solcom.ddev.site; <slug> = eine real existierende Vacancy bzw. die markierte Test-Stelle.

AK-#URLActionExpected
AK-1 https://solcom.ddev.site/karriere/test-stelle/bewerben Seite aufrufen HTTP 200, byte_theme-Branding, Stellentitel „test-stelle" sichtbar im Kontext-Header.
AK-2 https://solcom.ddev.site/karriere/test-stelle/bewerben Pflichtfelder ausfüllen, CV-PDF hochladen, Datenschutz anhaken, absenden Redirect auf Danke-Seite mit Hinweis auf folgende Personio-Bestätigungsmail; drush queue:list zeigt 1 Item in solcom_personio_application_queue; private://applications/<uuid>/ existiert.
AK-3 https://solcom.ddev.site/karriere/test-stelle/bewerben Datei > Max-Größe oder falscher Typ hochladen, absenden Inline-Fehlermeldung am Upload-Feld; kein Queue-Item erzeugt.
AK-4 CLI ddev drush queue:run solcom_personio_application_queue nach gültigem Submit POST an Personio; bei 2xx: status=sent + personio_application_id gesetzt, danach Submission + Files gelöscht; Watchdog-Eintrag PII-frei (nur tracking_hash + application_id + Timestamp).
AK-5 CLI Worker mit gemocktem 4xx (außer 429) laufen lassen status=dlq_persistent sofort (kein Retry-Burn); HR-Notification-Mail im drush-Maillog.
AK-6 https://solcom.ddev.site/admin/karriere/dlq Als anonym aufrufen HTTP 403.
AK-7 https://solcom.ddev.site/admin/karriere/dlq Als HR-Rolle (Permission manage karriere dlq) aufrufen, „erneut versuchen" auf einer DLQ-Zeile Liste zeigt dlq_persistent-Bewerbungen mit Datei-Download; Re-Queue läuft über POST + CSRF (kein state-ändernder GET), Item zurück in der Queue.
AK-8 CLI ddev drush solcom:personio-smoke-test Markierte Test-Bewerbung wird durchgeschickt und per API-Read-back in Personio bestätigt; Exit-Code 0.
07

Open questions

Personio-API-Request-/Response-Schema (Implementation-Probe, nicht blockierend)
Multipart oder JSON+Base64? Liefert die Response 201 + application_id? Wird ein Idempotency-Key-Header server-seitig respektiert? Wie verhält sich Retry-After bei 429? Per Live-Probe gegen Personio mit den Multiposting-Credentials zum Implementation-Start klären.
Klären beim · Implementation-Start Phase 1
Field-Parität gegen die Referenz-Form
Decken die Webform-Felder die von der API erwarteten Pflicht- und custom_attributes-Felder vollständig ab? Abgleich gegen das bisherige Personio-Formular.
Klären beim · Field-Mapping in Phase 1
Apply-CTA-Rewiring (eigener Slice, ausserhalb dieses Plans)
Das Binding field_apply_url → interne Sub-Route (ADR-0014-Canvas-Eingriff) ist nicht Teil der Übermittlungslogik dieses Plans und als eigener Slice + QA-Test zu führen (G7 aus dem Re-Grill).
Track · separater Plan-Slice