ACBDiDATAP.AI
Engineering note · full version · September 2026 · 3-minute version

Decouple your data from your engine.
The engineering behind it.

Technology leaders tell us the data must stay the company’s own asset, not be bound to whichever engine processes it today. That is what we mean by data sovereignty here. Open table formats freed the files from the engine; lineage, quality evidence and ownership still live inside each engine. DATAP.AI is the layer that carries that governance across engines. This note shows the build behind the claim, on our own demo environment: the same dbt-core models, one shared copy on Apache Iceberg, produced on Athena, consumed on Snowflake and on Databricks in another cloud, with column-level lineage across the engine boundary. It covers the architecture, the configuration, what we measured, and five things that broke.

The build, live

Click any column. Its provenance lights up in both directions, across every engine it passes through.

Architecture

The same models run on three engines. Each engine writes to its own catalog. The shared tables and the Snowflake marts are one set of Iceberg files in one S3 bucket, read by every engine. Databricks writes its own feature tables to its own Azure storage.

SAME dbt-core MODELS · ONE ICEBERG COPY · THREE ENGINES · TWO CLOUDSdbt-core models (one project, or a repository per team)targets: athena · snowflake · databrickstags: role_producer / role_consumer · engine_<name>Athena — PRODUCERcleans + conforms 8.5M rows once4 shared tables, MERGE modelsAWS · ap-southeast-2Snowflake — CONSUMERfinance marts on the shared tableswrites Snowflake-managed IcebergAWS · ap-southeast-2Databricks — CONSUMERfeature tables (20-day momentum)written to its own Azure storageserverless SQL warehouse · AZUREAWS Glue catalogowns the shared tables (open)Snowflake catalogowns the marts · mirrors Glue tablesUnity Catalogforeign catalog over the Snowflake databaseAmazon S3 — Apache Iceberg (Parquet + metadata)one copy of every shared table and mart · in your own bucket · no table data copied between engines in this buildcatalogintegrationcatalogfederationcross-cloud, read-onlywrite + readwrite + read

Who writes, who reads, through which catalog

This table is the core of the design. Open files were necessary but not sufficient for us; which catalog owns a table decided which engines could find it.

TablesWritten byOwned inSnowflake readsDatabricks (Azure) reads
Shared tables (prices, tickers, FX, exchanges)AthenaAWS Glue (open catalog)Direct — Iceberg table backed by a Glue catalog integration, auto-refreshThrough Snowflake — when we built this, AWS Glue was not among the documented federation sources for Azure Databricks
Finance martsSnowflakeSnowflake catalog (Snowflake-managed Iceberg)NativeDirect — Unity Catalog asks Snowflake for the current metadata file, then reads S3 itself
Feature tablesDatabricks (Azure)Unity Catalog — Delta in Azure storage, not part of the shared Iceberg copyNot consumed in this buildNative

How it is put together

Six steps. It uses dbt-core, an open table format, catalog features the engines document publicly, and our own code for stitching the lineage.

  1. 01

    One environment, three adapters

    dbt-core 1.9 with the Snowflake, Athena and Databricks adapters in a single Python environment. One profiles file; each engine is a target of one profile, and the connection definitions are reused, not copied.

  2. 02

    Folders switch on by engine — the single-project shortcut

    We put producer and consumers in one dbt project and enable model and source folders by target type, so each engine sees only its own slice. Source names must be unique project-wide, even when disabled. One project is the simplest shape for a demonstration, not the shape we would recommend for many teams; see the note at the end of this section.

  3. 03

    Producer publishes to an open catalog

    Athena builds the shared tables as Iceberg into Glue, as merge models with a unique storage location per table.

  4. 04

    Consumers attach without copying

    Snowflake: a Glue catalog integration plus the existing external volume. Databricks on Azure: a read-only cross-cloud storage credential, an external location, and one foreign catalog over the Snowflake database.

  5. 05

    Lineage per engine, then stitched

    Each target writes its own manifest and catalog. SQL is parsed per dialect; graphs are joined where a consumer source and a producer model declare the same physical identity.

  6. 06

    Persist and serve

    Edges and tables go to plain system tables with the engine on both sides of every edge, and to one JSON file that the lineage page reads.

The project file: folders switch on by engine

dbt_project.yml
models:
  stock_xengine:
    +tags: ["cross_engine_producer_consumer", "cross_engine"]
    producer_athena:
      +enabled: "{{ target.type == 'athena' }}"
      +tags: ["role_producer", "engine_athena"]
      +table_type: iceberg
    consumer_snowflake:
      +enabled: "{{ target.type == 'snowflake' }}"
      +tags: ["role_consumer", "engine_snowflake"]
      +table_format: iceberg
    consumer_databricks:
      +enabled: "{{ target.type == 'databricks' }}"
      +tags: ["role_consumer", "engine_databricks"]

sources:            # switch sources per engine too, or each engine tries to catalogue the others'
  stock_xengine:
    producer_athena:     { +enabled: "{{ target.type == 'athena' }}" }
    consumer_snowflake:  { +enabled: "{{ target.type == 'snowflake' }}" }
    consumer_databricks: { +enabled: "{{ target.type == 'databricks' }}" }

Shared tables are merge models, on purpose

In our setup a rebuilt table got a new Iceberg identity on every run, while a merged table kept one and added snapshots. The consuming engine could follow the second, not the first.

models/producer_athena/common_price_daily.sql (head)
{{ config(
    materialized         = 'incremental',
    incremental_strategy = 'merge',
    unique_key           = ['ticker', 'exchange', 'trade_date'],
    on_schema_change     = 'append_new_columns'
) }}
-- every run re-merges the last 7 days; one table identity, new snapshots

Where the lineage graphs join

dbt gives one manifest per target, so each engine’s graph ends at a source. The consumer declares which physical table that source is; the producer’s model resolves to the same identity.

models/consumer_databricks/_sources.yml
sources:
  - name: stock_common_dbx              # produced by Athena; Databricks is the second consumer
    database: "{{ var('dbx_bridge_catalog') }}"
    schema: stock_common
    tables:
      - name: common_price_daily
        meta: { physical_identity: "glue:stock_common.common_price_daily" }
  - name: stock_consumer_sf             # produced by Snowflake, read from the same Iceberg files
    schema: stock_consumer
    tables:
      - name: mart_price_monthly_usd
        meta: { physical_identity: "snowflake:datapai.stock_consumer.mart_price_monthly_usd" }

Running it

one command per engine
dbt build --target athena     --select tag:role_producer,tag:engine_athena
dbt build --target snowflake  --select tag:role_consumer,tag:engine_snowflake
dbt build --target databricks --select tag:role_consumer,tag:engine_databricks

# artefacts per engine, then one stitched graph -> JSON for the page + system tables
dbt docs generate --target <engine> --target-path target_<engine>
xengine lineage --project . --to-db

One project or many

This build uses one dbt project for all three engines. That made one point easy to check, the same model code ran everywhere with nothing rewritten, but it is not how we would lay out an estate with many teams: one release unit for everyone, one credentials file for every engine, and project boundaries that follow engines rather than ownership. So we built the other shape too: the same models split into three repositories, one per team, with no folder switching, stitched through a small estate file. It produced the same lineage, 12 tables, 75 column edges, 46 crossing an engine boundary, and the same row counts on every table. The join is on physical identity, not on project membership. That build is its own note →

The auditor’s question, in SQL

Which numbers left one engine for another? Every edge stores the engine on both sides.

lineage system tables
select from_engine || ' -> ' || to_engine as crossing, count(*) as column_edges
from   v_sys_ai_column_lineage_latest
where  pipeline_id = 'stock_xengine'
group  by 1 order by 1;

 athena    -> athena        29
 athena    -> databricks    16
 athena    -> snowflake     24
 snowflake -> databricks     6

The part we had to build

Everything above is configuration of open tools and vendor features. It gets you three engines over one copy, and three separate lineage graphs. Four pieces of our own code turn that into one graph with evidence in your database. Excerpts from the working repository, lightly trimmed.

1 · Stitching the per-engine graphs

dbt produces one manifest per target. Each engine’s graph therefore ends at a source, and no engine knows that its source is another engine’s model. The stitcher walks the engines in producer-to-consumer order, parses every model’s compiled SQL in that engine’s dialect for column edges, and when a source declares a physical identity that an earlier engine’s model already claimed, it aliases the source onto that model. A model is marked built only if the engine’s own catalog lists its relation with columns; otherwise the lineage is of code, not of a table.

lineage/xengine/stitch.py
for e in engines:                                   # producer -> consumers
    man = manifest(project_dir, e["target_dir"])
    cat = catalog(project_dir, e["target_dir"])["nodes"]
    lin = lineage_from_artifacts(project_dir, dialect=e["dialect"], target_dir=e["target_dir"])

    source_alias = {}                              # "source:<name>" in THIS engine -> stitched node
    for s in man["sources"].values():
        ident = s["meta"].get("physical_identity")  # e.g. glue:stock_common.common_price_daily
        if ident in identity_to_node:               # an upstream engine PRODUCED this table
            source_alias[f"source:{s['name']}"] = identity_to_node[ident]
        else:
            nodes[f"source:{s['name']}"] = {"engine": eng, "role": "source", "identity": ident}

    for n in models(man):
        ident = model_identity(n, eng)              # glue:<db>.<t> | snowflake:<db>.<schema>.<t>
        nodes[n["name"]] = {"engine": eng, "identity": ident,
                            "built": bool(cat.get(n["unique_id"], {}).get("columns"))}
        identity_to_node[ident] = n["name"]

    for ed in lin["edges"]:                         # column -> column, from the parsed SQL
        frm = source_alias.get(ed["from_node"], ed["from_node"])
        edges.append({**ed, "from_node": frm, "to_engine": eng,
                      "from_engine": nodes[frm]["engine"]})

crossings = [e for e in edges if e["from_engine"] != e["to_engine"]]

The result on this project: 12 tables, 75 column edges, 46 of them crossing an engine boundary, 0 unresolved, 0 not built. Those four numbers are printed by the tool and are the ones we would want a reader to check first.

2 · Keeping the catalogs in step

Open files do not announce themselves. An engine finds a table through its catalog, and each engine has its own. Two small tools carry the current Iceberg metadata pointer across. Snowflake to Glue asks Snowflake where the table’s metadata file is and registers a Glue table pointing at it. Glue to Snowflake creates a read-only Iceberg table in Snowflake that follows the Glue entry, so a producer rewrite is picked up without a re-sync.

lineage/xengine/glue_sync.py — Snowflake-managed table -> Glue pointer
cur.execute(f"show iceberg tables in schema {database}.{schema}")
for t in cur.fetchall():
    cur.execute(f"select system$get_iceberg_table_information('{database}.{schema}.{t.name}')")
    meta = json.loads(cur.fetchone()[0])["metadataLocation"]   # s3://.../metadata/00012-....metadata.json
    glue.update_table(DatabaseName=glue_db, TableInput={
        "Name": t.name.lower(), "TableType": "EXTERNAL_TABLE",
        "Parameters": {"table_type": "ICEBERG", "metadata_location": meta, "written_by": "snowflake"},
        "StorageDescriptor": {"Columns": columns_from_iceberg_metadata(meta), "Location": meta.rsplit("/metadata/", 1)[0]}})
lineage/xengine/snowflake_sync.py — Glue-owned table -> Snowflake, follows Glue
# catalog_namespace = the table's own Glue database, so ONE integration serves every database.
# CREATE OR REPLACE swaps a read-only pointer; the producer owns the files, nothing is deleted.
cur.execute(f"""
  create or replace iceberg table {db}.{schema}.{name}
    external_volume  = '{external_volume}'
    catalog          = '{glue_catalog_integration}'
    catalog_namespace  = '{glue_database}'
    catalog_table_name = '{name}'
    auto_refresh = true
""")

3 · Evidence as tables in your own database

The stitched graph is not kept in a tool. Every edge is written with the engine on both sides and the engine-independent identity it was joined on; every table with whether it was built. The migration is additive: single-engine lineage already in the tables keeps working with NULLs in the new columns.

migrations/2026-09-21_sys_ai_column_lineage_cross_engine.sql
alter table datapai.sys_ai_column_lineage
    add column if not exists from_engine   text,   -- engine that BUILDS from_node
    add column if not exists to_engine     text,
    add column if not exists from_identity text,   -- glue:stock_common.common_price_daily
    add column if not exists to_identity   text;   -- snowflake:datapai.stock_consumer.mart_x

create table if not exists datapai.sys_ai_lineage_node (
    pipeline_id text not null, run_id text not null, run_at timestamptz not null default now(),
    node text not null, engine text not null, role text not null,      -- source | producer | consumer
    identity text, relation text,
    built boolean,          -- true: relation exists in the engine catalog; false: lineage from code only
    unique (pipeline_id, run_id, node));

create or replace view datapai.v_sys_ai_column_lineage_cross_engine as
select pipeline_id, from_engine, from_node, from_column, to_engine, to_node, to_column, from_identity
from   datapai.v_sys_ai_column_lineage_latest
where  from_engine <> to_engine;               -- the auditor's question, as a view

4 · The connection kits

Connecting three engines across two clouds took an IAM role with two trust relationships, a Glue-read policy that needed one permission the documentation did not make obvious, a Snowflake catalog integration, a Snowflake reader role with a key pair, a Unity Catalog storage credential, external location, connection and foreign catalog. Each of these was done by hand once, then turned into a template that renders the exact statements for a given environment, plus a script that captures the current definition so it can be rolled back. That is the difference between a demo that worked once and a step that can be repeated in someone else’s estate.

infra/xengine/ — what is rendered per environment
snowflake_glue_integration/
  render.py --from-snowflake        # reads the integration's IAM user/external-id, renders trust + read policies
  params.datapai.yml                # account, role name, glue databases, region
databricks_azure_reads_snowflake_iceberg/
  1_s3_read_policy.json.tmpl         2_trust_policy.json.tmpl
  3_snowflake_reader_role.sql.tmpl   4_databricks_federation.sql.tmpl
  capture_foreign_catalog.py        # DESCRIBE -> re-creatable DDL, incl. storage_root, before any drop
  rollback/                         # what the capture produced for our own environment

In one table

PartWhat does itWhose
One shared copy of the tablesApache Iceberg on S3Open standard
The same models on three enginesdbt-core with per-engine adapters; in this build one project with folders switched on by targetOpen source; the project layout is ours
Engines finding each other’s tablesGlue catalog integration; Unity Catalog federationVendor features, documented publicly
Keeping the catalogs in stepglue_sync, snowflake_sync (pointer and catalog-integration modes)Ours
Lineage inside one enginedbt manifest and per-dialect SQL parsing; column resolution against the engine catalogOpen-source parser; the resolver is ours
Lineage across the engine boundaryJoin of per-engine graphs on a declared physical identity; built flag on every tableOurs
Where the evidence livesTables and views in the company’s own databaseOurs
Connecting the engines safelyTemplates that render the IAM, Snowflake and Unity Catalog statements; capture-before-dropOurs

The layer owns no compute and no storage. It sits beside the engines, which is why it can follow data across all of them, and why it can be switched off without moving any data: the Iceberg files, the catalogs and the evidence tables all remain where they are. The same code base generates the dbt models, their tests and a personal-data map from a source schema on a single engine today (Data Governance); carrying the personal-data flags across the engine boundary is the next item on the roadmap below.

What we measured

Same data, three engines. These are correctness checks from our demo environment on 22 September 2026, using sample market data. They are not a benchmark and not a comparison of engines; we deliberately publish no timings.

CheckResult
Row counts of the shared price table8,520,211 on Athena, on Snowflake and on Databricks
One sample price record, read from all three enginesidentical to the last stored digit: native value, conversion rate and converted value
20-day return and volume ratio computed on DatabricksEqual to an independent window calculation in Snowflake, to six decimals
A consumer picking up a new producer snapshot without a manual stepobserved within the 60-second catalog polling interval we configured
Column lineage12 tables · 75 edges · 46 cross-engine · 0 unresolved

Five things that broke

The useful part. These are things we observed in our own build. They are notes on our configuration, not statements about how any product behaves in general.

01

A full rebuild can quietly break a downstream engine

What we saw.
The consumer kept answering — with yesterday's data, and no error.
Why.
In our setup, a dbt table model was rebuilt by drop and create, which gave the Iceberg table a new identity and a new folder on every run. The consuming engine was following the table by identity, so its automatic refresh stopped and it kept serving the previous snapshot.
What we do now.
Shared tables are merge models. One table identity, new snapshots. The consumer then picked up a producer run by itself in under a minute, and Iceberg history survives.
02

Open files are not enough. The catalog entry has to be open too

What we saw.
Tables written by one engine were invisible to the others, although the files were plain Iceberg in our own bucket.
Why.
The files were open; the list of tables lived in the writing engine's private catalog.
What we do now.
Tables that many engines consume are owned in an open catalog. Tables an engine produces for its own consumers can stay in its catalog and still be read by others — through that catalog, not around it.
03

Two mirrors of one database collide

What we saw.
LOCATION_OVERLAP: a second catalog over the same source could list tables but not read them.
Why.
Each table's storage path can belong to one catalog entry. Two mirrors of the same database both claim the same paths.
What we do now.
One mirror per source database. We saved the definition of the first as a script before dropping it, so going back is one statement.
04

A green build is not a correct table

What we saw.
A model built successfully, passed its tests — and held zero rows. Earlier, a price column had silently been a copy of another, and a volume column was zero on eight million rows.
Why.
Not-null and uniqueness tests check shape. They pass on an empty table, and on a column filled with the wrong number.
What we do now.
Tests that check meaning: a table must not be empty; a converted price must differ from the native one; a volume must not be all zero. And after every first build, look at the data before saying it works.
05

File statistics written by one engine tripped up another

What we saw.
Reading a column worked; filtering on it failed with a corrupted-statistics error.
Why.
In that one file the column was stored as a 32-bit number while the table schema said 64-bit. Reading was fine; filtering that relied on min/max statistics was not. This is our hypothesis, not a confirmed cause.
What we do now.
Isolated in six queries and worked around in the one model that needed it. We did not establish the root cause. Our takeaway: test each pair of writer and reader rather than assume.

Strategy and roadmap

The build is one step in a direction: take back, layer by layer, what has become bound to a vendor. Three horizons.

Horizon 1

Decouple the files

Done in this build

Shared tables stored once in an open format, in storage the company owns. Any engine can read them; none of them owns them.

Horizon 2

Decouple the governance

Shown in this build for lineage; in progress for the rest

Column-level lineage, quality evidence and ownership held outside every engine, in the company’s own tables. Next: carry personal-data classification across the engine boundary, and publish lineage in the open standard that catalogs already read.

Horizon 3

Decouple the AI

Next

Models the company chooses, running in its own environment, answering only from data whose lineage and classification it holds. The same independence, one layer up.

How a regulated enterprise could adopt it, without a big-bang

A path, not a promise. Each step stands on its own and can be stopped after; none requires committing to the next.

  1. 01

    Start with evidence, not migration

    Point the lineage at one existing domain, read-only. Nothing moves. The first deliverable is an answer to “where did this number come from” that the business did not have before.

  2. 02

    Put one shared domain on an open format

    Choose a domain several teams consume. Publish it once, as merge tables owned in an open catalog. Existing consumers keep working; new ones attach without a copy.

  3. 03

    Prove an exit on one workload

    Build the same model on a second engine over the same files and compare the results. That comparison is exit-readiness evidence a risk committee can read — before anyone decides to move anything.

  4. 04

    Place workloads deliberately

    With lineage and tests following the model, decide per workload where it should run, and revisit the decision when prices or needs change.

  5. 05

    Extend the same control to AI

    Bring models inside the same boundary, gated by the classification the pipelines already produce.

What this does not claim

  • “Data sovereignty” here means ownership and independence. Your data in your storage, in an open format, with governance in your own tables, so engines are replaceable. The build runs on public cloud services; legal jurisdiction, encryption-key control and who operates the platform are separate questions it does not address.
  • This is a demonstration, not a production reference. One project, sample data, small compute, our own accounts. It shows that the approach works end to end; it does not show how it behaves at your scale.
  • Not every engine reads every file directly. Databricks in Azure reads the Snowflake-produced tables straight from the Iceberg files. It reads the Athena-produced tables through Snowflake, because when we built this (September 2026) AWS Glue was not among the documented catalog federation sources for Azure Databricks. The results are identical; the route is not. Vendor capabilities change, so check current documentation.
  • Not every table is Iceberg. The shared tables and the Snowflake marts are. The two feature tables Databricks builds are its default format in its own Azure storage; making those Iceberg too is a configuration step we have not done.
  • Cross-cloud reads cost money and time. It proves the data is not trapped. It is not how you would run a heavy daily workload.
  • Storage and catalog were S3 and Glue. The same design applies with Azure Data Lake Storage or Google Cloud Storage and an Iceberg REST, Unity or Polaris catalog; we have not run that yet, and would treat it as its own note.
  • Lineage covers what runs through the project. A notebook or a job outside it is invisible until it is brought in.
  • Coexistence is a bonus, never a requirement. If you have one engine and are happy, keep it. It may matter if you already run two, are considering a second, or want evidence that moving a workload is technically possible.

Engines increasingly share open tables.
Governance has to travel with them.

This is an engineering note, not a product page. If you are working on the same problem, or see a flaw in the approach, I would like to hear it. Every estate differs; nothing here is a promise about yours.

Apache Iceberg is a trademark of The Apache Software Foundation. dbt is a trademark of dbt Labs. Amazon Athena, AWS Glue and Amazon S3 are trademarks of Amazon.com, Inc. or its affiliates. Snowflake is a trademark of Snowflake Inc. Databricks and Unity Catalog are trademarks of Databricks, Inc. Microsoft Azure is a trademark of Microsoft. DATAP.AI is not affiliated with, endorsed by or sponsored by any of them. Product behaviour described here is what we observed in our own configuration in September 2026 and may differ in yours or change over time.