Vacancy-Application Submission via direktem Personio-API-POST (Pfad A)
Status: accepted supersedes ADR-0047 customer-lens-reviewer: GO-WITH-CONCERN (2026-05-29)POST /v1/recruiting/applications — erreichbar, der in ADR-0047 noch hinter dem Scope-Gate lag (17 Probes, alle HTTP 401). Der Submit läuft jetzt asynchron über die Drupal Core Queue: ein Thin-QueueWorker ruft einen PersonioApplicationClient (Auth via key-Modul mit direktem Recruiting-Access-Token + X-Company-ID), lädt Bewerbungsdokumente zuerst via POST /v1/recruiting/applications/documents hoch und POSTet danach die Bewerbung als JSON. Die API-2xx-Response bestätigt die Application-Erstellung synchron; eine Personio-Application-ID ist optional, da die Live-Queue-Probe vom 2026-05-30 einen erfolgreichen 2xx ohne ID im Response-Body lieferte. Der gesamte Webhook-ACK-Layer aus ADR-0047 (Subscription, Receiver, Signatur-Verifikation, Tracking-Hash-Match, 24h-Timeout-Cron) entfällt ersatzlos. Diese ADR supersedet ADR-0047 und leitet zugleich die State-/Queue-/Security-Schicht Drupal-way neu her, nachdem die C+-Implementierung im Review vom 2026-05-28 mit Acceptance: FAIL durchfiel (HARD-Findings S-5 Backoff, S-6 State-Storage, S-7 CSRF).
Kontext
ADR-0047 fixierte am 2026-05-25 Pfad C+ (Email-Inbox-Import + Webhook-ACK) als MVP-Architektur für F-100 / P2503-90 („Bewerber-Flow“) — nicht aus Präferenz, sondern weil Pfad A nicht erreichbar war: 17 Live-Probes belegten, dass POST /v1/recruiting/applications mit SOLCOMs Standard-Recruiting-Credentials HTTP 401 liefert (Multiposting-Partner-Scope-Gate). Die Klärung des Scopes lief parallel als non-blocking Sub-Task P2503-191.
Seither haben sich zwei Dinge geändert, die zusammen diese Re-Decision auslösen:
- Multiposting-Scope wurde gewährt. P2503-191 ist abgeschlossen; der Personio-Account-Manager hat den Multiposting-Partner-Scope für SOLCOM aktiviert. Damit ist der in ADR-0047 als „Future-Refactor-Option“ dokumentierte Pfad A heute verfügbar — und damit die technisch überlegene Architektur, weil ein direkter API-POST die Application synchron bestätigt, statt über eine opake Email-Inbox fire-and-forget zu versenden.
- Die C+-Implementierung fiel im Review durch. Der
solcom-review-Lauf vom 2026-05-28 (Report) gabAcceptance: FAILmit drei HARD-Findings: S-5 (Backoff viaRuntimeExceptionstattDelayedRequeueException— mechanisch wirkungslos), S-6 (kompletter Lifecycle-State als Array in einem Webform-Hidden-Field_meta→ PHP-Array-to-String-Cast zu"Array"), S-7 (DLQ-Recovery-Actions als state-changing GET-Routes ohne CSRF). Die Re-Grilling-Session vom 2026-05-29 leitet die State-/Queue-/Security-Schicht deshalb von Grund auf neu her, gemäß dem Leitprinzip „im Drupal-Way lösen, keinen State duplizieren, den ein Core-Subsystem besitzt“.
Verifizierte Core-Mechanik (Drupal 11.3)
Der zentrale S-5-Befund wurde gegen die Core-Source verifiziert: DatabaseQueue implementiert DelayableQueueInterface::delayItem(), und Cron::processQueue() (web/core/lib/Drupal/Core/Cron.php, Zeile 211) ruft $queue->delayItem($item, $e->getDelay()) auf, wenn ein QueueWorker eine DelayedRequeueException wirft. Der Backoff ist also mit reinem Core implementierbar — advancedqueue ist dafür nicht nötig. Wichtige Konsequenz: delayItem() verändert die Item-Payload nicht (es setzt nur expire = now + delay); der retry_count kann daher nicht in der Queue-Payload hochgezählt werden, sondern muss durable gespeichert und vom Worker inkrementiert werden.
Was Pfad A vereinfacht
Der Webhook-ACK-Layer in ADR-0047 existierte nur, weil der Email-Inbox-Import auf Personio-Seite asynchron und opak ist: man konnte nicht wissen, ob Personio die Mail importiert hat, ohne einen Webhook abzuwarten. Ein direkter API-POST liefert die HTTP-2xx-Annahme synchron; die Personio-Application-ID kann im Response-Body fehlen. Damit entfallen: Webhook-Subscription, Webhook-Receiver, HMAC-Signatur-Verifikation, der sent_pending_ack-Limbo-Status, der 24h-Timeout-Cron und die Inbox-Adress-Konfiguration. Der Re-Grill resultiert also in weniger Komplexität als die durchgefallene C+-Implementierung — die einzige neue Komplexität ist Idempotenz (siehe Entscheidung Punkt 5).
Entscheidung
Pfad A — direkter HTTP-POST über eine asynchrone Core-Queue wird Architektur. Konkret, in acht Punkten:
- Integration-Pfad. Ein QueueWorker lädt vorhandene Dokumente zuerst per
POST /v1/recruiting/applications/documentshoch und macht anschließendPOST /v1/recruiting/applicationsalsapplication/jsongegen die Personio Recruiting API. Der bisherige SMTP-an-Inbox-Versand entfällt. Der Webhook-ACK-Layer (ADR-0047, Punkte 5–7) entfällt vollständig — die API-2xx-Response bestätigt die Application-Erstellung synchron. - Delivery-Modus: asynchron via Drupal Core Queue. Der Webform-Submit-Handler speichert die Submission + Files in
private://applications/<submission-uuid>/und enqueued ein minimales Item ({submission_id}) insolcom_personio_application_queue. Ein Thin-QueueWorker delegiert an einenPersonioApplicationClient-Service (HTTP + Auth + Response-Klassifikation). Auth ist nicht der/v2/auth/token-Client-Credentials-Flow, sondern der direkte Recruiting-Access-Token auskey.key.solcom_personio_api_key;X-Company-IDkommt aus Modul-Config. Asynchron, weil ein 3×10 MB-Upload plus API-Latenz den Submit-Request nicht blockieren darf und weil die HR-Reliability-Anforderung (Retry, DLQ, Recovery) transport-agnostisch ist und genauso für einen API-Ausfall gilt wie für einen Inbox-Bounce. - State-Store: skalare Webform-Elemente. Der Lifecycle-Status lebt als getrennte, skalare Hidden-Elemente auf der Webform-Submission —
status(queued→sent|dlq_persistent|resolved_manually),retry_count,sent_at— nicht als ein_meta-Array. Skalare round-trippen durch Webforms Storage sauber; der S-6-Array-Cast-Bug entsteht nur bei Arrays in Hidden-Feldern. Die Webform-Submission ist der durable Record (Begründung siehe Rejected Alternative „Custom Content Entity“). Verbindliche Regel: jedes Lifecycle-Element trägt'#access': false; ein Schema-/Render-Test prüft das, damit interner State nie in der HR-Results-UI auftaucht. - Backoff via
DelayedRequeueException. Bei transientem Fehler wirft der Workerthrow new DelayedRequeueException($delay)mit$delayaus dem Schedule[60, 300, 900, 3600, 21600]Sekunden (1 min / 5 min / 15 min / 1 h / 6 h).retry_countwird durable im Submission-Element inkrementiert (nicht in der Payload — siehe Kontext). - Idempotenz. Jeder POST trägt einen deterministischen Idempotency-Key, der aus der Submission-UUID + Site-Salt rekonstruiert wird (
tracking_hash— recomputen, nicht speichern). Das verhindert das Pfad-A-spezifische Risiko, dass ein Retry nach Timeout (erster POST erfolgreich, Response verloren) eine zweite identische Application anlegt, sofern Personio den Header server-seitig auswertet. Ein API-Read-back-Fallback ist mit dem verifizierten Token aktuell nicht verfügbar (GET /v2/recruiting/applicationsliefert 404,GET /v1/recruiting/applications405). Dertracking_hashwechselt damit gegenüber ADR-0047 die Rolle — von Webhook-Match-Key zu Idempotenz-Key. - Failure-Klassifikation nach HTTP-Status.
2xx→ Erfolg, GDPR-Purge.429→Retry-After-Header als Delay honorieren.5xx/ Netzwerk / Timeout → transient, Backoff-Schedule bis 5×.4xxaußer 429 (400/401/403/422) → permanent, sofort DLQ + HR-Notification (kein Retry-Burn gegen einen Fehler, der nie verschwindet). Folgt dem Repo-Pattern aus ADR-0029 („429 ausgewertet, 5xx mit exponentiellem Backoff“). - DLQ-Recovery-UI via Views + VBO. Die HR-Recovery-Oberfläche ist eine View auf
webform_submission, gefiltert aufstatus = dlq_persistent, mit Re-Queue / Resolve als VBO-Actions und File-Download. POST + CSRF + Entity-Access kommen damit aus Contrib statt aus Custom-Code (struktureller S-7-Fix). Zugang über Permissionmanage karriere dlq(HR-Rolle), kein Admin-Vollzugriff nötig. - GDPR-Purge bei 2xx. Nach erfolgreichem POST löscht der Worker den Record vollständig:
$submission->delete()(cascade'dwebform_submission_data+webform_submission_log) plusfile_usage-Cleanup und$file->delete()für die managed Files plus Löschung desprivate://applications/<uuid>/-Verzeichnisses (struktureller S-12-Fix). Ein PII-freier Watchdog-Eintrag (tracking_hash,personio_application_id, Timestamp) bleibt als Audit-Trail. DLQ-Submissions bleiben persistent, bis HR sie über die Recovery-UI auflöst.
Der Smoke-Test-Drush-Command und der Apply-CTA-Mechanismus (Sub-Route, Field-Binding) bleiben gegenüber ADR-0047 unverändert; nur der innere Versand-Body (SMTP → HTTP) und die Live-Verifikation des Smoke-Tests passen sich an. API-Read-back ist mit dem aktuellen Recruiting-Token nicht verfügbar.
Rejected Alternatives
Pfad C+ — Email-Inbox-Import + Webhook-ACK (ADR-0047)
Superseded, weil der Grund für seine Wahl entfallen ist. C+ war in ADR-0047 nur deshalb MVP-Architektur, weil Pfad A hinter dem Multiposting-Scope-Gate lag. Mit der Scope-Gewährung (P2503-191) ist diese Begründung hinfällig. C+ schleppt Komplexität mit, die Pfad A nicht braucht: Webhook-Subscription + Receiver + HMAC-Verifikation, das Risiko des nicht-validierten Personio-Inbox-Mail-Parsings, die Abhängigkeit von SPF/DKIM/DMARC-Absender-Reputation, und das Fehlen einer synchronen HTTP-Annahme. Alle diese Negativ-Consequences aus ADR-0047 verschwinden mit Pfad A.
Custom Content Entity als durable Application-Record
Rejected. Naheliegend wäre eine Custom-Content-Entity vacancy_application mit typisierten Base-Fields (Status-Enum, retry_count, …) statt skalarer Webform-Elemente. Das stärkste Argument dafür — „Entity bekommt EntityQuery, Views-Integration und Entity-Access geschenkt“ — hält im SOLCOM-Repo aber nicht: der einzige Präzedenz-Entity ProjectOfferAlert (solcom_project_offer_alert) hat keinen list_builder, keine views_data-Integration, keine Collection-Route und keine ausgelieferte View. Diese Infrastruktur wäre also auch beim Custom-Entity net-new. Bei einem MVP-Volumen von < 50 Submissions/Tag und einer Tabelle, die durch den 2xx-Purge größtenteils leer steht, überwiegt der Wiedererkennungswert der vertrauten Webform-Results-UI für Site-Builder. (Customer-lens-reviewer-Befund, 2026-05-29.)
Synchroner POST im Submit-Handler
Rejected, weil es die HR-Reliability-Anforderung opfert. Ein synchroner POST beim Submit gäbe sofortiges Feedback bei HTTP-2xx und bräuchte keine Queue. Aber: ein Personio-Ausfall / Timeout / Rate-Limit beim Submit würde die Bewerbung verlieren oder den Bewerber zum manuellen Neu-Absenden zwingen, und es gibt keinen Raum für 5 Retries über 6 Stunden. Damit fielen AK-9/10/11 (Retry, DLQ, Recovery) — die explizite HR-Stakeholder-Hälfte der User-Story — weg. Zudem würde der Submit auf 3×10 MB-Upload + API-Latenz blockieren.
Webform-_meta-Array als State-Store
Rejected — war der S-6-Bug. Die durchgefallene Implementierung speicherte den gesamten Lifecycle-State als Array in einem Hidden-Element _meta. Hidden-Webform-Felder casten Arrays beim Save zum String "Array", wodurch alle State-Reads brachen (DLQ-Listing permanent leer, Match permanent fehlschlagend). Ersetzt durch getrennte skalare Elemente.
advancedqueue-Contrib für Backoff/DLQ
Rejected für die Queue-Schale selbst. Die Core Queue API plus DelayedRequeueException deckt Backoff und Retry für < 50 Submits/Tag vollständig ab (Core-verifiziert). Eine zusätzliche Queue-Engine-Dependency wäre Over-Tooling. (Hinweis: für die DLQ-Recovery-UI werden hingegen bewusst webform_views + views_bulk_operations gezogen — siehe Consequences.)
Consequences
Positive
- Einfacher als C+. Vier Anforderungs-Blöcke fallen ersatzlos weg (Webhook-Subscription, Webhook-Receiver inkl. Signatur-Verifikation, Inbox-Adress-Konfiguration, 24h-Timeout-Cron). Der gesamte S-9-Komplex (optionale Signatur-Verifikation bei leerem Secret) verschwindet.
- Synchrone Bestätigung. Die API liefert die HTTP-2xx-Annahme direkt; kein
sent_pending_ack-Limbo, keine zeitbasierte Heuristik, keine Abhängigkeit von Webhook-Zustellungs-SLA oder Inbox-Mail-Parsing. Eine Application-ID im Response-Body ist optional. - Drei HARD-Findings strukturell vermieden statt gepatcht. S-5 (
DelayedRequeueException), S-6 (skalare Elemente), S-7 (Views+VBO POST/CSRF) sind durch Topologie-Wahl ausgeschlossen, nicht durch nachträgliche Korrektur. - Keine SPF/DKIM/DMARC-Absender-Reputation-Abhängigkeit und kein Risiko, dass Bewerbungen im Personio-Spam-Ordner landen.
Negative / Concerns (customer-lens-reviewer, 2026-05-29)
C-1 — Zwei net-new Contrib-Dependencies
Die DLQ-UI verlangt drupal/webform_views + drupal/views_bulk_operations; beide fehlen heute in composer.json. webform_views läuft gegen webform:^6.3.0-beta8 (Beta-Risiko). Mitigation: beide Deps explizit in den Implementation-Plan; falls webform_views am Beta scheitert, Fallback auf einen plain Core-Controller mit ConfirmFormBase-Actions (POST + _csrf_token, _custom_access auf manage karriere dlq) — die S-7-vermeidende Eigenschaft (POST statt GET) bleibt in beiden Varianten zwingend.
C-2 — Idempotenz ist neue Komplexität und probe-gated
Ein API-POST ist nicht fire-and-forget wie eine Inbox-Mail; Duplikat-Vermeidung wird zur Korrektheits-Anforderung. Der Request-Contract ist inzwischen live verifiziert, der Idempotency-Key-Support auf Personio-Seite bleibt separat zu beobachten. Mitigation: deterministischen Header weiterhin senden; weil kein API-Read-back verfügbar ist, muss ein fehlender Personio-Dedup-Support als eigenes Folge-Design behandelt werden.
C-3 — Status-Element-Sichtbarkeit nicht strukturell erzwungen
Jedes der skalaren Lifecycle-Elemente muss '#access': false tragen; vergisst es jemand, leakt interner State in die HR-Results-UI. Mitigation: verbindliche Regel im Implementation-Plan + ein Kernel-/Schema-Test, der die #access-Flags aller Lifecycle-Elemente gegenprüft.
C-4 — GDPR-delete() vs. Results-UI-Erwartung
Sofort-Löschung bei 2xx entfernt den Record aus dem Webform-Results-Tab; das bricht die Site-Builder-/HR-Default-Annahme „alle Bewerbungen sind im Results-Tab sichtbar“. Mitigation: PII-freier Watchdog-Audit-Eintrag pro Löschung (statt stiller delete()) + Erwartungs-Klarstellung für HR/Site-Builder im Story-Hub.
Neutral / Folgeeffekte
- ADR-0014-Topologie bleibt unverändert. Das Apply-CTA-Field-Binding (computed Sub-Route vs.
field_apply_url-Escape-Hatch) ist von dieser ADR unberührt — nur das, was hinter dem Submit passiert, ändert sich. - ADR-0035 (Personio-XML-Anonymous-Pulling, Inbound-Vacancy-Feed) bleibt unverändert.
- Sub-Task-Auswirkung: P2503-186 (Webhook-ACK) und die Webhook-Teile von P2503-187 werden gegenstandslos und sind umzuwidmen oder zu schließen. P2503-189 (Tenant-Config) reduziert sich: die Inbox-Adresse entfällt, aber die Custom-Apply-URL pro Job (zeigt auf die Drupal-Sub-Route) bleibt. P2503-190 (AVV): der übermittelte Feld-Scope ist bei Pfad A im Kern derselbe wie bei C+ (gleiche Bewerber-Felder, anderer Transport) — HR/Legal bestätigt, dass der AVV-Scope die API-Felder deckt.
- Personio-API-Credentials: der Recruiting-Access-Token liegt via key-Modul (Provider
env) unterkey.key.solcom_personio_api_key; die nicht mehr passendepapi-Client-ID wird für den Application-POST nicht verwendet. Die in der Grilling-/Probe-Session exponierten Werte gelten als kompromittiert; Rotation bleibt Production-Vorbedingung. - Site-Salt-Quelle für den
tracking_hash:Settings::getHashSalt()oder ein dediziertesstate-Secret (Implementation-Detail, im Plan zu fixieren). - Re-Enqueue-Loop-Guard (Re-Grill #2, 2026-05-29). Da der Lifecycle-State in skalaren Webform-Elementen liegt und der Worker ihn per
$submission->save()persistiert, muss zweierlei zwingend gelten: (a) der Worker schreibtretry_count/statusvor demDelayedRequeueException-Throw (der Throw verlässtprocessItem(), unddelayItem()friert die Payload ein); (b) der enqueuendeApplicationQueueHandler(ein WebformHandler) darf NUR beim Initial-Submit ein Queue-Item erzeugen — Guard: kein Enqueue, wenn dasstatus-Element bereits gesetzt ist. Ohne Guard re-triggert jedes Worker-save()den Handler → Endlos-Enqueue. Diese Bedingung war in keinem früheren Doc und ist Pflicht-Bestandteil des Worker-/Handler-Refactors. - Skalare statt
_meta-Array (Re-Grill #2). Die S-6-Wurzel war der Array-Typ im#type:hidden, nicht der Speicherort. Lifecycle-State bleibt co-located an der Submission (GDPR-Kopplung: stirbt/überlebt mit ihr, kein Orphan-State), aber als separate skalare Elemente (status,retry_count,sent_at,personio_application_id,tracking_hash). Code-Ist-Zustand: die installiertewebform.webform.vacancy_application.ymlträgt heute noch ein einzelnes_meta-Array-Element (S-6 live), der Worker wirft\RuntimeExceptionstattDelayedRequeueException(S-5 live), die DLQ-Routes sindGETohne CSRF (S-7 live) undPersonioApplicationClientexistiert nicht — die drei Findings sind topologisch ausgeschlossen, aber noch nicht im Code gefixt. Disposition: Refactor-mit-Kill-Liste (C+-only-ServicesPersonioMailBuilder/PersonioWebhookController/PersonioWebhookCommands+ Webhook-Route löschen, Scaffolding refactoren, Client neu bauen).
Geklärte und offene Fragen (Implementation-Phase-Probes)
- API-Request-/Response-Schema (geklärt 2026-05-30).
multipart/form-dataauf/v1/recruiting/applicationsist falsch und liefert Media-Type-Fehler. Korrekt ist zweistufig: Dokumente perPOST /v1/recruiting/applications/documentshochladen, danach JSON anPOST /v1/recruiting/applicationsmitfirst_name,last_name,email,job_position_idund optionalenfiles-Referenzen.X-Company-IDist Pflicht; der direkte Recruiting-Access-Token wird als Bearer verwendet. Die Live-Queue-Probe vom 2026-05-30 akzeptierte die Bewerbung mit 2xx, lieferte aber keine nutzbare Application-ID im Body. Da keine Application-ID zurückkommt und kein Read-back existiert, hältpersonio_application_idbei Erfolg den deterministischen Idempotency-Key (vgl. Idempotenz-Punkt) als lokalen Send-Token fest — eine nicht-leere Korrelations-Referenz, keine Personio-ID. Dokument-Transmission ist über das Flagpersonio_send_documentsschaltbar (Default an; Kill-Switch für einen degradierten documents-Endpoint). Auth-Modell bestätigt 2026-05-30: der Hex-Recruiting-Token ist der v1-Schreib-Bearer (mitX-Company-ID), während diepapi-Credentials v2-OAuth-Client-Credentials sind (Scopesrecruiting:read/write), deren v2-Recruiting-Read-Endpoints für Tenant 78231 aktuell 404 liefern. - Idempotency-Key-Support. Der Header wird deterministisch gesendet; ob Personio server-seitig dedupliziert, bleibt als Retry-Szenario zu verifizieren. Ein Pre-POST-Existence-Check per API ist mit dem aktuellen Token nicht verfügbar (v2 404, v1 GET 405).
- Field-Parität (AK-3). Exakte Feld-Inventur der Referenz-Form unter
karriere.solcom.devia agent-browser zum Implementation-Start.