Begleitbeitrag zum Data-Engineering-Grundpfeiler, Teil 2 von 2. Teil 1 — eine Pipeline ist ein Deskriptor, kein Programm behandelte die Metadaten, die eine Pipeline liest: Quell-Schemata, Job-Deskriptoren, Zielverträge, Expectation Suites, Policy-Metadaten und den einen generischen Executor, der sie interpretiert. Dieser Teil behandelt, was sie emittiert — und warum genau das die gesamte Governance-Geschichte ist.
Rückblick — wo Teil 1 endete
Teil 1 endete mit einem deskriptorgetriebenen Executor, der ein Datenprodukt schreibt und an jedem seiner drei Ausgänge emit(...) aufruft — abgeschlossen, quarantäniert und fehlgeschlagen — und die Definition hierher vertagt. block_publish ist kein vierter Ausgang: es wirft eine Exception, und dieser Wurf landet auf derselben einen Grenze, die auch Schema-Drift und unbekannte Policy-Tags abfängt. Ein blockiertes Publish verlässt die Pipeline also über fehlgeschlagen und trägt das Assertion-Ergebnis mit, das es blockiert hat. § 4 definiert emit() vollständig, sobald die drei Ströme feststehen, die es tragen muss.
Die drei Ströme, die er emittiert, wurden in Teil 1 § 1 benannt, und die Unterscheidung, auf die es am meisten ankommt, ist eine Wiederholung wert, weil die beiden Seiten leicht zu verwechseln sind: die Regeln sind eingehende Metadaten, die Ergebnisse sind ausgehende. Eine Expectation Suite ist etwas, das eine Pipeline liest. Ein Assertion-Ergebnis ist etwas, das sie erzeugt. Ein Programm, das nur das Erste besitzt, hat eine Richtlinie, deren Anwendung niemand nachweisen kann.
Drei ausgehende Ströme also:
- Lineage — konsumierte Eingaben, erzeugte Ausgaben, Transformationsidentität. § 1 und § 2.
- Assertion-Ergebnisse — was die deklarierte Expectation Suite für diesen Lauf gemeldet hat. § 3.
- Provenance — welcher Code, welcher Commit, welche Konfiguration diesen Lauf erzeugt hat. § 4.
Es gibt einen strukturellen Gewinn aus Teil 1, der vorab erwähnt gehört: Weil die Pipeline deklariert statt geschrieben wurde, steht das meiste, was diese Ereignisse brauchen, bereits im Deskriptor — Quelle und Ziel, die Rechtsgrundlage, die gepinnte Schema-Version. Emission ist weitgehend eine Frage des Weiterreichens von bereits Deklariertem. Genau deshalb bekommen Metadata-first-Pipelines ihren Governance-Nachweis nahezu geschenkt, und nachgerüstete Pipelines nie ganz.
Ein Hinweis zum Folgenden, wie in Teil 1. Das Argument wird mit echten Artefakten illustriert statt nur mit Diagrammen — OpenLineage-Payloads, Emitter-Konfiguration, ein kleiner Python-Adapter, Terraform. Es ist ein architektonisches Argument, kein sprachspezifisches, und der Fließtext trägt den Faden für alle, die die Payloads lieber überfliegen als Feld für Feld lesen. Nur an einer Stelle lohnt es, beim JSON langsamer zu werden: bei den zwei Facets in § 1, die die Governance-Schicht direkt liest.
§ 1 — OpenLineage als herstellerneutrales Rückgrat
OpenLineage ist eine offene Spezifikation für Lineage-Ereignisse, verwaltet unter der LF AI & Data Foundation. Es lohnt, ausdrücklich zu sagen, warum es hier die Wahl ist, denn „die Branche hat sich darauf geeinigt“ ist der schwächste der verfügbaren Gründe: Lineage ist das eine Artefakt in diesem Stack, das die Werkzeuge überdauern muss, die es erzeugt haben. Das einzige Format, in das man jahrelange Historie schreiben sollte, ist deshalb eines, das kein einzelner Runtime-, Cloud- oder Katalog-Anbieter einseitig ändern kann. Das Argument trägt auch in einem Haus mit einer einzigen Engine und ohne Umzugspläne: Die Neutralität ist eine Versicherung, die man abschließt, bevor man sie braucht — keine Reaktion darauf, bereits vier Engines zu haben.
Die Struktur ist bewusst klein: Ein RunEvent (eines pro Job-Ausführung) trägt eine Eingabeliste, eine Ausgabeliste, eine Job-Kennung, eine Lauf-Kennung und Facets — erweiterbare Metadaten, die an jedes davon angehängt werden können.
{
"eventType": "COMPLETE",
"eventTime": "2026-08-04T09:14:22.123Z",
"producer": "https://github.com/cordata-tech/pipeline-runtime/tree/2.4.1",
"schemaURL": "https://openlineage.io/spec/2-0-2/OpenLineage.json#/$defs/RunEvent",
"run": {
"runId": "b8c1c9e3-5a1e-4c0f-9a1a-2c9f8b1a5e12",
"facets": {
"processing_engine": {"name": "spark", "version": "3.5.1"},
"nominalTime": {"nominalStartTime": "2026-08-04T09:00:00Z"}
}
},
"job": {
"namespace": "cordata.fraud",
"name": "transactions-scored-daily",
"facets": {
"sourceCodeLocation": {"type": "git", "path": "sql/score.sql",
"url": "https://github.com/cordata-tech/domains-fraud"},
"processing": {"purpose": "fraud-detection", "legal_basis": "legitimate-interest"}
}
},
"inputs": [
{"namespace": "cordata.fraud", "name": "fraud_raw.transactions"}
],
"outputs": [
{"namespace": "cordata.fraud", "name": "fraud_curated.transactions_scored",
"facets": {"schema": {"fields": [{"name": "tx_id", "type": "STRING"},
{"name": "fraud_score", "type": "DOUBLE"}]}}}
]
}Eine Anmerkung vor den Feldern, auf die es ankommt. producer und schemaURL sind auf jedem Ereignis Pflicht und hier vollständig gezeigt; jedes Facet verlangt zusätzlich eigene _producer- und _schemaURL-Angaben, die in diesem Beitrag durchgehend weggelassen sind, damit die besprochenen Felder lesbar bleiben. Eine von hier kopierte Payload wird von einem validierenden Consumer abgelehnt, bis diese ergänzt sind — die Spezifikation ist die Referenz, nicht diese Auszüge. Das Ereignis oben ist zudem eine zusammengeführte Sicht statt der Ausgabe eines einzelnen Emitters: Die Runtime-Integration steuert die mechanischen Facets bei, der Executor die deklarierten, beide unter derselben runId. § 4 löst das auf.
Zwei Facets im Beispiel oben verdienen es, benannt zu werden, weil die Governance-Schicht sie direkt liest:
job.facets.processing— die Felderpurposeundlegal_basisfließen unmittelbar in die DSGVO-Art.-30-Projektion im Behaviour-first-Begleitbeitrag § 2. Das Verarbeitungsverzeichnis wird aus diesen Feldern erzeugt. Das heißt: Das Lineage-Ereignis jedes Jobs trägt die Compliance-Metadaten seiner eigenen Verarbeitungstätigkeit — keine separate Tabelle im Compliance-Team. Beachtenswert, woher die beiden Werte stammen: aus demprocessing-Block des Deskriptors in Teil 1 § 2. Die Pipeline-Autorin hat sie einmal deklariert, in der Datei, die sie ohnehin bearbeitet hat.output.facets.schema— das Schema dessen, was der Job erzeugt hat. Nachgelagerte Consumer, die über DataZone abonnieren, sehen dieses Schema automatisch im Katalog. Kein manueller Registrierungsschritt. Beachtenswert, wer es füllt: die Runtime-Integration, nicht der Executor. Dasopenlineage-spark-Plugin liest die Spaltenliste aus dem Spark-Ausführungsplan;emit()setzt es nie, weil ein Deskriptor deklariert, welche Tabelle geschrieben wird, nicht welche Spalten die Transformation erzeugt. § 4 arbeitet durch, wie zwei Emitter zu einem Ereignis beitragen.
Es lohnt klarzustellen, was dieses zweite Facet nicht ist, denn der Instinkt erwartet einen Zeiger: Es bettet die Feldliste ein, statt auf einen Registry-Eintrag zu verweisen — und das ist für Nachweiszwecke die richtige Richtung. Ein Verweis auf „v7“ ist nur so gut wie das Versprechen, dass sich die Definition von v7 nie ändert; ein eingebetteter Schnappschuss hält fest, welche Form dieser Lauf tatsächlich erzeugt hat, und bleibt wahr, was die Registry danach auch tut. Der Registry-Verweis ist ein eigenes Anliegen mit eigenem Facet — CatalogDatasetFacet trägt Framework, Katalogname und eine metadataUri, und dorthin gehört ein Zeiger auf den Glue Data Catalog oder die Glue Schema Registry. Die eingehende Seite aus Teil 1 pinnt eine Version; die ausgehende Seite hält eine Beobachtung fest. Dieselbe Trennung wie überall sonst in diesen beiden Beiträgen, eine Ebene tiefer.
Das Ökosystem macht es billig, nach diesem Argument zu handeln. Jede nicht-triviale Pipeline-Runtime hat eine Integration — Spark (openlineage-spark), Airflow (seit 2.7 im Kern), dbt (der dbt-ol-Wrapper), Trino, Flink, Dagster. Einen Job zu instrumentieren ist typischerweise eine Konfigurationszeile plus Paketinstallation, kein Code-Umbau.
Die Alternative — proprietäre Lineage aus dem SaaS-Katalog eines einzelnen Anbieters — macht zwei Eigenschaften des Lineage-Graphen von einer Geschäftsbeziehung abhängig: seine Portabilität und seine Abdeckung. Wechselt der Katalog-Anbieter, wird die Lineage zum Datenmigrationsprojekt. Kommt eine neue Pipeline-Runtime in den Stack (etwa Flink neben bestehendem Spark), sieht der proprietäre Katalog nur den halben Graphen.
§ 2 — Der AWS-native Einführungspfad: DataZone als OpenLineage-Senke
Die naheliegende Frage in AWS-nativen Häusern lautet an dieser Stelle: „Brauchen wir ein separates Lineage-Backend wie Marquez?“
Seit 2024 lautet die Antwort nein. Amazon DataZone nimmt OpenLineage-Ereignisse direkt entgegen, über seine PostLineageEvent-API — im Juli 2024 als Preview angekündigt, seither gereift. Die Ereignisse landen in DataZones eigenem Lineage-Speicher, und Ereignisse, LF-Tag-Metadaten und Subscription-Log liegen alle in demselben Katalog, aus dem die Governance-Schicht liest.
Das Muster:
Diagramm in Worten. Jede Pipeline-Runtime emittiert OpenLineage-Ereignisse an ihrer natürlichen Grenze — Spark auf Glue über das openlineage-spark-Plugin, Airflow auf MWAA über den eingebauten OpenLineage-Provider, dbt über den dbt-ol-Wrapper und der Executor selbst über emit(). Alle Emitter posten an eine dünne Adapter-Lambda (oder je nach Volumen an einen Kinesis-Stream), die die Payload unverändert in eine PostLineageEvent-Anfrage verpackt. Die Lineage landet in DataZones Katalog, neben den Assets, Subscriptions und LF-Tag-Policies, die die Fabric-+-Mesh-auf-AWS-Referenz etabliert hat.
Die Emitter-Integrationen
Glue-Spark-Jobs — das openlineage-spark-Plugin. Das Plugin wird als Glue-Job-Argument angehängt; es emittiert Ereignisse für jede DataFrame-Operation, ohne den Job-Code zu berühren. Eine Glue-spezifische Falle zeigt man besser, als sie zu beschreiben: Job-Parameter sind eine Schlüssel-Wert-Abbildung, --conf kann also nur einmal vorkommen. Jede weitere Spark-Einstellung muss innerhalb dieses einen Werts verkettet werden, --conf inklusive:
# Glue job parameters — two keys, not five
--extra-jars s3://cordata-artifacts/openlineage-spark_2.12-1.34.0.jar
--conf spark.extraListeners=io.openlineage.spark.agent.OpenLineageSparkListener
--conf spark.openlineage.transport.type=http
--conf spark.openlineage.transport.url=https://lineage-adapter.cordata.internal
--conf spark.openlineage.namespace=cordata.fraud
Man beachte den Artefaktnamen: Das JAR trägt das Scala-Versionssuffix (_2.12) und muss zur Scala-Version der eingesetzten Glue-Runtime passen.
MWAA-Airflow — der eingebaute OpenLineage-Provider. Hier ist das richtige Paket entscheidend, denn das ältere taucht in Suchergebnissen weiterhin auf: OpenLineage ist mit Airflow 2.7 unter AIP-53 in den Airflow-Kern gewandert. Das eigenständige openlineage-airflow ist veraltet und darf auf Airflow 2.8 oder neuer nicht installiert werden — auf MWAA heißt das: immer der Provider.
# requirements.txt
apache-airflow-providers-openlineage==2.20.0
# MWAA "Airflow configuration options" — each becomes AIRFLOW__OPENLINEAGE__*
openlineage.transport {"type": "http", "url": "https://lineage-adapter.cordata.internal"}
openlineage.namespace cordata.orchestration
dbt — der dbt-ol-Wrapper. Dieser ist kein Plugin, und der Unterschied zählt. OpenLineage-Unterstützung ist keine Funktion von dbt Labs — dbt-core #11750 ist weiterhin ein offener Wunsch —, die Integration ist also openlineage-dbt, ein vom OpenLineage-Projekt gepflegter Wrapper, den man anstelle des dbt-Binaries aufruft. Er führt dbt unverändert aus, liest nach Abschluss manifest.json und run_results.json und emittiert aus diesen Artefakten. Konfiguriert wird über Umgebungsvariablen, nicht über profiles.yml:
pip install openlineage-dbt
OPENLINEAGE__TRANSPORT__TYPE=http
OPENLINEAGE__TRANSPORT__URL=https://lineage-adapter.cordata.internal
OPENLINEAGE_NAMESPACE=cordata.transform
# then substitute the wrapper for the binary
dbt-ol run --select tag:curated
Dass es ein Nachverarbeiter ist und kein prozessinterner Listener, hat eine Konsequenz, die man kennen sollte, bevor man sich darauf verlässt: Ereignisse treffen nach Abschluss des Laufs ein, nicht währenddessen. Ein dbt-Prozess, der stirbt, bevor er seine Artefakte schreibt, emittiert also gar nichts. Das ist das Gegenteil der Garantie, die die run()-Grenze aus Teil 1 gibt — und es lohnt zu wissen, welche der eigenen Runtimes im Fehlerfall ein terminales Ereignis liefern und welche stillschweigend Schweigen.
Der Executor selbst — emit(). Der vierte Emitter im Diagramm ist die Pipeline-Runtime aus Teil 1, die an denselben Adapter postet wie die Plugins. Sie ist da, weil nichts anderes im Stack zwei der drei Ströme erzeugen kann: Kein Plugin kennt die legal_basis des Deskriptors, und keines kann die Deklaration hashen, die den Lauf erzeugt hat. § 4 definiert sie.
Hinweis — Great Expectations kann auch selbst emittieren. Gut zu wissen, bevor man sich dagegen entscheidet. openlineage-integration-common[great-expectations] liefert eine OpenLineageValidationAction, die in die Action-Liste eines Checkpoints gehört:
{"name": "open_lineage",
"action": {"class_name": "OpenLineageValidationAction",
"module_name": "openlineage.common.provider.great_expectations",
"openlineage_host": os.getenv("OPENLINEAGE_URL"),
"openlineage_namespace": os.getenv("OPENLINEAGE_NAMESPACE")}}Das Paket pinnt great-expectations>=1.0.0, während das veröffentlichte Beispiel noch den älteren Checkpoint-Stil zeigt — die Verdrahtung also gegen die tatsächlich eingesetzte Great-Expectations-Version prüfen.
Der Executor hängt das Ergebnis stattdessen selbst an, aus einem Grund: Er hält die Validation bereits, weil er die Suite an der Pipeline-Grenze ausgeführt hat. Dort angehängt, liegt das Assertion-Ergebnis auf demselben terminalen Ereignis wie Lineage und Provenance, unter dem Lauf, der alle drei erzeugt hat — statt davon abzuhängen, dass eine separat konfigurierte Checkpoint-Action ein passendes eigenes Ereignis absetzt. Die Action ist die richtige Wahl, wenn Great Expectations dort läuft, wo der Executor nicht ist — etwa ein geplanter Scan über eine Warehouse-Tabelle. Innerhalb einer deskriptorgetriebenen Pipeline ist die Suite ohnehin Teil des Laufs, das Ergebnis reist also mit.
Der dünne Adapter — verpacken, nicht übersetzen
Der Adapter ist dünner, als „Übersetzung“ nahelegt, denn DataZone will gar keine andere Payload. PostLineageEvent nimmt das OpenLineage-RunEvent als rohen Request-Body — die API-Referenz sagt ausdrücklich, dass „only open-lineage run event are supported as events“ — und trägt DataZones eigene Kennungen an anderer Stelle der HTTP-Anfrage. Innerhalb der Payload wird nichts umgeschrieben.
Diagramm in Worten. Ein Ereignis hinein, eine HTTP-Anfrage hinaus, dreifach aufgeteilt. Die DataZone-Domain-Kennung adressiert die Anfrage über den URI-Pfad; ein Hash der Payload wird zum Client-Token-Header, der Wiederholungen idempotent macht; und das OpenLineage-Ereignis selbst ist der Body, Byte für Byte so, wie der Emitter ihn erzeugt hat. Genau diese Eigenschaft ist der Grund, warum das ein dünner Adapter bleibt und keine Abbildungsschicht mit eigenem Release-Zyklus — und warum ein OpenLineage-Upgrade, das ein Facet ergänzt, hier gar keine Änderung erfordert.
# adapter.py — simplified core
import hashlib
import json
import os
import boto3
datazone = boto3.client("datazone")
DOMAIN_ID = os.environ["DATAZONE_DOMAIN_ID"] # dzd-… , one per DataZone domain
def handle(openlineage_event: dict) -> None:
payload = json.dumps(openlineage_event, sort_keys=True)
datazone.post_lineage_event(
domainIdentifier=DOMAIN_ID,
# Idempotency is per event, not per run. One runId legitimately
# produces several events — a START, the plugin's COMPLETE, and our
# own — so keying the token on runId alone would drop all but the
# first and quietly undo the accumulation § 4 depends on. Hashing the
# payload dedupes genuine retries and nothing else. sort_keys makes
# that hash stable across dict orderings.
clientToken=hashlib.sha256(payload.encode()).hexdigest(),
event=payload,
)Zwei Grenzen sollte man kennen, bevor das Produktionslast trägt. Der event-Body ist auf 300 KB gedeckelt — ein breites Schema-Facet oder spaltengenaue Lineage über eine Tabelle mit hundert Spalten kommt dem näher, als es klingt, und der Adapter ist der richtige Ort, das zu bemerken und laut zu scheitern, statt Ereignisse in einer ValidationException verschwinden zu lassen. Und clientToken ist auf 128 Zeichen mit dem Muster [\x21-\x7E]+ begrenzt, was ein 64-stelliger Hex-Digest mit Reserve erfüllt.
Dieses Token hört auf, theoretisch zu sein, sobald das Volumen einen von API Gateway zu Kinesis treibt: Die Zustellung ist mindestens-einmal, und Lambda wiederholt einen fehlgeschlagenen Batch als Ganzes — ein Batch, der beim vierhundertsten Ereignis scheitert, liefert die ersten dreihundertneunundneunzig erneut aus. Das sort_keys=True ist es, das den Digest über diese Wiederholungen stabil hält, weil Python Dicts in Einfügereihenfolge serialisiert.
Wann man nichts davon braucht. openlineage-python liefert einen AmazonDataZoneTransport mit, der dieselbe PostLineageEvent-API aus dem Client heraus aufruft — ein einzelner Python-Produzent kann also per Konfiguration auf DataZone zeigen und hier aufhören zu lesen. Der Adapter verdient seinen Platz in der Topologie, um die es in diesem Beitrag geht, in der die Produzenten Spark, dbt und Airflow sind statt eines Python-Prozesses, und in der ein Transport innerhalb des Python-Clients sie nicht bedienen kann. Vor der Übernahme aber wissenswert: Dieser Transport sendet überhaupt kein clientToken — die Idempotenz oben bekommt man vom Adapter, nicht von der Bibliothek.
§ 3 — Assertion-Ergebnisse als Data-Quality-Facet
OpenLineage sagt, was geschehen ist. Das Assertion-Ergebnis sagt, ob die Daten, die herauskamen, etwas taugten — und es ist der Strom, der Datenqualität aus einer erklärten Absicht in einen Nachweis verwandelt, den eine Aufsicht lesen kann.
Teil 1 § 5 behandelte die Expectation Suite als deklarierte Eingabe: ein versioniertes Artefakt, im Eigentum des Fachbereichs, gelesen an der Pipeline-Grenze nach der Transformation und vor dem Publish. Dies ist das Assertion-Ergebnis, das sie erzeugt, und das emit() aus § 4 legt es auf denselben Lauf:
{
"eventType": "OTHER",
"run": {"runId": "b8c1c9e3-5a1e-4c0f-9a1a-2c9f8b1a5e12"},
"job": {"namespace": "cordata.fraud", "name": "transactions-scored-daily.validate"},
"inputs": [{
"namespace": "cordata://fraud",
"name": "fraud_curated.transactions_scored",
"inputFacets": {
"dataQualityAssertions": {
"assertions": [
{"assertion": "expect_column_values_to_not_be_null", "column": "tx_id", "success": true},
{"assertion": "expect_column_kl_divergence_to_be_less_than", "column": "fraud_score",
"success": false, "expected": "0.15", "actual": "0.19"}
]
}
}
}],
"outputs": []
}Zwei Dinge an dieser Platzierung wirken zunächst falsch, und beide sind Spezifikation und nicht Geschmack. Das Assertion-Ergebnis hängt an einer Eingabe, und der Datensatz, den es benennt, ist derjenige, den diese Pipeline gerade erzeugt hat. dataQualityAssertions ist als InputDatasetFacet typisiert, inputFacets ist damit sein einziger standardkonformer Ort — und der Datensatz, auf den sich eine Assertion bezieht, ist eine Eingabe für die Assertion, was immer er für die Pipeline war. Beide Referenz-Integrationen machen es so: die von dbt emittiert es gegen einen <model>.test-Job, und die OpenLineage-Action von Great Expectations emittiert inputs=[…], outputs=[]. Legt man es stattdessen in die facets des Ausgabe-Datensatzes, ist das Ereignis weiterhin wohlgeformt — Facet-Maps sind offen —, aber das Ergebnis liegt dort, wo niemand nachsieht, und das ist die stille Sorte falsch. Zweitens ist der Typ OTHER und nicht ein zweites COMPLETE, weil Teil 1 § 3 genau ein terminales Ereignis pro Lauf zusichert — und OTHER ist das, was die Spezifikation für zusätzliche Metadaten reserviert, die sich gegen eine Lauf-ID ansammeln, also derselbe Mechanismus, auf den sich § 4 stützt.
Eine Lauf-ID also, die sowohl die Lineage als auch das Qualitätsurteil für denselben Batch trägt. Dass sie als zwei Ereignisse eintreffen, kostet Konsumenten nichts — auf der Lauf-ID zu joinen ist genau das, wofür ein Lineage-Speicher da ist —, und die Ko-Lokation ist es, die den nächsten Zug möglich macht.
Wer das nachgelagert liest. Kein Signal für ein Publikum — es landet in demselben Katalog, in dem bereits drei Rollen arbeiten, und jede stellt eine andere Frage daran.
- Der erzeugende Fachbereich. Der Deskriptor benennt einen
owner, und eine fehlschlagende Suite ist zuerst das Problem dieses Teams. Dason_failureaus Teil 1 hat bereits entschieden, ob dieser Batch veröffentlicht wird; was das Emittieren des Ergebnisses hinzufügt, ist Historie — dieselbe Expectation, die zweimal in einer Woche fehlschlägt, ist ein anderes Problem als ein einmaliger Fehlschlag. - Die Governance- oder Datenqualitätsfunktion. Ergebnisse sammeln sich zu einer Reihe pro Datensatz, mit Lauf, Batch und Expectation daran. Ein Verstoß gegen eine Qualitätsrichtlinie wird zu etwas mit Nachweis daneben, statt zu einer Behauptung, die jemand hinterher rekonstruieren muss.
- Consumer des Datensatzes. Das Facet hängt am Knoten des Datensatzes im Katalog, ein Team, das über ein Abonnement entscheidet — oder ein Dashboard, das gleich dagegen aktualisiert —, kann also sehen, ob der jüngste Batch bestanden hat, bevor es sich darauf verlässt. Das ist die Frage, die ein Consumer tatsächlich hat, und meist die, die niemand beantworten kann.
Automatisierte Durchsetzung sitzt am äußersten Ende dieser Spanne: Eine Regel wie „setze den Grant eines Abonnements aus, wenn die täglichen Assertions eines Erzeugers dreimal in einer rollierenden Woche fehlschlagen“ wird überhaupt erst ausdrückbar, sobald die Ergebnisse Ereignisse sind — dasselbe Betriebsmuster wie in Behaviour-first Governance in der Praxis, wo Durchsetzung und Nachweis derselbe Akt sind. Es lohnt klarzustellen, dass das eine Eskalation ist und kein Standard: Es beantwortet eine Qualitätsfrage durch Entzug von Zugriff, und das sind nicht dieselben Achsen.
Man beachte, welche Assertion in der Payload oben fehlschlägt: expect_column_kl_divergence_to_be_less_than auf fraud_score, tatsächlich 0,19 gegen eine deklarierte Schranke von 0,15. Kein Schema-Test hätte das gefangen — die Spalte ist vorhanden, korrekt typisiert und nicht null. Die Ausgabeverteilung des Modells hat sich schlicht verschoben. Der Wert davon, das Ergebnis zu emittieren statt die Prüfung nur auszuführen, liegt darin, dass diese Drift nun ein abfragbares Ereignis mit Zeitstempel ist, angehängt an genau den Batch, der sie gezeigt hat, in demselben Speicher, auf den die Prüfung ohnehin Zugriff hat.
Der Unterschied zwischen dem, was ein Schema-Test fangen kann, und dem, was dieser gefangen hat, entscheidet auch, wer die Prüfung schreiben kann. Eine Schema-Expectation — Spalte vorhanden, korrekt typisiert, nicht null — braucht nur die Form der Daten, ein Plattform- oder Pipeline-Ausführungsteam kann sie also schreiben oder aus dem Katalogeintrag erzeugen, ohne zu wissen, wofür die Tabelle da ist. Eine Schranke von 0,15 auf die KL-Divergenz von fraud_score braucht jemanden, der weiß, was dieser Score bedeutet, wie seine Verteilung normalerweise aussieht und was eine verschobene über das Modell dahinter aussagt. Kein Plattformteam hat das, und kein zentraler Qualitätsdienst erwirbt es dadurch, dass man ihm mehr Tabellen gibt. Genau deshalb legt Teil 1 § 5 die Suite in das Repository des besitzenden Fachbereichs: Formprüfungen lassen sich zentralisieren, semantische existieren nur dort, wo die Bedeutung existiert.
§ 4 — Provenance: die Identität des Laufs
Lineage sagt, welche Datensätze beteiligt waren. Provenance sagt, welches Rezept diesen konkreten Batch erzeugt hat — der Code, der Commit, die Konfiguration, die Engine: nicht die Daten des Laufs, sondern seine Identität.
Das ist der Strom, der die Frage beantwortet, bei der ein BaFin- oder DORA-Gespräch irgendwann ankommt: Welcher Code hat diese Zahl berechnet, gegen welche Eingaben, und wer hat es autorisiert?
In einer deskriptorgetriebenen Plattform ist Provenance ungewöhnlich billig zu erzeugen, weil der Deskriptor aus Teil 1 bereits der größte Teil des Rezepts ist. Hasht man ihn, hat man Quelle, Schritte, Ziel, Vertrag und Rechtsgrundlage in einem einzigen Feld erfasst:
"run": {
"facets": {
"processing_engine": {"name": "spark", "version": "3.5.1"},
"cordata_provenance": {
"descriptor_path": "domains/fraud/pipelines/transactions_scored.yml",
"descriptor_sha256": "9c1f4b7e…",
"source_table": "fraud_raw.transactions",
"source_schema_version": 7,
"descriptor_git_commit": "4af3958a…",
"descriptor_git_commit_signed": true,
"executor_version": "pipeline-runtime 2.4.1",
"step_params": {"model_version": "2026-07-fraud-v3"}
}
}
}Vier dieser Felder leisten die eigentliche Arbeit.
descriptor_sha256 pinnt die exakte Deklaration, die dieser Lauf ausgeführt hat — nicht den Deskriptor, wie er heute aussieht, sondern wie er aussah, als dieser Lauf lief. source_schema_version pinnt, welche Form die Eingabe hatte, und das ist der Unterschied zwischen „diese Zahl kam aus transactions“ und „diese Zahl kam aus transactions, wie es definiert war, bevor die Händlerkategorie-Spalte dazukam“. step_params hält die Werte fest, die in die deklarierten Schritte eingesetzt wurden — dort lebt model_version: 2026-07-fraud-v3, die prüfungsrelevanteste Einzeltatsache über einen Scoring-Lauf.
Hier werden zwei Identitäten festgehalten, und es lohnt, sie nicht zu vermengen. descriptor_git_commit ist das Repository des Fachbereichs — der Commit, der diesen Deskriptor trug, und genau das macht die Reproduktion weiter unten möglich. executor_version ist das der Plattform, und beide bewegen sich unabhängig: Ein Fachbereich kann einen Schwellwert ändern, ohne dass sich die Runtime ändert, und die Runtime kann ein Release ausliefern, ohne dass sich ein Deskriptor bewegt. „Welcher Code hat diese Zahl berechnet“ braucht beide Antworten, deshalb stehen beide auf dem Ereignis.
Ein berechtigter Einwand an dieser Stelle: Was hindert daran, den festgehaltenen Hash nachträglich zu ändern? Für sich genommen nichts — eine Zahl in einem Ereignis beweist sehr wenig, und eine Prüfung behandelt sie zu Recht als Behauptung, nicht als Tatsache. Was sie zum Nachweis macht, ist ihre Reproduzierbarkeit. descriptor_git_commit steht im selben Facet, Git-Objekte sind inhaltsadressiert, jede und jeder kann also ein Jahr später git show <commit>:<descriptor_path> | sha256sum ausführen und prüfen, ob drei unabhängige Artefakte übereinstimmen: der zur Laufzeit festgehaltene Hash, der Deskriptor, wie dieser Commit ihn gespeichert hat, und die Signatur über diesen Commit. Eine Abweichung zwischen ihnen ist selbst der Befund. Den Satz zu fälschen hieße, Git-Historie umzuschreiben, das Lineage-Ereignis zu verändern und unter einem vertrauenswürdigen Schlüssel neu zu signieren — und das Ereignis liegt nicht dort, wo das erzeugende Team hinkommt: PostLineageEvent zielt auf eine DataZone-domainIdentifier, und die Topologie § 1 legt die DataZone-Domain in das zentrale Governance-Konto, nicht in die Producer-Konten, in denen die Pipelines laufen.
descriptor_git_commit_signed ist das Feld, das den Kreis schließt. Der Commit, der diese Deskriptor-Version eingeführt hat, wurde signiert, reviewt und unter dem GitOps-Fluss gemerged, den der Behaviour-first-Begleitbeitrag § 5 beschreibt. Das Provenance-Facet hält also nicht bloß fest, was lief — es verkettet sich zu wer es autorisiert hat, über ein Artefakt, das niemand nachträglich bearbeiten kann. Ein Lauf, dessen Provenance auf einen unsignierten Commit zeigt, ist selbst ein Befund.
Die nachgerüstete Variante davon ist ein Deployment-Protokoll — welche Modellversion wann live ging — von Hand gepflegt, von wem auch immer sich erinnert.
Ein Lauf, drei Ströme — das emit(), das Teil 1 immer wieder vertagte
Der Executor aus Teil 1 ruft emit(...) an allen drei Ausgängen auf und vertagt die Definition hierher. Es ist die kleinste interessante Funktion im System, weil zu diesem Zeitpunkt jedes Feld, das sie braucht, bereits deklariert oder berechnet ist:
# platform/pipeline_runtime/emit.py
from dataclasses import replace
from openlineage.client import OpenLineageClient
from openlineage.client.event_v2 import Job, Run, RunEvent, RunState, set_producer
from openlineage.client.facet_v2 import error_message_run
# Becomes the producer on every event; the client supplies schemaURL.
set_producer(f"https://github.com/cordata-tech/pipeline-runtime/tree/{__version__}")
# Resolves its transport from the environment, so nothing here names a
# destination — where events go is deployment configuration, not a pipeline's.
client = OpenLineageClient()
STATE = {
"COMPLETE": RunState.COMPLETE,
"FAIL": RunState.FAIL,
"QUARANTINED": RunState.FAIL, # a quarantined run did not publish
}
def emit(pipeline: Descriptor, run_id: str, result: Validation | None,
status: str, error: PipelineError | None = None) -> None:
# 1. Run facets — provenance always, plus the reason if this run died (§ 4)
run_facets = {"cordata_provenance": provenance_facet(pipeline)}
if error is not None:
run_facets["errorMessage"] = error_message_run.ErrorMessageRunFacet(
message=str(error), programmingLanguage="python")
# 2. Datasets — what the run actually touched. A failed run touched nothing;
# a quarantined one wrote to quarantined(target), not the declared one.
match status:
case "FAIL":
inputs, outputs = [], []
case "QUARANTINED":
inputs, outputs = [dataset(pipeline.source)], [dataset(quarantined(pipeline.target))]
case _:
inputs, outputs = [dataset(pipeline.source)], [dataset(pipeline.target)]
job = Job( # job id + job facets
namespace=f"cordata.{pipeline.metadata.domain}",
name=pipeline.metadata.name,
facets={"processing": pipeline.processing.model_dump()}, # § 1
)
# 3. The terminal event — exactly one per run, carrying the five parts § 1 named
client.emit(RunEvent(
eventType=STATE[status],
eventTime=now_utc_iso(),
run=Run(runId=run_id, facets=run_facets), # run id + run facets
job=job,
inputs=inputs, # input list — consumed
outputs=outputs, # output list — produced
))
# 4. The assertion result, when there is one to report, against the dataset
# that was actually tested (§ 3). OTHER, not a second COMPLETE: part 1
# promises one terminal event per run, and this is not it.
if result is not None:
tested = quarantined(pipeline.target) if status == "QUARANTINED" else pipeline.target
client.emit(RunEvent(
eventType=RunState.OTHER,
eventTime=now_utc_iso(),
run=Run(runId=run_id), # same run — see § 4 on accumulation
job=replace(job, name=f"{job.name}.validate"),
inputs=[input_dataset(tested, inputFacets={
"dataQualityAssertions": result.as_facet()})],
outputs=[],
))Das sind eigene Hilfsfunktionen, keine Aufrufe in die Client-Bibliothek, und alle sind mechanisch. dataset() macht aus dem source- oder target-Block eines Deskriptors ein OpenLineage-InputDataset oder OutputDataset — ein Namespace, ein Name und alle übergebenen Facets. input_dataset() ist dasselbe, festgelegt auf die Eingabeseite — das Validierungs-Ereignis braucht das, weil der Datensatz, über den es urteilt, das Ziel dieser Pipeline ist. quarantined() leitet den Quarantäne-Ort aus dem deklarierten Ziel ab und stammt aus Teil 1, wo er im selben Zweig verwendet wird. provenance_facet() setzt die acht Felder oben zusammen. replace ist dataclasses.replace — der Validierungs-Job unterscheidet sich vom Pipeline-Job allein durch seinen Namen. Der Transport des Clients wird außerhalb des Codes konfiguriert, und genau das lässt dasselbe emit() in Produktion den Adapter aus § 2 erreichen und im Test eine Datei. Descriptor, Validation und PipelineError sind Typen aus Teil 1.
Drei Facets, drei Abschnitte dieses Beitrags, ein Lauf. Man beachte, wie wenig davon berechnet wird: Namespace und Job-Name kommen aus den Metadaten des Deskriptors, das Processing-Facet ist der processing-Block des Deskriptors unverändert serialisiert, Eingabe- und Ausgabe-Datensätze sind seine deklarierte Quelle und sein deklariertes Ziel. Nur Lauf-ID, Zeitstempel und Assertion-Ergebnis sind neue Information. Das ist der strukturelle Gewinn aus dem Rückblick — Emission ist überwiegend Weiterreichen von bereits Deklariertem.
Das match ist die einzige echte Entscheidung in der Funktion, und es steht dort, weil die drei Ausgänge tatsächlich Unterschiedliches berührt haben. Man sollte es allerdings nicht als die drei on_failure-Zweige aus Teil 1 § 3 in neuen Kleidern lesen — jene laufen nur, wenn die Suite fehlschlägt, diese hier sind terminale Zustände. QUARANTINED ist das einzige Eins-zu-eins: COMPLETE deckt einen sauberen Durchlauf und ein warn ab, während FAIL block_publish zusammen mit jedem Fehler aufnimmt, der die Suite nie erreicht hat. Eine Konsequenz gehört klar gesagt, denn hier verdient sich die Aufteilung in zwei Ereignisse selbst: Ein blockiertes Publish hat überhaupt keinen Ausgabe-Datensatz — es wurde nichts geschrieben —, ein am Ausgang hängendes Assertion-Ergebnis hätte also nirgendwo hin gekonnt. Am geprüften Datensatz hängend überlebt es, und auf ein FAIL-Ereignis, das den Fehler benennt, folgt ein OTHER-Ereignis, das die Expectation benennt, die ihn ausgelöst hat. Eine Governance-Schicht, die Fehlschläge zählt, und eine, die fragt, welche Regel gegriffen hat, werden beide über die Lauf-ID bedient, ohne dass eine von beiden in den Expectation-Speicher greifen müsste.
Das Ereignis, das ein fehlgeschlagener Lauf erzeugt. Teil 1 § 3 garantiert, dass jeder Lauf genau ein terminales Ereignis emittiert, ein Fehlschlag erzeugt also eine Payload statt einer Lücke — und es ist die detailliertere der beiden, weil sie sich erklären muss. Bricht ein Lauf an Schema-Drift ab, fängt run() an seiner Grenze ab und emit() erzeugt dies:
{
"eventType": "FAIL",
"eventTime": "2026-08-05T03:02:11.880Z",
"producer": "https://github.com/cordata-tech/pipeline-runtime/tree/2.4.1",
"schemaURL": "https://openlineage.io/spec/2-0-2/OpenLineage.json#/$defs/RunEvent",
"run": {
"runId": "a17e2b90-…",
"facets": {
"errorMessage": {
"message": "fraud_raw.transactions is at v8, descriptor pins v7. Diff: + merchant_category_code (string, nullable)",
"programmingLanguage": "python"
},
"cordata_provenance": {
"descriptor_sha256": "9c1f4b7e…",
"source_schema_version": 7,
"descriptor_git_commit": "4af3958a…"
}
}
},
"job": {"namespace": "cordata.fraud", "name": "transactions-scored-daily"},
"inputs": [], "outputs": []
}Leere inputs und outputs sind kein Versäumnis — sie sind die festgehaltene Tatsache. Es wurde nichts gelesen und nichts geschrieben, und der Lineage-Graph sollte das sagen, statt einen Teillauf nahezulegen. Das Provenance-Facet landet trotzdem, weil welche Deskriptor-Version fehlgeschlagen ist genau das ist, was die Person zum Beheben braucht, und die errorMessage trägt den spaltengenauen Diff, den SchemaDrift aus Teil 1 § 4 zusammengesetzt hat.
Ein fehlendes Ereignis ist das eigentliche Problem: Es sieht genauso aus wie eine Pipeline, die niemand eingeplant hat. Ein FAIL-Ereignis, das seinen Grund mitträgt, ist das, was den Lauf überhaupt debuggbar macht.
Wie das mit der Plugin-basierten Emission aus § 2 koexistiert. Die beiden Mechanismen wirken widersprüchlich — das openlineage-spark-Plugin wirbt damit, ohne Eingriff in den Job-Code zu emittieren, und hier emittiert Job-Code. Beide laufen, und sie leisten Unterschiedliches. Das Plugin leitet das Basisereignis aus dem Spark-Plan ab: Es sieht Eingaben, Ausgaben, spaltengenaues Schema, Zeilenzahlen — und es sieht sie genauer, als es ein handgeschriebener Aufruf je könnte. Was es kategorisch nicht wissen kann, sind die legal_basis des Deskriptors, die Assertion-Ergebnisse oder der Hash der Deklaration, die den Lauf erzeugt hat — nichts davon existiert im Spark-Plan.
Das Datenmodell von OpenLineage löst das sauber, weil Ereignisse sich unter einer runId ansammeln, statt einander zu ersetzen. Das Plugin emittiert also die mechanische Wahrheit, emit() sendet die deklarierte Wahrheit unter demselben Lauf, und das Backend führt sie zu einem Lineage-Datensatz zusammen. Die Alternative — das Plugin abschalten und alles von Hand konstruieren — ist verfügbar und für Nicht-Spark-Runtimes gelegentlich richtig, bedeutet aber, spaltengenaue Lineage-Extraktion nachzubauen, also genau die Arbeit, für die es das Plugin gibt.
Drei Formen statt zwei, sobald dbt im Stack ist: ein prozessinterner Listener, der die Ausführung mitverfolgt, während sie geschieht (Spark, Airflow), eine nachträgliche Rekonstruktion aus dem, was der Lauf hinterlassen hat (dbt-ol liest dbts Artefakte, sobald der Lauf vorbei ist), und explizite Aufrufe wie emit(), die beitragen, was nur der Deskriptor weiß. Sie fügen sich zusammen, weil alle drei auf dieselbe runId zeigen. Aber nur die erste und die dritte können einen Lauf melden, der gestorben ist — ein Nachverarbeiter braucht einen Prozess, der lange genug überlebt, um seine Artefakte zu schreiben. Das ist der praktische Grund, warum ein dbt-lastiger Stack weiterhin eine eigene Executor-Grenze will, statt bei terminalen Ereignissen dem Wrapper zu vertrauen.
§ 5 — Orchestrierung: zentraler Scheduler, dezentraler Compute
Die bisherigen Beispiele laufen mit Airflow auf MWAA, und nichts am Argument hängt davon ab. Was der Orchestrator hier zu tun hat, ist schmal: sich zu einem festen Zeitpunkt in ein Producer-Konto authentifizieren, Arbeit auslösen, aus dem Weg gehen. Step Functions, Dagster oder alles, was zu einer festen Zeit eine Rolle annehmen kann, leistet das, und nichts davon ändert, was eine Pipeline emittiert — die Wahl gehört also dem, der das Ding betreiben muss.
Man beachte, was der Orchestrator in der deskriptorgetriebenen Plattform aus Teil 1 tut und was nicht. Es gibt weiterhin eine DAG — aber sie hält keine Pipeline-Logik. Sie plant execute(descriptor_path, run_id) und kümmert sich um Wiederholungen, Backfills und Abhängigkeiten zwischen Produkten, während Quelle, Schritte, Ziel, Vertrag und Policy allesamt im Deskriptor leben. Jede DAG bekommt damit dieselbe Form, und die Datei schrumpft zu einer Schleife über das Deskriptor-Verzeichnis des Fachbereichs — die zweitgrößte Reduktion handgeschriebenen Codes nach dem Executor selbst.
Die Frage, auf die es ankommt, erzwingt die Ein-Konto-pro-Fachbereich-Topologie: Wenn jeder Fachbereich sein eigenes Konto besitzt, betreibt dann jeder Fachbereich seinen eigenen Orchestrator?
Mit ziemlicher Sicherheit nicht, und der Grund ist das Preismodell, nicht die Architektur. Dezentralisiere, was pro Nutzung abgerechnet wird; zentralisiere, was nach Kapazität abgerechnet wird. Glue wird in DPU-Sekunden abgerechnet, Transform-Jobs in acht Producer-Konten laufen zu lassen kostet also dasselbe wie in einem — Compute zu dezentralisieren ist kostenlos, und es ist zugleich zwingend, weil genau das Quell-Zugangsdaten und Rohzeilen innerhalb der Fachbereichsgrenze hält. Ein dauerhaft laufender Scheduler kehrt das um: Man bezahlt die Control Plane, ob in dieser Stunde etwas läuft oder nicht, und acht davon heißt acht zu kaufen und acht zu patchen.
Die Standardform ist deshalb ein einzelner Orchestrator in einem gemeinsamen Automatisierungskonto, der eine schmale Rolle pro Fachbereich annimmt, um Arbeit auszulösen, die innerhalb des Producer-Kontos ausgeführt wird. Der Orchestrator plant und verfolgt; er berührt nie eine Zeile. Diese Trennung erkauft zudem das, was Orchestratoren pro Fachbereich wirklich schwer machen — fachbereichsübergreifende Abhängigkeiten, bei denen das Schaden-Produkt nicht starten kann, bevor das Vertrags-Produkt gelandet ist. Mit einem Scheduler ist das eine Kante in einem Graphen; mit acht wird es ein Event-Bus zwischen Orchestratoren und eine Debugging-Fläche, die niemand wollte. Ein regulierter Ausschnitt, der sich keinen Scheduler mit dem Rest teilen darf, ist die doppelte Control Plane wert — aber als benannte Ausnahme, nicht als Muster.
Der Fehlermodus, den es zu vermeiden gilt, ist ein zentraler Orchestrator, dem Fachbereichslogik zuwächst. In dem Moment, in dem er Transformations-SQL oder fachbereichsspezifische Verzweigungen hält, ist er kein Scheduler mehr, sondern der Monolith, den der Mesh ablösen sollte — und er wird dem Plattformteam gehören, und genau so kommt der zentrale Flaschenhals durch die Hintertür zurück.
§ 6 — Was die Infrastruktur eines Fachbereichs wissen muss
Sehr wenig, und das ist der Punkt. Das Terraform, das einen Fachbereich aufstellt, braucht fast nichts über die Pipeline, weil der Deskriptor aus Teil 1 es bereits hält:
# live/prod/domains/fraud/terragrunt.hcl — transform jobs
transform_jobs = [
{
name = "transactions_scored_daily"
descriptor = "domains/fraud/pipelines/transactions_scored.yml"
openlineage_enabled = true # attaches the openlineage-spark plugin
},
]Zwei Dinge zu diesem Eintrag. Er benennt einen Deskriptor, keinen Skriptpfad plus Eingabe-Präfix plus Ausgabe-Präfix — diese drei sind in die Datei gewandert, die dem Fachbereich gehört, statt in Terraform zu sitzen, wo sie dem Plattformteam gehören würden. Und OpenLineage ist ein Schalter an der Infrastruktur, keine Änderung am Job: Das Plugin hängt sich an die Glue-Job-Definition, eine Pipeline zu instrumentieren ist also nichts, woran eine Fachbereichs-Entwicklerin denken muss.
Das Modul hinter diesen Eingaben erzeugt einen Glue-Job, weil dieser Stack das fährt — und der Eintrag ist genau deshalb so kurz, weil es eine Engine gibt. Fährt man zwei, muss etwas sagen, welche. Man beachte, wohin dieses Etwas gehört: ein engine = "emr" neben descriptor im Terraform-Eintrag, niemals ein neues Feld im Deskriptor. Der Deskriptor sagt, was eine Pipeline liest und erzeugt; die Infrastruktur sagt, was sie ausführt. Die Engine auf dieser Seite der Linie zu halten ist es, was vierzig Deskriptoren eine Migration weg von Glue überstehen lässt, ohne dass sie angefasst werden. Die Ingestion ist genauso verdrahtet, mit der Replikationsinstanz im Producer-Konto, aus dem Grund, den § 5 zum Compute nennt — eine zentrale würde die Rohzeilen jedes Fachbereichs an einem einzigen Ort konzentrieren.
Wo der Graph beginnt. OpenLineage hat Integrationen für Spark, dbt, Flink, Hive und SQL; für DMS gibt es keine. Eine Replikationsaufgabe, die Zeilen in raw/ ablegt, emittiert also kein RunEvent, und das Quellsystem ist kein Knoten im Lineage-Graphen.
Das ist eine Grenze, kein Loch, und eine vertretbare — die Verantwortung der Plattform beginnt bei der Ingestion, so wie die einer Grenzstelle an der Grenze beginnt. Was ankommt, wird erfasst, auch wenn die Reise davor es nicht wird. Die Aufgabe, die diese Zeilen abgelegt hat, ist im Terraform oben deklariert, mit Quell-Endpunkt, Ziel-Präfix und LF-Tags versioniert und PR-reviewt, „woher kam raw/transactions/“ hat also eine Antwort.
Was wirklich fehlt, ist enger: Man kann sagen, welche Aufgabe dieses Präfix befüllt, aber nicht, welcher Replikationslauf einen bestimmten Batch abgelegt hat. Provenance pro Tabelle, nicht pro Batch. Falls dieser Unterschied zählt — und für einen regulierten Ausschnitt kann er das —, ist die Lösung ein Wrapper, der ein RunEvent um die Replikationsaufgabe emittiert, also eine Ergänzung und kein Neuentwurf.
§ 7 — Die Semantikschicht über den Datenprodukten
Über den Datenprodukten, die eine Pipeline erzeugt, sitzt noch eine Ebene. Die Semantikschicht — die von dbt, oder Cube, oder Malloy — stellt typisierte Kennzahlen bereit, etwa monthly_recurring_revenue, policy_attach_rate und fraud_recall_at_30_days, mit ihren fachlichen Definitionen in Git versioniert, gestützt auf dbt-Modelle, abgefragt über eine Semantik-API. BI-Werkzeuge, Notebooks und LLM-Agenten greifen alle auf monthly_recurring_revenue zu und bekommen dieselbe Zahl, gleich definiert, gestützt auf dieselbe Lineage.
Dieser letzte Consumer ist der Grund, warum die Ebene aufgehört hat, optional zu sein. Rohes SQL gegen einen governten See ist für einen agentischen Consumer unsicher; eine typisierte Semantik-API darüber ist das, was die Frage eines Agenten ohne Halluzination beantwortbar macht.
Für die Pipeline-Schicht, die diese beiden Beiträge behandeln, ist die Semantikschicht der Consumer-Vertrag. Jede Transform-Pipeline, die einen kennzahlentragenden Datensatz erzeugt, veröffentlicht ihr Schema über DataZone; die Semantikschicht referenziert diese Datensätze und definiert Kennzahlen darauf; Consumer fragen die Semantikschicht ab, nicht die Rohtabellen. Kennzahlen werden auffindbar, governt und konsistent von Konstruktion her, statt pro BI-Werkzeug neu hergeleitet.
Der Data-Engineering-Grundpfeiler hat diese Ebene in „Wo KI in einem Fabric-+-Mesh-Hybrid hingehört“ benannt, und die Fabric-+-Mesh-auf-AWS-Referenz § 4 nannte sie „die Ebene, die DataZone nicht liefert“. Ein eigener Beitrag — Die Semantikschicht ist, wie KI Ihre Daten sicher liest — dbt Semantic Layer, Cube und der DACH-Prüfungsfall — liegt im Backlog und wird sie als eigenes Thema behandeln. Es lohnt zu trennen, welche Prüfungsfrage zu welcher Ebene gehört, denn sie werden leicht vertauscht. „Welcher Code hat diese Zahl erzeugt“ gehört der Pipeline-Schicht, und das Provenance-Facet aus § 4 beantwortet sie. Die Frage der Semantikschicht ist eine andere: ob das monthly_recurring_revenue in einer Vorstandsvorlage und das in einer aufsichtsrechtlichen Meldung dieselbe Definition sind, wer diese Definition geändert hat und wann. Die Bedeutung einer Kennzahl lebt in keiner Pipeline, und deshalb beantwortet keine Menge an Lineage sie. Für die Pipeline-Hälfte kommt es auf die Einordnung an. Pipelines erzeugen Datenprodukte; die Semantikschicht ist der Ort, an dem diese Produkte fachbereichsübergreifend konsumierbar werden, weil eine einmal definierte Kennzahl im Schaden-Bereich dasselbe bedeutet wie im Betrugs-Bereich — und das ist das Föderationsproblem, das ein Mesh erzeugt und lösen muss. Davon getrennt zu halten ist allerdings ein governtes fachliches Glossar: Das Glossar ist Sache des Katalogs, und DataZone hat dafür CreateGlossaryTerm. Der Katalog klärt, was ein Begriff bedeutet; die Semantikschicht sorgt dafür, dass jeder Consumer ihn gleich berechnet.
§ 8 — Was dieser Beitrag nicht abdeckt
Drei konkrete Lücken, die diese beiden Beiträge bewusst anderen Behandlungen überlassen:
- Streaming-Pipelines. MSK Connect + Kinesis Firehose + Flink ist eine wirklich andere Form — der Begriff der „Lauf-Grenzen“ für Lineage wird unschärfer, Expectations laufen kontinuierlich statt pro Lauf, und die Konto-Topologie muss unter dauerhaft laufenden Konnektoren tragen. Verdient einen eigenen Beitrag; die Muster oben setzen Batch und geplante Läufe voraus.
- MLOps-Pipelines. Feature Stores, Modelltrainings-Pipelines, Lineage im Modell-Serving — wieder eine andere Form, und die Einheit der Lineage ist keine Tabelle mehr. OpenLineage hat überhaupt keine ML-Integration und keine Modell- oder Feature-Store-Facets, Modell-Lineage bedeutet also, eigene zu definieren; und die Assertion-Geschichte überschneidet sich mit Modellqualitäts-Metriken, ohne dasselbe zu sein.
- Die Ereigniserfassung selbst. Der Behaviour-first-Begleitbeitrag § 1 nimmt diese Ereignisse als gegeben — er benennt die Quellen, aus denen seine Projektionen lesen, und geht dann dazu über, was sich bauen lässt, sobald sie landen. Diese beiden Beiträge liefern die Pipeline-Hälfte dieser Annahme, nicht die Hälfte der Lineage-Ereignis-Senken-Infrastruktur. Der
PostLineageEvent-Einführungspfad in § 2 deckt die Senke für AWS-native Häuser ab; für Teams, die Marquez oder eine eigene Senke betreiben, ist diese konkrete Verrohrung eine eigene Entscheidung.
Den Bogen schließen
Zwei Grundpfeiler und vier Begleitbeiträge, alle im Dienst eines einzigen Satzes aus dem Data-Governance-Grundpfeiler: die Artefakte — Richtlinien, RACIs, Verarbeitungsverzeichnisse — sind Nebenprodukte dieses Verhaltens, keine Liefergegenstände. Alles seither war ein Argument dafür, dass sich das bauen lässt statt bloß anzustreben, und jeder Beitrag trug einen Teil der Last:
- Der Data-Engineering-Grundpfeiler führte den architektonischen Fall — Data Fabric als Maschinerie, Data Mesh als Organisation, komponiert statt gegeneinander gewählt.
- Fabric + Mesh auf AWS machte es konkret: ein Konto pro Fachbereich, die LF-Tag-Ontologie, zweischichtige DataZone-Segmentierung, in Terraform, das läuft.
- Behaviour-first Governance in der Praxis baute die vier Projektionen darauf — Verarbeitungsverzeichnis aus Lineage, RACI aus Katalog-Eigentum, den Prüfbericht aus dem Subscription-Log, Änderungsautorisierung als nachweisgestützter Ablauf — und benannte die drei Ereignisquellen, aus denen sie lesen.
- Teil 1 machte die Pipeline zu einer Deklaration, damit diese Ereignisse etwas Ehrliches zu beschreiben hätten.
- Dieser Teil ließ die Deklaration ihren eigenen Nachweis emittieren.
Es lohnt, genau zu sein, was das gegenüber dem Versprochenen einlöst, denn das Versprechen war konkret. Behaviour-first Governance in der Praxis § 1 benennt drei Ereignisquellen, aus denen seine Projektionen lesen: OpenLineage-Ereignisse, das DataZone-Subscription-Log und CloudTrail-Datenereignisse. Diese beiden Beiträge liefern die erste dieser drei, und nur diese. Das Subscription-Log erzeugt DataZone; das Zugriffsprotokoll pro Abfrage gehört LakeFormation und CloudTrail. Was die Pipeline-Schicht der Governance-Schicht schuldet, ist der Lineage-Strom — und jedes Stück davon hat ein benanntes Ziel:
job.facets.processing, daspurposeundlegal_basisdirekt aus dem Deskriptor trägt, ist das, was die Verarbeitungsverzeichnis-Projektion in Behaviour-first § 2 liest. Der DSGVO-Art.-30-Eintrag wird pro Lauf erzeugt, aus einem Feld, das die Pipeline-Autorin einmal geschrieben hat.cordata_provenance, unddescriptor_git_commit_signedim Besonderen, ist das, was den Änderungsautorisierungs-Ablauf in Behaviour-first § 5 eine veröffentlichte Zahl zurück zu dem reviewten Commit verketten lässt, der den erzeugenden Code autorisiert hat.dataQualityAssertionsdehnt dasselbe Betriebsmuster auf Datenqualität aus, die die ursprünglichen vier Mechanismen nicht abdeckten: Ein Grant, der bei wiederholten Assertion-Fehlschlägen ausgesetzt wird, ist eine als Verhalten ausgedrückte Richtlinie, und er braucht ein Ergebnis pro Lauf, auf das er reagieren kann.
Etwas wird natürlich weiterhin gepflegt — Deskriptoren werden bearbeitet, Expectation Suites nachjustiert, der Executor liefert Releases aus. Was aufhört, gepflegt zu werden, ist der Nachweis. Das Verarbeitungsverzeichnis, die Provenance-Spur, die Qualitätshistorie: Die schreibt niemand, und niemand muss es, weil eine korrekt laufende Pipeline sie hinterlässt. Definiert man die Deskriptoren einmal gut, behalten die Artefakte ihren eigenen Puls — was der Satz des Grundpfeilers ist, vom anderen Ende her erreicht.
Der Code in diesem Beitrag läuft. cordata-tech/pipeline-runtime emittiert die Ereignisse von oben tatsächlich — spezifikationskonformes OpenLineage samt Provenance-Facet — in eine Datei statt an einen Adapter, sodass ein frisch geklontes Repository ohne AWS-Konto nachlesen kann, was es erzeugt hat. Beim Bauen fiel der Platzierungsfehler in § 3 auf, den dieser Beitrag hatte; er und vier weitere sind im Repository dokumentiert.
Wenn Sie eine Metadata-first-Pipeline-Schicht auf AWS-nativem Stack ausgeliefert haben — oder eine ausgeliefert und die nachgerüstete Form als nicht mehr einholbar erlebt haben —, würde mich wirklich interessieren, wo die Nähte sichtbar wurden. Die Tür steht offen unter cordata.tech/contact. Weiterführend: Teil 1 — eine Pipeline ist ein Deskriptor, kein Programm für die eingehende Hälfte; der Data-Engineering-Grundpfeiler für das zusammengesetzte Argument; die Fabric-+-Mesh-auf-AWS-Referenz für die Ein-Konto-pro-Fachbereich-Topologie, auf der diese Pipelines sitzen; und Behaviour-first Governance in der Praxis dafür, wie die Governance-Schicht die Ereignisse liest, die diese Pipelines emittieren. Zwei Folgebeiträge stehen noch an: Die Semantikschicht ist, wie KI Ihre Daten sicher liest — der Abgleich von dbt Semantic Layer, Cube und Malloy mit dem DACH-Prüfungsfall — und DSGVO Art. 17 über einen Mesh hinweg zur Löschmechanik, wenn die Daten einer Kundin in acht Konten liegen.