Multi-Channel & CRM

Technische Architektur für Kanaladapter, vereinheitlichtes Messaging, die Vereinheitlichung von Kontaktidentitäten und den CRM-Hub.

Architektur von Multi-Channel & CRM

Owlat beginnt mit E-Mail. Der Kommunikations-Hub ist jedoch kanalunabhängig — jede Nachricht wird zu einem strukturierten Ereignis in derselben Strecke, ganz gleich, woher sie stammt. Diese Seite behandelt die technische Architektur für die Multi-Channel-Unterstützung und den CRM-Hub, der Kontakte über alle Kanäle hinweg vereinheitlicht.

Große Teile dieser Architektur sind bereits implementiert: die ausgehenden Kanaladapter (apps/api/convex/channels/adapters/), die Tabellen unifiedMessages und channelConfigs, die CRM-Tabellen contactIdentities und contactRelationships (apps/api/convex/schema/messaging.ts, apps/api/convex/schema/contacts.ts), die aktiven eingehenden Webhook-Routen, die inzwischen die Agenten-Strecke speisen, AES-256-GCM-Verschlüsselung im Ruhezustand für Kanal-Zugangsdaten, ein Cron zum automatischen Zusammenführen von Kontakten bei exaktem Bezeichner, ein 5-Minuten-Cron für Kanal-Health-Checks (apps/api/convex/crons.ts) und ein ausgehender Versandpfad, der Zugangsdaten entschlüsselt und die Adapter für SMS/WhatsApp/generisch aufruft. Was folgt, ist daher das ausgelieferte Rückgrat plus die Stellen, an denen die Vision dem Code noch vorauseilt: Der ausgehende Versand ist an den Antwortpfad des KI-Agenten und den manuellen Admin-Composer angeschlossen, bleibt aber untätig, solange keine Anbieter-Zugangsdaten existieren; die automatische Beziehungsextraktion hat noch keinen Aufrufer; und das gesundheitsgesteuerte Einreihen ausgehender Nachrichten steht weiterhin auf der Roadmap.

Schnittstelle der Kanaladapter

Kanaladapter sind einsteckbare TypeScript-Klassen, die verschiedene Kommunikationskanäle in ein einheitliches Nachrichtenformat normalisieren. Sie liegen in apps/api/convex/channels/adapters/ (die Schnittstelle ist types.ts) und laufen innerhalb von Convex — keine separaten Dienste, keine zusätzliche Infrastruktur.

// The channel discriminator is declared once, by the validators the schema and
// every Convex function argument already use (`lib/convexValidators.ts`), and
// derived everywhere else — including the web config form's channel type:
//   type UnifiedMessageChannel = 'email' | 'sms' | 'whatsapp' | 'generic' | 'chat'
//   type OutboundChannel       = 'sms' | 'whatsapp' | 'generic'   // has an adapter

interface ChannelAdapter {
  /**
   * Which channel this adapter dispatches — `OutboundChannel`, the
   * dispatchable subset ('sms' | 'whatsapp' | 'generic'), so an adapter
   * claiming `email` or `chat` is a compile error rather than a convention.
   */
  id: OutboundChannel

  /** Send a message through this channel */
  send(message: OutboundMessage): Promise<SendResult>

  /** Check delivery status of a sent message */
  getDeliveryStatus(externalId: string): Promise<DeliveryStatus>

  /** Report current connection health */
  healthCheck(): Promise<ChannelHealth>
}

// Exactly the two things `updateChannelHealth` persists. The shape once also
// declared `lastSuccessfulSend`, `rateLimitRemaining` and `latencyMs`: no
// adapter ever set the first two, and the third was measured on every probe
// and then discarded, so D10 dropped all three. Persisting probe latency is a
// schema change, not a member.
interface ChannelHealth {
  status: 'healthy' | 'degraded' | 'down'
  lastError?: string
}

interface OutboundMessage {
  contactId: string
  channel: UnifiedMessageChannel   // all five — dispatch fails safe on the two with no adapter
  content: {
    text?: string
    html?: string
    subject?: string        // email only
    mediaUrl?: string       // SMS/WhatsApp
  }
  threadId?: string
  metadata?: Record<string, string>
}

Der Vertrag ist bewusst rein ausgehend. Ein Adapter verantwortet Versand, Zustellstatus und Verbindungsgesundheit — genau das, was die Versand-Action, der Cron für den Zustellstatus und der Health-Cron aufrufen. Er verantwortet nichts Eingehendes: Signaturprüfung und Payload-Normalisierung leben in apps/api/convex/webhooks/adapters/{twilio,meta,generic}.ts, auf der echten HTTP-Route, gegen echte Secrets. Die Schnittstelle deklarierte einst zusätzlich ein parseInbound und ein validateSignature — eine zweite Kopie von Regeln, welche die Webhook-Adapter bereits verantworteten, ohne Aufrufer und mit einer Drift, die der Compiler nicht sehen konnte —, weshalb der Ehrlichkeits-Durchgang D10 das Paar gelöscht hat, statt zwei Antworten auf eine Frage zu behalten.

Einen neuen Kanal hinzuzufügen bedeutet daher zweierlei, nicht einerlei: diese Schnittstelle für die ausgehende Hälfte implementieren und für die eingehende Hälfte ein Modul unter webhooks/adapters/ samt zugehöriger Route ergänzen — dasselbe Muster wie bei den bestehenden Webhooks von Resend und MTA.

Eingebaute Adapter

AdapterAusgehendEingehendAnbieter
E-MailMTA / SES / Resend (bestehend)Eingehendes SMTP über den MTASelbst gehostet oder Cloud
SMSTwilio-APIWebhook vom AnbieterCloud-API
WhatsAppWhatsApp Business APIWebhook von MetaCloud-API
GenerischHTTP POST an eine externe URLHTTP POST mit gemeinsamem Secret von einem externen SystemBeliebig
ChatConvex-Echtzeit (nativ)Convex-Mutation (nativ)Eingebaut

Nur drei der fünf Zeilen sind Adapterklassen. E-Mail und Chat sind Kanäle des einheitlichen Nachrichtenmodells, keine Kanalanbieter: Ausgehende E-Mail läuft über den Send-Worker (apps/api/convex/delivery/worker.ts) zu einem konfigurierten Versandanbieter (MTA / SES / Resend / Mandrill) via apps/api/convex/lib/sendProviders/, und Chat ist nativ — sendChatMessage schreibt direkt in Convex-Tabellen, mit Echtzeit-Aktualisierung über Subscriptions. Beide hatten einst eine Platzhalterklasse, welche die obige Schnittstelle implementierte; jede täuschte ihre Antworten vor (ein send(), das fest einen Fehlschlag oder eine erfundene ID zurückgab, ein healthCheck(), das fest „healthy“ zurückgab, ein validateSignature(), das fest true zurückgab), und keine hatte einen Aufrufer — deshalb wurden beide gelöscht, statt sie als Attrappe zu behalten.

Die Adapter für SMS, WhatsApp und generisch sind echte Sende-Clients für Twilio/Meta/HTTP (apps/api/convex/channels/adapters/), und eine Convex-Versand-Action steuert sie an: dispatchOutbound (apps/api/convex/channels/outbound.ts) entschlüsselt die Zugangsdaten des Kanals, instanziiert den passenden Adapter, sendet und hält eine ausgehende unifiedMessages-Zeile fest (siehe Kasten unten).

Der ausgehende Versand ist verdrahtet, bleibt aber ohne Zugangsdaten untätig

Eingehend ist für SMS, WhatsApp und den generischen Kanal aktiv (die Webhook-Routen weiter unten). Der Versandpfad für ausgehende Nachrichten ist gebaut und hat heute zwei aktive Aufrufer — den Antwortpfad des KI-Agenten (sendApprovedReply in apps/api/convex/agent/agentPipeline.ts) und den manuellen Admin-Composer (sendChannelMessage in apps/api/convex/channels/outbound.ts). Beide planen dispatchOutbound ein, das die Zugangsdaten entschlüsselt, den Adapter aufruft und das Ergebnis mit einem ausfallsicheren failed-Zeilen-Vertrag festhält, der niemals wirft. Er bleibt nur so lange ein No-op (mit dem Vermerk Channel not configured), bis eine Betreiberin oder ein Betreiber Anbieter-Zugangsdaten konfiguriert. E-Mail und Chat versenden heute schon echt; SMS/WhatsApp/generisch sind nur einen Satz Anbieter-Zugangsdaten entfernt.

Einheitliches Nachrichtenmodell

Konversationsnachrichten aller Kanäle fließen in eine einzige Tabelle unifiedMessages — SMS, WhatsApp, generisch und Chat vollständig, dazu konversationelle E-Mail (eingehende Nachrichten und bestätigte Agentenantworten, idempotent über den Index by_external_message_id mitgespiegelt). Kampagnen-, Transaktions- und Automations-E-Mail ist bewusst ausgeschlossen — das sind keine Gesprächsbeiträge und erscheint stattdessen über contactActivities im Tab „Aktivität“ des Kontakts. Die Tabelle:

unifiedMessages: defineTable({
  threadId: v.id('conversationThreads'),
  channel: v.union(
    v.literal('email'),
    v.literal('sms'),
    v.literal('whatsapp'),
    v.literal('generic'),
    v.literal('chat')
  ),
  direction: v.union(v.literal('inbound'), v.literal('outbound')),
  contactId: v.optional(v.id('contacts')),
  memberId: v.optional(v.string()),     // internal sender (BetterAuth user ID)
  content: v.string(),                   // JSON: { text, html, subject, mediaUrl }
  contentVersion: v.optional(v.number()), // schema version of the content blob
  externalMessageId: v.optional(v.string()),
  status: v.union(
    v.literal('received'),
    v.literal('queued'),
    v.literal('sent'),
    v.literal('delivered'),
    v.literal('read'),
    v.literal('failed')
  ),
  metadata: v.optional(v.string()),      // Channel-specific metadata (JSON)
  createdAt: v.number(),
})
  .index('by_thread', ['threadId'])
  .index('by_channel', ['channel'])
  .index('by_contact', ['contactId'])
  .index('by_created_at', ['createdAt'])

Die Tabelle conversationThreads (eingeführt in der Agenten-Strecke) wird zum universellen Knotenpunkt. Ein Thread kann E-Mail-Nachrichten, SMS-Nachrichten, Chat-Nachrichten und Webhook-Ereignisse enthalten — allesamt in chronologischer Reihenfolge.

Kanalkonfiguration

Jede Organisation konfiguriert, welche Kanäle aktiv sind und wie sie sich verbinden:

channelConfigs: defineTable({
  channel: v.union(
    v.literal('email'),
    v.literal('sms'),
    v.literal('whatsapp'),
    v.literal('generic'),
    v.literal('chat')
  ),
  isEnabled: v.boolean(),
  displayName: v.optional(v.string()),
  config: v.optional(v.string()),  // JSON: provider-specific (API keys, phone numbers, etc.)
  // Health monitoring — folded directly onto the config row
  healthStatus: v.optional(v.union(
    v.literal('healthy'),
    v.literal('degraded'),
    v.literal('down')
  )),
  lastHealthCheckAt: v.optional(v.number()),
  lastSuccessfulSend: v.optional(v.number()),
  lastError: v.optional(v.string()),
  createdAt: v.number(),
  updatedAt: v.number(),
})
  .index('by_channel', ['channel'])

Das Feld config enthält eine anbieterspezifische JSON-Zeichenkette (API-Keys, Telefonnummern usw.). Wer selbst hostet, braucht für SMS und WhatsApp API-Keys der jeweiligen Anbieter — das sind die einzigen externen Abhängigkeiten, die sich nicht selbst hosten lassen.

Kanal-Zugangsdaten sind im Ruhezustand verschlüsselt

updateChannelConfig (apps/api/convex/unifiedMessages.ts) schreibt den config-Blob niemals im Klartext. Die Funktion läuft in der v8-Laufzeitumgebung und plant deshalb die Node-Action encryptAndPersistConfig (apps/api/convex/channels/outbound.ts) ein, die den Klartext über apps/api/convex/lib/credentialCrypto.ts in einen AES-256-GCM-Umschlag hüllt und die Zeile über setChannelConfigSecret patcht — den einzigen Schreiber der Spalte config. getChannelConfigInternal entschlüsselt sie innerhalb von dispatchOutbound für den Versand wieder. Die Spalte channelConfigs.config hält auf der Platte daher stets nur einen verschlüsselten Umschlag, nach demselben Schema, das bereits für externalMailAccounts verwendet wird. Die eingehenden Anbieter-Secrets (Twilio-Auth-Token, Meta-App-Secret und Verify-Token, generisches gemeinsames Secret) stammen aus derselben Zeile: Die v8-Webhook-Routen können sie nicht selbst entschlüsseln, lesen sie deshalb über die Node-Action channels.credentials.getInboundSecret und fallen auf die Umgebungsvariable des Deployments zurück, wenn der Kanal keines hinterlegt hat.

Health-Monitoring der Adapter

Jeder Adapter stellt eine Methode healthCheck() bereit. Ein Convex-Cronjob (channel health checks in apps/api/convex/crons.ts) läuft alle 5 Minuten, iteriert über die aktivierten channelConfigs-Zeilen und schreibt das Ergebnis über updateChannelHealth zurück, das healthStatus, lastHealthCheckAt und lastError patcht. E-Mail und Chat haben keinen Adapter, weshalb allein das Vorhandensein einer Konfiguration als Signal dient und sie als healthy markiert werden; eine SMS-/WhatsApp-/generische Zeile ohne Zugangsdaten wird ohne Netzwerkaufruf als down markiert; eine konfigurierte Zeile führt die echte Sonde des Adapters aus (probeChannelHealth, das node:crypto zum Entschlüsseln der hinterlegten Zugangsdaten braucht und deshalb in einer 'use node'-Action lebt) und meldet degraded/down, wenn der Anbieter sie abweist.

lastSuccessfulSend ist kein Feld des Health-Crons, obwohl es in derselben Zeile sitzt: unifiedMessages.recordOutbound stempelt es anhand eines tatsächlich zugestellten Versands. Eine Sonde meldet Erreichbarkeit, keinen Versand — deshalb trägt ChannelHealth nur status und lastError.

Das angestrebte gesundheitsgesteuerte Verhalten, sobald der ausgehende Versand vollständig verdrahtet ist, sieht so aus:

  • Degraded — ausgehende Nachrichten werden mit Backoff eingereiht statt sofort gesendet, und das Dashboard zeigt eine Warnung.
  • Down — ausgehende Nachrichten werden für eine spätere Zustellung eingereiht; eingehende Webhooks nehmen weiterhin Nachrichten an und speichern sie (der Anbieter liefert weiterhin an uns aus, auch wenn wir nicht zurücksenden können), und das Dashboard zeigt eine Warnmeldung mit dem letzten Fehler.

Ziel ist es, stille Fehlschläge zu verhindern — fällt die Twilio-API aus, sehen die Support-Mitarbeitenden die Beeinträchtigung in ihrem Dashboard, bevor Kundinnen und Kunden fehlende SMS-Antworten melden.

Noch nicht aktiv: gesundheitsgesteuertes Einreihen ausgehender Nachrichten

Der 5-Minuten-Cron für Health-Checks und die Felder healthStatus/lastError sind aktiv. Das oben beschriebene Verhalten Einreihen mit Backoff bei degraded/down beschreibt den angestrebten Entwurf — es ist noch nicht implementiert. Der ausgehende Versandpfad (dispatchOutbound) hat inzwischen aktive Aufrufer — den Antwortpfad des Agenten und den manuellen Composer (siehe „Der ausgehende Versand ist verdrahtet, bleibt aber ohne Zugangsdaten untätig“ unter „Eingebaute Adapter“) —, aber kein Versandpfad zieht vor dem Absenden healthStatus heran, sodass der Gesundheitszustand Sendungen bislang weder blockiert noch einreiht.

Muster für eingehende Webhooks

Die eingehenden Nachrichten jedes Kanals treffen über einen HTTP-Webhook ein. Das Muster ist identisch mit den bestehenden Webhook-Handlern für MTA und Resend:

External provider (Twilio, Meta, etc.)
  → POST /webhooks/{channel}
  → Inbound pipeline rate-limits the request
  → Adapter verifySignature() verifies webhook authenticity (Twilio HMAC, Meta X-Hub-Signature-256, generic shared secret)
  → Adapter parseEvent() normalizes the payload
  → Store in unifiedMessages (find-or-create contact + thread first)
  → Feed into the Agent Pipeline (live — project onto an inboundMessages row and start the agent walker)

Die HTTP-Routen liegen in apps/api/convex/http.ts; ihre Handler in apps/api/convex/webhooks/channels.ts sind dünne Hüllen, die runInboundPipeline() (apps/api/convex/webhooks/pipeline.ts) aufrufen, was Signaturprüfung und Parsing an die anbieterspezifischen Eingangsadapter unter apps/api/convex/webhooks/adapters/ delegiert (twilio.ts, meta.ts, generic.ts). Die Signaturprüfungen werden durchgesetzt — eine fehlende oder ungültige Signatur wird abgewiesen, bevor irgendeine Nachricht gespeichert wird. (Hinweis: Diese Eingangsadapter sind von den oben beschriebenen ausgehenden ChannelAdapter-Klassen verschieden und sind die einzige Stelle, an der die Signatur eines eingehenden Kanals geprüft wird.) Nach dem Speichern der unifiedMessages-Zeile projiziert processInboundChannel die Kanalnachricht auf eine inboundMessages-Zeile und plant internal.agent.walker.start ein — eingehende SMS/WhatsApp/generische Nachrichten laufen also nun durch dieselbe Agenten-Strecke wie E-Mail. Dieser Schritt ist Best-Effort und über das Flag ai.agent abgesichert: Ein deaktivierter oder falsch konfigurierter Agent verwandelt eine gespeicherte eingehende Nachricht nie in einen 5xx, der den Anbieter zu einem erneuten Versuch bewegen würde. Die Routenregistrierungen:

// SMS inbound (Twilio)
http.route({
  path: '/webhooks/sms',
  method: 'POST',
  handler: handleSmsWebhook,
})

// WhatsApp inbound (Meta)
http.route({
  path: '/webhooks/whatsapp',
  method: 'POST',
  handler: handleWhatsAppWebhook,
})

// WhatsApp verification challenge — Meta requires a GET handshake
http.route({
  path: '/webhooks/whatsapp',
  method: 'GET',
  handler: handleWhatsAppWebhook,
})

// Generic shared-secret webhook (for custom integrations)
http.route({
  path: '/webhooks/channel',
  method: 'POST',
  handler: handleGenericWebhook,
})

CRM-Hub

Der CRM-Hub erweitert die bestehende contacts-Tabelle um eine vereinheitlichte Identitätsverwaltung und Beziehungsintelligenz.

Vereinheitlichung von Kontaktidentitäten

Dieselbe Person kommuniziert oft über mehrere Kanäle — geschäftliche E-Mail, private E-Mail, Telefon, WhatsApp. Das CRM vereinigt diese zu einem einzigen Kontaktprofil:

contactIdentities: defineTable({
  contactId: v.id('contacts'),
  channel: v.string(),         // 'email', 'phone', 'whatsapp', 'twitter', etc.
  identifier: v.string(),      // email address, phone number, handle
  isPrimary: v.boolean(),
  verifiedAt: v.optional(v.number()),
  createdAt: v.number(),
})
  .index('by_contact', ['contactId'])
  .index('by_identifier', ['channel', 'identifier'])

Trifft eine eingehende Nachricht ein, prüft die Strecke contactIdentities auf einen passenden Bezeichner:

  1. Treffer gefunden → mit bestehendem Kontakt verknüpfen, Thread aktualisieren
  2. Kein Treffer → einen neuen Kontakt samt Identität anlegen
  3. Automatisches Zusammenführen bei exaktem Bezeichner → teilen sich zwei lebende Kontakte einen exakt übereinstimmenden starken Bezeichner (E-Mail / Telefon / SMS / WhatsApp), faltet der Cron auto-merge duplicate contacts (autoMergeDuplicates in apps/api/convex/contacts/identities.ts, alle 6 Stunden) die Dubletten ohne menschliche Bestätigung in den ältesten Kontakt hinein. Starke Kanäle sind die eindeutige Teilmenge; schwache bzw. Social-Handles (Twitter, generisch, Chat) sind bewusst ausgenommen.
  4. Vorgeschlagene Zusammenführung → für alles außerhalb dieser Teilmenge exakter starker Bezeichner (etwa unscharfe Überschneidungen oder solche bei Social-Handles) zeigt getMergeSuggestions im Tab „Identitäten“ des Kontakts einen Zusammenführungsvorschlag an, den ein Mensch bestätigen muss, statt automatisch zusammenzuführen.

Beziehungsintelligenz

Die Vision sieht vor, dass der Wissensgraph Beziehungserkenntnisse in das CRM einspeist. Die Tabelle contactRelationships und ihr manual-Upsert sind heute ausgeliefert; Beziehungen werden von Betreiberinnen und Betreibern eingetragen. Ein automatisierter Extraktionspfad würde einen neuen source-Zweig und einen Schreiber ergänzen, der Konversationen auswertet — beides existiert noch nicht:

contactRelationships: defineTable({
  fromContactId: v.id('contacts'),
  toContactId: v.id('contacts'),
  relationship: v.string(),    // "manager_of", "colleague", "reports_to", etc.
  confidence: v.number(),      // 0-1
  source: v.literal('manual'),
  createdAt: v.number(),
})
  .index('by_from', ['fromContactId'])
  .index('by_to', ['toContactId'])

Die CRM-Ansicht für jeden Kontakt soll Folgendes zeigen — heute schon aktiv:

  • Vereinheitlichter Zeitstrahl — Konversationsnachrichten über alle Kanäle hinweg (SMS/WhatsApp/generisch/Chat plus eingehende E-Mail und Agentenantworten) in chronologischer Reihenfolge; E-Mail aus Massenkampagnen, Transaktionen und Automationen lebt im Tab „Aktivität“, nicht hier
  • Wissenszusammenfassung — zentrale Fakten, Präferenzen und Ziele aus dem Wissensgraphen

und auf der Roadmap (diese hängen von der Beziehungs- und Stimmungsextraktion ab, die noch nicht verdrahtet ist — siehe den Kasten unten):

  • Beziehungskarte — Verbindungen zu anderen Kontakten (Kolleginnen und Kollegen, unterstellte Personen usw.)
  • Stimmungsverlauf — wie sich die Stimmung des Kontakts im Zeitverlauf entwickelt hat
  • Offene Zusagen — in Konversationen gemachte Versprechen („Sie sagten, Sie schicken das Angebot bis Dienstag“)
  • Kommunikationspräferenzen — bevorzugter Kanal, Antwortmuster, aktive Zeiten

Kommunikationsnative Aktualisierungen

Klassische CRMs verlangen manuelle Dateneingabe. In Owlat baut sich das CRM aus der tatsächlichen Kommunikation selbst auf:

  • Schreiben Sie einer Investorin oder einem Investor eine E-Mail → die Interaktion wird automatisch protokolliert
  • Kippt die Stimmung einer Kundin oder eines Kunden ins Negative → der Beziehungs-Gesundheitswert wird aktualisiert
  • Wird in einem Thread über einen Abschluss gesprochen → der Pipeline-Status spiegelt die Konversation wider
  • Wechselt ein Kontakt den Job (erkannt an geänderten E-Mail-Signaturen) → das Profil wird aktualisiert

Angestrebt ist, dass all dies über die Extraktionsstrecke des Wissensgraphen geschieht — das CRM als Sicht auf den Wissensgraphen, nicht als separater Datenspeicher.

Teilweise: Die Wissensextraktion ist aktiv, die automatische Beziehungsextraktion nicht

Der vereinheitlichte Zeitstrahl (apps/web UnifiedTimelineTab / useUnifiedContactTimeline über getContactTimeline), Kontaktidentitäten, das automatische Zusammenführen bei exaktem Bezeichner, manuelle Beziehungen und die Oberfläche für Zusammenführungsvorschläge sind allesamt aktiv. Auch die Wissens-Extraktion pro Nachricht ist inzwischen in die Eingangsstrecke verdrahtet: Sobald eine Nachricht klassifiziert ist, plant processingLifecycle.ts sowohl für eingehende E-Mail als auch für eingehende Kanalnachrichten internal.knowledge.extraction.extractFromMessage ein (idempotent, Best-Effort). Nicht verdrahtet ist die Beziehungs-Hälfte des sich selbst aufbauenden CRM-Verhaltens — das automatische Protokollieren extrahierter Fakten in contactRelationships, Stimmungsverläufe, Abschluss-/Pipeline-Status und die Erkennung von Jobwechseln aus E-Mail-Signaturen. contactRelationships (apps/api/convex/contacts/relationships.ts) trägt heute nur die Quelle manual, Beziehungen bleiben also von Betreiberinnen und Betreibern eingetragen. Betrachten Sie die Schicht zur Beziehungs- und CRM-Ableitung als Zielentwurf.