Architecture Decision Record · 0050

Vacancy-Application Submission via direktem Personio-API-POST (Pfad A)

Status: accepted supersedes ADR-0047 customer-lens-reviewer: GO-WITH-CONCERN (2026-05-29)
TL;DR — Personio hat SOLCOM den Multiposting-Partner-Scope gewährt (Sub-Task P2503-191, abgeschlossen). Damit ist Pfad A — direkter HTTP-POST gegen 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:

  1. 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.
  2. Die C+-Implementierung fiel im Review durch. Der solcom-review-Lauf vom 2026-05-28 (Report) gab Acceptance: FAIL mit drei HARD-Findings: S-5 (Backoff via RuntimeException statt DelayedRequeueException — 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:

  1. Integration-Pfad. Ein QueueWorker lädt vorhandene Dokumente zuerst per POST /v1/recruiting/applications/documents hoch und macht anschließend POST /v1/recruiting/applications als application/json gegen 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.
  2. 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}) in solcom_personio_application_queue. Ein Thin-QueueWorker delegiert an einen PersonioApplicationClient-Service (HTTP + Auth + Response-Klassifikation). Auth ist nicht der /v2/auth/token-Client-Credentials-Flow, sondern der direkte Recruiting-Access-Token aus key.key.solcom_personio_api_key; X-Company-ID kommt 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.
  3. 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_atnicht 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.
  4. Backoff via DelayedRequeueException. Bei transientem Fehler wirft der Worker throw new DelayedRequeueException($delay) mit $delay aus dem Schedule [60, 300, 900, 3600, 21600] Sekunden (1 min / 5 min / 15 min / 1 h / 6 h). retry_count wird durable im Submission-Element inkrementiert (nicht in der Payload — siehe Kontext).
  5. Idempotenz. Jeder POST trägt einen deterministischen Idempotency-Key, der aus der Submission-UUID + Site-Salt rekonstruiert wird (tracking_hashrecomputen, 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/applications liefert 404, GET /v1/recruiting/applications 405). Der tracking_hash wechselt damit gegenüber ADR-0047 die Rolle — von Webhook-Match-Key zu Idempotenz-Key.
  6. Failure-Klassifikation nach HTTP-Status. 2xx → Erfolg, GDPR-Purge. 429Retry-After-Header als Delay honorieren. 5xx / Netzwerk / Timeout → transient, Backoff-Schedule bis 5×. 4xx auß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“).
  7. DLQ-Recovery-UI via Views + VBO. Die HR-Recovery-Oberfläche ist eine View auf webform_submission, gefiltert auf status = 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 Permission manage karriere dlq (HR-Rolle), kein Admin-Vollzugriff nötig.
  8. GDPR-Purge bei 2xx. Nach erfolgreichem POST löscht der Worker den Record vollständig: $submission->delete() (cascade'd webform_submission_data + webform_submission_log) plus file_usage-Cleanup und $file->delete() für die managed Files plus Löschung des private://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

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

Geklärte und offene Fragen (Implementation-Phase-Probes)