Architecture Decision Record · 0029

solcom_sf_publication — Hybrid-Architektur (Migrate inbound + Custom outbound + Per-Run-Sweep)

proposed

TL;DR. Modul solcom_sf_publication spiegelt FCSB-Salesforce-Publications (TR1__Job__c, RecordType Publication) nach Drupal-Bundle sf_publication. Inbound via migrate_plus mit Custom Data-Fetcher (für SF-spezifisches nextRecordsUrl-Paging im JSON-Body), Standard-oauth2-Auth (grant_type: refresh_token), Per-Run-Sweep im MigrateEvents::POST_IMPORT-EventSubscriber. Outbound (URL-Writeback nach hohr__Job_Application_URL__c) via hook_node_update mit Status-Transition-Guard → QueueWorker → eigener SalesforceClient-Service mit Token-Cache. Folgt dem Repo-Standard ADR-0013 (Migrate-API für externe Quellen) und der Vacancy-Editor-Boundary aus ADR-0026.

Kontext

SOLCOM-Recruiter und Account-Manager pflegen offene Projekt-Publikationen vollständig in Salesforce (FCSB-Org, BH4SF-Layer auf TR1__Job__c mit RecordType Publication). Das geplante Projektpartnerportal (PPP, Story-Epos Projektpartnerportal) braucht eine automatisierte Spiegelung dieser Publications nach Drupal, plus einen Rück-Schreib-Pfad für die kanonische PPP-URL (hohr__Job_Application_URL__c) bei Publish/Unpublish-Transitionen.

Ein erster Implementierungsversuch auf Branch feature/P2503-108 (Commit 791d974) bestand ausschließlich aus Doku-Files (ADR-0026/0027 lokal, Context-0008 lokal, 8 Jira-Sub-Tasks P2503-127..134), keine Code-Schicht. Die zentrale Architektur-Frage Migrate-API vs. Custom-Queue wurde dort nicht evaluiert. Das ist die Auslöser-Lücke für diesen ADR: ADR-0013 (inline im Personio-Vacancy-Integration-PRD) hat den Repo-Standard explizit gesetzt:

„Migrate API + migrate_plus + migrate_tools (Cron+Drush). Cron 3600s. Rejected: Custom-QueueWorker (Idempotency selbst bauen), Feeds-Modul (D11 weniger gepflegt). Consequences: Setzt Standard für zukünftige externe Quellen."

Konstellation für die neue Architektur:

  • Daten-Schicht: 22 Inbound-Felder + 1 Outbound-Feld, 25-Werte-Industry-Picklist mit DE-Labels, 4 Force.com-Rich-Text-Felder mit MS-Word-Artefakten, 2 Datetime-Felder fürs Publishing-Window, 1 Picklist für den Lifecycle-Status (Match-Wert „Online"). Vollständiges Mapping in Context-0008.
  • Auth-Schicht: OAuth-2 mit Refresh-Token-Flow gegen login.salesforce.com (FCSB-Sandbox via my.salesforce.com-Domain). Credentials liegen als Env-Vars (SF_FCSB_CLIENT_ID, SF_FCSB_CLIENT_SECRET, SF_FCSB_REFRESH_TOKEN) vor — Pattern aus ADR-0011 (MCP-Token-Sourcing) und ADR-0010 (config_split).
  • Lifecycle-Schicht: Drupal-Editor:innen dürfen sf_publication-Nodes nicht editieren — analog zur Vacancy-Editor-Boundary aus ADR-0026. SF bleibt Lifecycle-Source-of-Truth.
  • Repo-Realität: migrate_plus bringt out-of-the-box ein OAuth2-Authentication-Plugin (alle 5 RFC-6749-Grant-Types via Sainsburys-Guzzle-OAuth2-Library), und ein Http-Data-Fetcher mit RFC-5988-Link-Header-Paging. SF-SOQL nutzt aber kein RFC-5988-Header-Paging, sondern liefert nextRecordsUrl als Feld im JSON-Body — d.h. der Fetcher muss angepasst werden.
  • Outbound-Realität: Migrate-API hat keine Drupal→SF-Destination. Outbound muss ereignisbasiert (Drupal-Node-Publish-Transition), nicht Batch-basiert (Migrate-Cron) sein. Beide Schienen brauchen einen SF-Bearer-Token; der Token kann geteilt oder pro Schiene separat gehalten werden.

Die Entscheidung ist nicht 1:1 aus dem Vacancy-Pattern ableitbar: Personio liefert ein öffentliches XML ohne Auth und ohne Paging. SF braucht OAuth, JSON-Body-Paging und einen Outbound-Pfad. Aber das Pattern-Skelett (Migrate für Inbound, Custom Process Plugins, POST_IMPORT-EventSubscriber für Hard-Delete) ist 1:1 übertragbar — der Grilling-Prozess (siehe PRD) hat acht Sub-Decisions herausgearbeitet, die alle in diese ADR-Entscheidung einfließen.

Entscheidung

Hybrid-Architektur. Inbound via migrate_plus mit Custom Data-Fetcher für SF-JSON-Body-Paging und Standard-OAuth2-Auth; Outbound via hook_node_update mit Status-Transition-Guard → QueueWorker → eigenem SalesforceClient-Service; Reconciliation via Per-Run-Sweep im MigrateEvents::POST_IMPORT-EventSubscriber.

Das Modul web/modules/custom/solcom_sf_publication bekommt folgende Verantwortungs-Schichten:

1 — Inbound (Migrate-Schiene)

SOQL-Vollscan stündlich gegen /services/data/v59.0/query, RecordType-Filter, JSON-Response, nextRecordsUrl-Paging. Plugin-Trio:

SchichtPluginImplementierung
SourceurlStandard migrate_plus
Data-Fetchersalesforce_soqlCustomSalesforceSoqlFetcher extends Http, überschreibt getNextUrls() für JSON-Body-Paging
Data-ParserjsonStandard migrate_plus
Authenticationoauth2 mit grant_type: refresh_tokenStandard migrate_plus (Sainsburys Guzzle-OAuth2)

2 — Credentials

Keine Credentials im Repo. Migration-YAML enthält Placeholder-Strings; settings.php setzt:

$config['migrate_plus.migration.sf_publication']
       ['source']['authentication']['client_id']     = getenv('SF_FCSB_CLIENT_ID');
$config['migrate_plus.migration.sf_publication']
       ['source']['authentication']['client_secret'] = getenv('SF_FCSB_CLIENT_SECRET');
$config['migrate_plus.migration.sf_publication']
       ['source']['authentication']['refresh_token'] = getenv('SF_FCSB_REFRESH_TOKEN');

Pattern analog ADR-0011 (MCP-Token-Sourcing). drupal/key-Modul ist Backup-Storage, nicht primärer Pfad.

3 — Auth-Sharing — Strict-Separation

Migrate-Schiene nutzt das Standard-oauth2-Plugin (frischer Token pro Migration-Run via Sainsburys-Middleware). Outbound-Schiene bekommt eigenen SalesforceClient-Service mit eigener getValidToken()-Logic (cached in cache.default, ~50min TTL — SF-Bearer gilt 1h). Bewusste Doppel-Auth: kostet nichts (SF Token-Endpoint hat kein relevantes Rate-Limit), bringt saubere Schicht-Trennung; Q2-Entscheidung bleibt unverletzt.

4 — Reconciliation — Per-Run-Sweep (POST_IMPORT-EventSubscriber)

SfPublicationHardDeleteSubscriber hört auf MigrateEvents::POST_IMPORT: sammelt alle in diesem Run gesehenen SF-Ids, gleicht gegen migrate_map_sf_publication ab, löscht Drupal-Nodes deren SF-Id in der Map aber nicht im Run-Set steht. Empty-Feed-Schutz: 0 Records → Sweep skip + Watchdog-Warning. HTTP-Fehler: Migration-Status failed → kein Sweep. SOQL-Konsequenz: Vollscan (kein Last-Modified-Delta), sonst würde der Sweep nicht-modifizierte Records false-positive löschen. 1:1-Port von solcom_vacancy/src/EventSubscriber/VacancyHardDeleteSubscriber.php.

5 — Industry-Lookup — Custom Process Plugin + Taxonomy mit field_sf_value

Vocabulary sf_industry mit 25 Terms (Name = DE-Label, field_sf_value = SF-API-Value, Liste in Context-0008). Pre-Seed via solcom_sf_publication.installhook_install. Custom Process Plugin sf_publication_industry_lookup matcht SF-Picklist-Wert gegen field_sf_value; Auto-Create-on-Miss mit Watchdog-Warning. Pattern aus solcom_vacancy_term_generate.

6 — Rich-Text-Sanitize — Service + Process Plugin (DI)

SalesforceRichTextSanitizer-Service in solcom_sf_publication.services.yml nutzt DOMDocument + Whitelist (p, br, strong, em, ul, ol, li, h2-h4, a[href]); entfernt <font>, <o:p>, class="MsoNormal", alle inline-Styles. Custom Process Plugin sf_publication_richtext_sanitize injiziert via ContainerFactoryPluginInterface. Vier Felder durchlaufen den Sanitizer (siehe Context-0008).

7 — Lifecycle-Mapping — static_map

Drupal-Node-Status ist published gdw. SC_PublicationStatus__c == "Online" AND publish_start <= now < publish_end. Picklist-Match via static_map-Process-Plugin in der Migration-YAML ("Online" → 1, default → 0). Mapping-Source-of-Truth ist Context-0008 mit explizitem Drift-Hinweis (Mapping-Doc spricht teilweise von „Published", die Sandbox liefert „Online" — „Online" ist authoritativ).

8 — Outbound — hook_node_insert + hook_node_update mit Trigger-Guards + QueueWorker

solcom_sf_publication.module implementiert zwei Hooks:

  • hook_node_insert($node) — enqueued set, wenn ein neu angelegter Node bereits published ist ($node->isPublished() === TRUE). Deckt den Migrate-Initial-Insert-Pfad ab (eine SF-Publication, die beim ersten Auftauchen schon „Online" im Publishing-Window ist). Kein $node->original-Check, da original beim Insert null ist.
  • hook_node_update($node) — enqueued nur bei Status-Transition ($node->original->isPublished() !== $node->isPublished()). Verhindert Queue-Flooding bei den Migrate-Re-Saves identischer Records pro Run.

Beide Hooks teilen den Bundle-Check ($node->bundle() === 'sf_publication'). QueueItem-Format {nid, sf_id, action: 'set'|'clear'}; QueueWorker SfPublicationOutboundWorker ruft SalesforceClient::patchPublication($sf_id, ['hohr__Job_Application_URL__c' => $url|null]). Action-Mapping: Insert-published oder Update-to-published → set; Update-to-unpublished → clear.

Modul-Topologie (Zusammenfassung)

web/modules/custom/solcom_sf_publication/
├── solcom_sf_publication.info.yml
├── solcom_sf_publication.install        # hook_install: 25 sf_industry-Terms seeden
├── solcom_sf_publication.module         # hook_node_update mit Trigger-Guard
├── solcom_sf_publication.permissions.yml
├── solcom_sf_publication.services.yml   # SalesforceClient, SalesforceRichTextSanitizer
├── config/install/
│   └── migrate_plus.migration_group.sf_publication.yml
├── src/
│   ├── Service/{SalesforceClient, SalesforceRichTextSanitizer}.php
│   ├── EventSubscriber/SfPublicationHardDeleteSubscriber.php
│   └── Plugin/
│       ├── migrate_plus/data_fetcher/SalesforceSoqlFetcher.php
│       ├── migrate/process/{SfPublicationIndustryLookup, SfPublicationRichtextSanitize}.php
│       └── QueueWorker/SfPublicationOutboundWorker.php
└── tests/src/{Unit, Kernel}/…

config/sync/   (außerhalb des Moduls)
├── migrate_plus.migration.sf_publication.yml
├── node.type.sf_publication.yml
├── taxonomy.vocabulary.sf_industry.yml
└── ~21 × field.{storage,field}.node.sf_publication.*.yml

Rejected alternatives

Pull-Mechanik

  • Pure Custom-QueueWorker für Inbound: Bricht den Repo-Standard ADR-0013 frontal. Wir müssten Idempotenz, Map-Tabelle, Drush-Trigger, Update-Detection selbst bauen — alles Funktionalität, die migrate_plus kostenlos liefert. Für SF spricht nichts dafür außer „weniger Drupal-Indirection", was kein technisches Argument ist.
  • Pure Migrate für Outbound (Migrate-Destination nach Salesforce): Existiert in migrate_plus nicht. Outbound ist außerdem ereignisbasiert auf Publish-Status-Transitionen, nicht Batch-basiert — Migrate ist für bidirektionalen Real-Time-Sync nicht gedacht.
  • Drupal-Salesforce-Suite (drupal/salesforce_suite): Die Suite wäre Out-of-Box-Mapping + Push-Listener + Push-Queue + Admin-UI für Sync-Setup. Für unseren engen Scope (21 Felder, 1 Bundle, RecordType-Filter, Per-Run-Sweep, ein Outbound-Feld) ist das zu groß und addet Maintenance-Risk (Module-Maintainer-Status, D11-Kompatibilität). Migrate-API + 1 Custom Fetcher + 3 Custom Plugins + 1 Service ist transparenter und passt zum etablierten Vacancy-Pattern.

Inbound-Plugin-Topologie

  • Custom Source-Plugin (alles in einer Klasse): Eine SalesforcePublication-Source-Klasse, die Auth, SOQL-Paging und Parsing selbst macht. Mehr Code, aber alles an einem Ort. Bricht den klaren Plugin-Layer-Cut (Source → Fetcher → Parser); macht spätere Wiederverwendung des Fetchers für andere SF-Objects unmöglich.
  • Custom Authentication-Plugin (SalesforceOAuth2 mit Key-Service-DI): Eigene Subklasse von OAuth2, die Credentials nicht aus der YAML-Konfig nimmt, sondern über drupal/key-Service injiziert. Sauberer als $config-Override (kein YAML-Token-Leak in cex-Output), aber zusätzliche Plugin-Klasse zur Wartung. $config-Override-Pattern hat keinen Leak (Placeholder bleibt im YAML, Echtwert kommt aus Env-Var) — bringt also keinen Gewinn.

Auth-Sharing

  • Shared SalesforceAuthService für Inbound + Outbound: Single Source of Truth für Token, aber kippt das obige Auth-Plugin-Argument zurück: Migrate-Schiene bräuchte ein Custom-Plugin, das den Service injiziert. Zusätzlicher Code, nur um einen Token-HTTP-Call pro Stunde einzusparen — SF Token-Endpoint hat kein relevantes Rate-Limit.
  • Outbound zweckentfremdet das migrate_plus-OAuth2-Plugin direkt: \Drupal::service('plugin.manager.migrate_plus.authentication')->createInstance('oauth2', $config) in der QueueWorker-Logic. Semantisch zweifelhaft (Plugin ist für Migrate-Source-Auth, nicht für ad-hoc-Calls); Future-Drift-Risiko bei migrate_plus-Major-Updates.

Reconciliation

  • Nightly-Full-Sweep (separater Drush-Command + hook_cron-Tick): Wäre konsistent mit dem Briefing-Wunsch „einmal pro Nacht", würde aber gegen den Vacancy-Pattern-Standard verstoßen (Per-Run-Sweep ist dort etabliert). Macht nur Sinn, wenn der Inbound-Run mit Delta-Filter (Last-Modified) läuft — den nutzen wir hier nicht, weil dann der Per-Run-Sweep false-positive-deleten würde.
  • Hybrid Per-Run-Delta + Nightly-Full-Audit: Inbound mit Last-Modified-Filter (effizienter, kleinerer SOQL-Response), Sweep nur nachts mit Volldump. Robust, aber zwei SOQL-Query-Klassen zu warten — over-engineered für unsere geringe Datenmenge.

Industry-Lookup

  • static_map-Plugin mit hardcoded YAML-Mapping: 25 Einträge direkt in der Migration-YAML, DE-Labels als Strings. Kein zusätzliches Vocabulary, kein Custom Plugin. Aber: SF-Picklist-Drift erfordert YAML-PR + cim, nicht editor-pflegbar; Translatability verloren.
  • entity_lookup-Plugin ohne Auto-Create: Strenger als unsere Auto-Create-Variante — bei SF-Drift gibt's Migration-Fehler statt Auto-Term + Log-Warning. Weniger robust gegen Real-World-SF-Drift.
  • Service-Klasse + Process Plugin mit DI: Saubere Tests, aber Overhead für 25 fixe Werte. Sinnvoll erst, wenn Mapping-Logic komplexer wächst (Multi-Sector, Sub-Sektoren).

Rich-Text-Sanitize

  • Inline Process Plugin (kein Service): Eine Klasse macht alles. Weniger Code, kein services.yml-Eintrag. Aber Unit-Tests für Sanitize-Logic schwer (über MigrateExecutable-Kontext); typischerweise nur Kernel-Tests möglich. Für 30+ Zeilen Sanitize-Logic ist isolierte Testbarkeit den Service-Mehraufwand wert.
  • Filter-Modul + Render-Time-Sanitize: Custom Filter im Text-Format, der beim Render strippt. Daten bleiben schmutzig in der DB — bricht das Single-Source-of-Truth-Prinzip; Re-Import würde sauber sanitizen, Direkt-DB-Read nicht.
  • drupal/htmlpurifier-Modul: Battle-tested externe Lib, aber zusätzliche Composer-Dep + Config-UI. Overkill für 4 Felder mit bekannter Whitelist.

Lifecycle-Mapping

  • Config-Entity mit Admin-UI: Picklist-Mapping als editierbare Drupal-Config. Editor:innen könnten Mapping ohne Code-PR ändern. SF-Picklist-Werte ändern sich aber SEHR selten (Setup-Permission in SF) — Admin-UI ist over-engineered; Pflege-Verantwortung verwischt zwischen Backend-Team und Drupal-Editor.
  • Hardcoded in Custom Process Plugin: Wert als Klassen-Konstante im Plugin. Schnellste Implementation, aber Drift erfordert Code-Change ohne YAML-Diff-Sichtbarkeit. Weniger transparent als static_map.
  • Drupal-Workflow-State-Modul (content_moderation) mit Mapping-Hook: Synchronisiert Node-Moderation-State mit SF-Picklist. ADR-0026 hat das für Vacancy abgelehnt — analog für SF-Publications gilt: keine redaktionelle Workflow-Schicht nötig.

Outbound-Trigger

  • hook_node_update ohne Status-Transition-Guard: Würde bei jedem Save ein Outbound-Item enqueuen, auch bei Migrate-internen Saves — Queue floodet bei stündlichen Migrate-Runs mit hunderten redundanten PATCHs.
  • Salesforce-Push (Apex-Trigger / Outbound-Message / Platform Event): Wäre SF-side-getriggert (kein Drupal-Trigger nötig), aber: braucht SF-Side-Setup (Custom-Apex / Platform-Event-Definition), Drupal-side-Listener (cometd / HTTP-Endpoint), Replay-Logic bei Drupal-Downtime. Komplexitätssprung. Kann später als Phase-2-Optimierung kommen, wenn URL-Writeback-Latenz ein Problem wird.

Consequences

Was muss man wissen

  • Drupal-Editor:innen können sf_publication-Nodes nicht editieren oder anlegen — Permissions sind so gesetzt. Jede Drupal-seitige Änderung würde beim nächsten Migrate-Run überschrieben (analog Vacancy/ADR-0026).
  • SOQL-Query ist Vollscan (kein Delta-Filter). Konsequenz für API-Quota: bei steady-state < 200 Publications = 1 SOQL-Page/Stunde = 24 Requests/Tag. Sandbox-Daily-Quota (15.000 calls/24h) bleibt locker. Pre-Implementation-Sub-Task: SELECT COUNT(Id) FROM TR1__Job__c WHERE RecordTypeId='<Pub>' gegen Sandbox laufen lassen — bestätigt Paging-Strategie.
  • Industry-Vocabulary muss vor erstem Migrate-Run gefüllt sein. hook_install seeded 25 Terms mit DE-Labels (Liste in Context-0008). Wenn das vergessen wird, legt der Process-Plugin alle Industries als englische Strings an („Agriculture" statt „Landwirtschaft"). Watchdog-Warning macht das sichtbar; Editor:in muss DE-Label manuell nachpflegen.
  • Outbound-Trigger braucht beide Hooks: hook_node_insert für den Migrate-Initial-Pfad (Publication erscheint beim ersten Mal schon „Online", $node->original ist null) und hook_node_update mit Status-Transition-Guard für spätere Saves (Queue-Flooding-Schutz). Test-Coverage muss beide Pfade abdecken: Kernel-Test 1 — Insert-published-Node → 1× set; Kernel-Test 2 — Update ohne Status-Change → 0× Enqueue; Kernel-Test 3 — Update mit Transition → 1× set oder clear.
  • Credentials liegen nicht im Repo: SF_FCSB_CLIENT_ID, SF_FCSB_CLIENT_SECRET, SF_FCSB_REFRESH_TOKEN müssen in der Hosting-Umgebung (Mittwald, lokal in settings.local.php) gesetzt sein. cex erzeugt Placeholder-Strings, nicht die echten Werte. Pattern aus ADR-0011.

Pre-Implementation-Gates

  • Live SF-Describe gegen FCSB-Sandbox vor Field-Storage-Config: /services/data/v59.0/sobjects/TR1__Job__c/describe aufrufen, alle 22 Inbound-Field-Namen verifizieren. Der Briefing-Verdacht zur _date_time__c vs. _date__c-Drift ist im Excel-Live-Sample nicht reproduzierbar (Live-Sample bestätigt _date_time__c), aber Live-Describe bleibt Pflicht-Gate.
  • Branch-Strategie für die Output-Commits: bisheriger Branch feature/P2503-108 trägt den verworfenen Doc-Commit 791d974 mit lokalem ADR-0026/0027 (Nummerkollision auf develop). Output dieser Session entsteht auf develop, Branch-Cleanup separat (Reset oder Löschen ohne destruktive Operation ohne User-Autorisierung).

Migrationsschritte / Sub-Tasks

Die ursprünglich angelegten 8 Sub-Tasks P2503-127..134 (auf dem verworfenen Branch) müssen gegen diesen ADR und das PRD neu refined werden. Erwartete neue Sub-Task-Granularität (10–12 Sub-Tasks):

  1. Sandbox-Connectivity + SOQL-Count-Query + Live-Describe (Pre-Implementation-Gate)
  2. Modul-Skeleton (.info.yml, .install mit Term-Seed, .services.yml)
  3. Bundle sf_publication + Field-Storage-Configs in config/sync/
  4. Vocabulary sf_industry + field_sf_value
  5. Custom Data-Fetcher SalesforceSoqlFetcher + Unit/Kernel-Test
  6. Process Plugins (industry_lookup, richtext_sanitize, lifecycle_status via static_map) + Tests
  7. SalesforceRichTextSanitizer-Service + Unit-Test
  8. Migration-YAML + erster Migrate-Run gegen Sandbox
  9. SfPublicationHardDeleteSubscriber + Kernel-Test (Empty-Feed-Schutz)
  10. SalesforceClient-Service + Mocked-HTTP-Test
  11. hook_node_update mit Status-Transition-Guard + QueueWorker + Kernel-Test
  12. End-to-End-Functional-Test mit Guzzle-MockHandler

Bewusste Restrisiken

  • Sainsburys Guzzle-OAuth2-Library ist archived (seit 2021). migrate_plus nutzt sie trotzdem weiter; bei einem Major-Update von migrate_plus oder einem Breaking-Change kann das Auth-Plugin brechen. Mitigation: solche Major-Updates flaggen, Test-Suite muss Auth-Pfad mocken.
  • SF-Picklist-Drift für SC_PublicationStatus__c: Wenn SF einen neuen Wert einführt (z.B. „Pending"), läuft unsere static_map in den default → 0-Fallback und der Publish-Status ist „unpublished" trotz scheinbar gültiger SF-Konfiguration. Mitigation: Log-Warning bei unbekanntem Picklist-Wert; Sub-Task im PRD-Open-Items.
  • Datenmenge: Bei sehr großem Wachstum (> 2000 Publications steady-state) wird nextRecordsUrl-Paging mit mehreren HTTP-Calls pro Run dominant. Kein direkter Performance-Blocker, aber Daily-Quota-Sizing wird relevant. Mitigation: SOQL-LIMIT 2000 + explizites Paging im Fetcher, plus Capacity-Monitoring im SOQL-Count-Sub-Task.
  • Outbound-Latenz: URL-Writeback nach SF läuft cron-getriggert (Drupal-Cron-Intervall, default 60s). Bei zeitkritischen Bedarfsfällen (Werbe-Anzeige sofort live) muss der Cron-Tick manuell getriggert werden. Mitigation: für V1 akzeptiert; Phase-2-Option ist SF-Push.

Customer-Lens-Reviewer: N/A — Backend-only

Die solcom-adr-workflow-Konvention macht den customer-lens-reviewer-Aufruf bei Komponenten-Topologie-/Architektur-Entscheidungen mit ≥2 Optionen zur Pflicht. Die drei festgeschriebenen Stakeholder-Linsen des Agenten sind Figma-Designer:innen, Drupal-Site-Builder:innen, Theme-Entwickler:innen — alle drei orientieren sich an UI-/Frontend-Artefakten (SDC-Topologie, Slot-vs-Prop-Granularität, Brand-Tokens, Twig-Komponentenfamilien).

Diese Entscheidung betrifft keinen Frontend-Touchpoint: kein SDC, kein Canvas-Template, kein Twig, keine CSS, keine Theme-Schicht. Die acht Sub-Decisions sind alle Backend-Service-/Plugin-/Migrate-Topologie-Fragen. Die Linsen-Set ist hier nicht 1:1 anwendbar.

Konsequenz: Customer-lens-Konsultation entfällt. Begründung wird hier explizit notiert (statt stillschweigend übergangen). Sollte das Modul später eine Listing-Page oder eine Project-Detail-SDC bekommen (Folge-Story Frontend-Slice F-290.x), greift die Lens-Pflicht für jene SDC-Topologie-Entscheidung erneut — separater ADR.