# Windmill as a dbt runtime Implementation spec for running an existing dbt project on Windmill with no changes to the project itself. Companion to [`pipelines-vs-dbt.md`](./pipelines-vs-dbt.md), which covers the opposite direction (native pipeline features that replace dbt). The two are complementary: this is the adoption ramp, that is the long game. Benchmark to beat is Airflow + [astronomer-cosmos](https://astronomer.github.io/astronomer-cosmos/), the dominant way dbt is orchestrated today. ## Scope - **In**: run an unmodified dbt project synced into Windmill, one Windmill job per invocation, live per-model observability, dbt models as first-class assets in the existing asset graph. - **Out**: one Windmill job per dbt model, `state:modified` / slim CI, `dbt docs` hosting, semantic layer, dbt platform integration. - **CE**: the runtime, the manifest ingest, the asset graph and every piece of UI ship in CE, as do all adapters except two. Only the `mssql` and `oracle` adapters are EE, mirroring the native `ScriptLang` boundary (decision 21). ## Decision log | # | Decision | Resolution | |---|---|---| | 1 | dbt engine | Three-way toggle (`dbt-core-1x` \| `dbt-core-2x` \| `fusion`); shipped default `dbt-core-1x`, instance-configurable. See below | | 2 | Artifact shape | `ScriptLang::Dbt` | | 3 | Graph in v0 | Yes, both runtime and graph | | 4 | Execution granularity | One job per invocation | | 5 | Project storage | The project is the script's module bundle; nothing is cloned. See "Where the dbt project lives" | | 6 | Multiple run configs | Per-run `select` on one script; N scripts means N projects | | 7 | Run-time `select` | Descriptor default plus run-arg override | | 8 | Credentials | Workspace warehouses, plus `profiles.yml` passthrough. A descriptor never names a resource. See below | | 9 | Adapter mappings | postgres, redshift, mysql, snowflake, bigquery, databricks translate from their Windmill resource; **every** adapter dbt has is reachable from a `dbt_profile` resource, or the project's own `profiles.yml` | | 10 | Private repo auth | Not applicable: the project is synced, not fetched | | 11 | Asset kind | `dbt:////` — keyed on the relation, not on dbt's node id. See below | | 12 | Graph refresh | Deploy-time, re-ingested per run only when the descriptor is dynamic, plus an explicit `parse` of the editor's buffer. See below | | 13 | Manifest storage | Sidecar table for nodes/edges. Full manifest **not** stored — see below | | 14 | Metadata depth | Tests, strategy, tags, freshness, column descriptions. Column **lineage** is not in the manifest — see below | | 15 | Node rendering | Asset nodes per model plus one runnable node for the script | | 16 | Progress | Live, from the JSON event stream | | 17 | Test failures | Honor dbt's own `severity` | | 18 | Retry | Automatic node-level retry in-job, plus `dbt retry` as a run argument. See below | | 19 | Caching | Worker-local global cache, keyed by the project digest and the resolution the deploy pinned | | 20 | Images | Full images only | | 21 | Licensing | CE except the `mssql` / `oracle` adapters. See below | | 22 | Naming | Match Cosmos field names; importer deferred | | 23 | Descriptor | `wm_dbt.yaml` inside the project, OPTIONAL. See below | | 24 | Warehouse | Configured on the workspace by name, `main` by default. See below | ## Decision 1: engine toggle, and why the shipped default is not Fusion yet `engine: dbt-core-1x | dbt-core-2x | fusion` in the descriptor. Omitted, it is `dbt-core-1x`, which runs today's projects untouched. No engine is baked into any image. Each is fetched or built on first use and cached, for a different reason in each case. | Engine | Distribution | Cold start | License | |---|---|---|---| | `dbt-core-1x` (default) | A uv venv resolved per adapter on first use, then cached. **Cannot** be baked: the adapter is a Python package chosen per project | One venv build per (core range, adapter) | Apache 2.0 | | `dbt-core-2x` | One adapter-agnostic Rust binary, fetched from GitHub releases on first use, cached | One download | Apache 2.0 | | `fusion` | **Never bundled.** Fetched from dbt Labs on first use, cached | One download (~290MB) | dbt Fusion engine license agreement | 2.x is the one that *could* be baked, and deliberately is not: it is a pre-release (`2.0.0-alpha.5`) that nothing is defaulted onto, so baking it costs a layer in every image and a version pinned in two places with nothing keeping them in step. An operator who wants an engine pre-staged — an air-gapped instance, or a fleet that should not fetch per worker — populates `DBT_BUNDLED_DIR` (default `/usr/local/dbt`) with `core2x-/dbt-sa-cli` in a derived image; the worker prefers it over its own cache. Two things to know before choosing 2.x: it is a pre-release, and it does not emit the per-node events the run page animates (see "Live per-model progress"), so a run on it reports its models only at the end. The 1.x venv resolves `dbt-core>=1.8,<2.0.0` *together with* the adapter rather than pinning a core version, because several adapters cap below the newest core (dbt-oracle and dbt-databricks below 1.12) and an independent pin makes those projects unprovisionable. The lockfile records whichever version the resolver actually chose. Both bounds and each engine version are env-overridable (`DBT_CORE_1X_FLOOR`, `DBT_CORE_1X_CEILING`, `DBT_CORE_2X_VERSION`). Fusion is the fastest option and the toggle exists so users can choose it. Two things block making it the *shipped* default, both verifiable rather than matters of taste: 1. **Redistribution terms.** The Fusion license grants only a "limited, non-exclusive, non-transferable, non-sublicensable" redistribution right, and 4.1 forbids introducing "obstacles or delays that have the effect of hampering or interfering with (a) communication between Provider and End User, (b) User's ability to view, access, or use the Product and/or any Account Features." A sandboxed non-interactive job runner sits squarely in that clause's path, and "may not share, pool, or relay its own login credentials to any End User" reads directly onto putting one dbt platform token in a workspace secret. That needs counsel, not an engineering judgment. **Fetch-at-runtime is the mitigation**: the user's own instance pulls the binary from dbt Labs directly, so Windmill never redistributes and never interposes. Do not bake Fusion into any image. 2. **Fusion is v2 semantics, and v2 drops all deprecated functionality.** Every deprecation warning, including historic ones and those added in 1.10, must be resolved before a project runs on it. An arbitrary existing dbt 1.x project therefore may not run unchanged, which is this feature's entire premise. dbt ships an autofix tool and Fusion/Core interoperate side by side, so it is a migration users can do, but not one Windmill should silently require of them. Consequence: ship with `dbt-core-1x`, which runs today's projects untouched, and flip the instance default to `fusion` once counsel clears the runtime-fetch model and a real project is verified end to end on it. Both dbt-core engines are exercised by the e2e suite, so the flip is a config change, not a port. ## Decision 21: mirror the native warehouse boundary, do not invent one Everything structural is CE: the executor, all three engines, the manifest ingest, the `dbt://` asset graph, live progress, the editor. The only gate is on two adapters, and it is not a dbt-specific policy — it is the same boundary the native script languages already draw. Since `bigquery` and `snowflake` became CE, the only warehouse `ScriptLang`s still behind a license are `mssql` and `oracledb`, so those two dbt adapters are EE and every other one (postgres, mysql, duckdb, snowflake, bigquery, databricks, redshift, clickhouse, salesforce) is CE. Gating any of the others would make reaching a warehouse through dbt stricter than reaching it natively, which is backwards. Those two are *recognized* (for the gate and for the pip package the 1.x engine's venv needs), but no Windmill connection resource translates into them: an `oracledb` resource is `{user, password, database}` with no host/protocol/service, and dbt-sqlserver needs an ODBC `driver` the images do not install. They reach their warehouse through a `dbt_profile` resource or the project's own `profiles.yml`, which is also how duckdb, clickhouse and salesforce work. Recognition is what the gate keys on, and it survives the open adapter set: a `dbt_profile` stating `sqlserver`, `mssql` or `oracle` resolves to the same `KnownAdapter` a resource type would, so it is gated identically. An adapter Windmill has never heard of is never enterprise — the boundary mirrors the two native warehouse languages, and an adapter with no Windmill runtime behind it is not one of them. The gate almost never fires in practice: `dbt-core-2x` supports neither adapter, so it can only apply to `dbt-core-1x` with one of those two. **The mechanism differs from the native languages.** They gate at compile time, so a CE binary simply lacks the executor. That is not available here: there is one dbt executor and the adapter is only known once the profile resolves. So it is a runtime check on the resolved adapter, at both deploy and run, and it must say what is wrong — a silent degradation that surfaces later as a connection error is worse than no gate at all. One trap: `ee_oss::LICENSE_KEY_VALID` is initialized to `true` in the OSS variant, so reading it alone passes on a CE build. The check is `cfg!(feature = "enterprise") && LICENSE_KEY_VALID`, which rejects both a CE build and an enterprise build whose key did not verify. ## Decision 11: `dbt://`, keyed on the relation and not on the dbt node `dbt:////`, one `AssetKind`, where `` is the workspace warehouse's NAME, so two scripts running against the same warehouse agree on identity. The SCHEME names the producer, because dbt is the only thing that creates one of these: no other language derives warehouse relations, `// materialize` takes DuckLake targets only, and a dbt run does not dispatch. Calling the kind something generic promised a parity with native Snowflake and BigQuery scripts that does not exist. The PATH is the physical relation, and that is the load-bearing half. dbt-core has no cross-project `ref()`: two projects meet when one materializes a mart and the next declares it a `source`. Their dbt identities differ there — `model.a_pkg.orders` against `source.b_pkg.analytics.orders` — while the relation does not, so keying on `unique_id` would make every project an island and turn the handoff into two unconnected nodes. `unique_id` also embeds the package name from `dbt_project.yml`, which two unrelated projects may both call `analytics`, collapsing two different tables onto one node. The relation cannot collide that way. It is also what a DuckDB, Python, TS or Ansible script can name in a `// on dbt://…` annotation to join the lineage — those four are the languages with a body-asset parser; the native SQL ones cannot declare assets at all. A dbt run does **not** trigger those readers. See "no cascade from dbt" below. An ephemeral model (an inlined CTE, never written), an exposure, or a source that is not separately modelled has no physical relation and therefore no place in this namespace. If those ever prove worth rendering they need a key of their own — `unique_id` suits them, precisely because nothing else can refer to them. Two traps, both of which quietly defeat the point if handled wrong. **Identifier canonicalization.** `manifest.json` gives `relation_name` pre-quoted (`"windmill"."Analytics"."Orders"`), an annotation is written by hand, and the warehouses disagree on case: Snowflake folds unquoted identifiers up, Postgres folds them down, DuckDB compares case-insensitively. Two spellings of one table produce two nodes, no edge, and nothing looks broken in isolation. So one rule is applied in exactly one place — `parse_asset_syntax`, the single point where an asset URI becomes a graph key: strip the quote characters (`"`, backtick, `[`/`]`) from the schema and name, then ASCII-lowercase them, matching the case-insensitive identifier comparison the DuckDB paths already use. The warehouse-name prefix is spelled as the workspace configures it and stays case-sensitive. **Warehouse identity is the workspace warehouse's name**, exactly as `ducklake://main.orders` keys on the workspace lake's name — never the host, account or database. A descriptor cannot name a resource at all (Decision 24), so there is exactly one spelling per warehouse and the ambiguity a per-project resource would create does not arise. The warehouse names the default database too, so it stays out of the key; a model that *overrides* its database (Snowflake `database`, BigQuery `project`) is genuinely elsewhere and qualifies its schema segment as `.`, so two same-named relations in different databases cannot collapse onto one node. A project that brings its own `profiles.yml` reports its target's database from that file, read with the same keys the renderer writes, so it spells a relation exactly as a workspace-warehouse project does and the two meet on one node. Only where the target leaves its database implicit does every relation qualify, because assuming they share one database is exactly what would collapse them. Three call sites derive this key: the manifest ingest that creates the node, and the live-progress and end-of-run paths that record status against it. They share one function, because a site that derives it differently records progress against a path no node has — the run still succeeds and the graph simply never moves. The same warehouse is reachable under several hostnames, and credential material has no business in an asset key. Accepted limitation, worth knowing before it is filed as a bug: **two workspace warehouses pointing at the same physical warehouse do not unify**, so assets under one will not share edges with assets under the other. Point both projects at one warehouse to link them. ## Decision 24: the warehouse is a workspace setting, named, and the only one A descriptor names a warehouse by NAME (`profile.warehouse`, `main` when it names none) and cannot name a resource. Admins configure the warehouses under Settings → dbt, where each entry points at a resource, exactly as `large_file_storage` points at the object-storage resource and a DuckLake names its catalog. **What a warehouse may point at.** Either a Windmill connection resource whose type `render_profile` translates (`postgresql`, `redshift`, `mysql`, `snowflake`, `snowflake_oauth`, `bigquery`, `gcp_service_account`, `databricks`), or a **`dbt_profile`** resource, whose VALUE IS one entry of that file's `outputs` map — `type` included, nothing lifted out or renamed. A block is copied from a working `profiles.yml` and pasted in, which is the whole point: a type that asked the user to restructure their block first would be doing the translation this exists to avoid. Its schema declares no properties, so the resource form renders one JSON editor over the value (`ResourceForm.svelte`). The picker is constrained to exactly these (`WAREHOUSE_RESOURCE_TYPES`); anything else has no way to become a target at all, which is why an unconstrained picker was a trap: it offered slack and github resources for a field that can only be a warehouse. The two exist for different reasons. A Windmill resource is the ergonomic path and is shared with everything else that connects to that warehouse, but it is *not* a dbt target: each adapter arm translates the fields Windmill's resource happens to carry into the keys dbt reads, so only what an arm covers can be expressed, and an adapter with no arm cannot be reached from one at all. `dbt_profile` inverts that — nothing is translated, so any adapter and any key it documents works. Which of the two a value is cannot be read off the value: both are objects with a `type`, and Windmill's bigquery resource is a service-account JSON that says `type: service_account`. So the warehouse carries its resource's TYPE (`DbtWarehouseConnection.resource_type`), and that is also what finally makes decision 9's "the resource type name is the authority" true at runtime rather than aspirational — the translated path resolved its adapter by sniffing connection fields until it had the name. **`dbt_profile` is open, deliberately.** Its `type` is not checked against a list: `DbtAdapter` carries an optional `KnownAdapter` beside the name, so the eleven adapters Windmill has facts about (a field mapping, a pip package, the license gate) keep them, and every other adapter dbt has — `trino`, `athena`, `spark`, whatever ships next — is carried by name and rendered, licensed and identified without Windmill knowing anything about it. A closed list would have made "whatever dbt supports" mean "whatever this enum lists", and each new adapter a Windmill release. The name is constrained to `[a-z0-9_-]` starting alphanumeric *because* it is open: it reaches a pip requirement and a venv path on the host, where a leading `-` is a flag and a `/` is a path segment. **Installing one is a separate question from using one.** `dbt-core-1x` fetches `dbt-` from PyPI, `dbt-` is not a reserved prefix there, and that install runs through `run_tool` — outside the nsjail ordinary Python dependency installation uses, with uv executing a source distribution's PEP 517 backend. An unbounded name would therefore let a script author publish `dbt-` and run code as the worker, on the one dependency path that is not sandboxed. So `ensure_adapter_installable` gates that install on `PUBLISHED_ADAPTERS` plus whatever an operator lists in `DBT_EXTRA_ADAPTERS`: the author chooses which adapter to use, the admin decides which packages this instance trusts. Nothing else is gated — a profile still renders for any adapter, and `dbt-core-2x` and `fusion` carry their adapters in the binary, install nothing, and take any `type` at all. Two keys are not passed through: `type` (Windmill writes the adapter's own dbt spelling) and `root_certificate_pem`, which is a PEM body rather than the path dbt hands the driver — it is written beside `profiles.yml` and pointed at by `sslrootcert`, as it is for a translated postgres resource. `profile.schema` and `threads` from the descriptor override their block keys rather than joining them. Three things follow, and they are the reason for the rule rather than consequences to work around. **A dbt project carries no connection.** The same project runs locally against a developer's own `~/.dbt/profiles.yml` and on Windmill against the workspace warehouse, with no Windmill-specific file in between and nothing to strip before committing it to a repository. This is what makes Decision 23 possible at all: if a project had to name its own resource, the descriptor could never be optional. **Asset identity has exactly one spelling.** Keying on a name is only sound because a name is all there is. Had both `profile.resource` and `profile.warehouse` existed, one physical warehouse would be reachable under two spellings and two projects on it would silently fail to share nodes — the exact failure Decision 11 exists to prevent. **dbt is unpermissioned, and the blast radius is bounded by construction instead.** The warehouse resource is read with NO permission check on the runner, exactly as `s3://` reaches the workspace bucket without the caller being granted the storage resource: configuring a warehouse is what makes it available, and anyone who may run a dbt script may build with it and read its models. What is reachable stays bounded because only an admin writes the setting and a descriptor cannot name a resource, only one of the names an admin configured. Per-relation rules were considered and rejected. `s3://` can enforce a path glob because Windmill mediates every object operation through its proxy; dbt has no such chokepoint — Windmill renders `profiles.yml` and dbt opens its own connection. A rule could only be a pre-run check against the manifest, and a `pre-hook`, a macro or `dbt run-operation` issues arbitrary SQL on the same connection, so it would stop the ordinary case while implying a guarantee it cannot keep. A project that brings its own `profiles.yml` still connects with it, and then names a warehouse only to say where its assets belong. The name must still match a configured warehouse — a typo is not identity, it strands the project's models on a node nothing else reaches — but it grants nothing, since nothing here is granted. It gets no identity by default, because defaulting to `main` would key a self-hosted profile's tables onto a workspace warehouse it never connected to. That label is worth having only because such a project spells its relations the same way: Windmill reads the target's database out of the project's own file (Decision 11), so a mart it builds and a workspace-warehouse project's `source` on the same relation land on ONE node. Without that the label would name a namespace and still share nothing, which is the failure it exists to prevent. An agent worker cannot read the database, so it resolves the name through a job-scoped API route. That route returns the resolved connection, which is why it requires a job token: a running job already holds those credentials in its rendered `profiles.yml`, and a browsable route would hand them to anyone. The same worker posts its per-model outcomes to a second job-scoped route, since the live reporter tails a log straight into the database and cannot run there. An agent's run page therefore fills in when the run ends rather than during it. Both routes are posted with the JOB's token: an agent's own credential authenticates only against the agent surface. ## Decision 23: the descriptor is optional, and lives inside the project `